Skip to content

[Security / Storage] Insecure plaintext storage and broken Windows file permissions for Telegram bot token in mavis local-runtime #141

Closed as not planned

Description

@fotedev

Security Disclosure: Plaintext Storage of Telegram Bot Tokens in Mavis Local-Runtime

Researcher: fotedev
Contact: via GitHub @fotedev
Date: 2026-09-12
Vendor: MiniMax (Mavis desktop product)
Affected component: @mavis/local-runtime (bundled in MiniMax Code desktop app, shipped as app.asar)
Affected versions observed: @mavis/local-runtime@0.2.1 (bundled with MiniMax Code, build date 2026-09-09)


Summary

The Mavis desktop runtime (@mavis/local-runtime) stores Telegram bot tokens, Feishu app secrets, WeChat iLink credentials, and OAuth bearer tokens as plaintext files inside the user's data directory. The runtime attempts to lock them down with fs.chmod(0o600) after writing, but that call is silently ignored on Windows NTFS, so any other interactive user on the same machine can read the bot token. The legacy credential files imported by the one-time legacy-im-migration are also never deleted after migration, leaving an additional plaintext copy behind.

This is not a remote-execution flaw. It is a local information disclosure with material blast radius: any local account (including low-privilege ones) can read an arbitrary user's bound Telegram bot token and impersonate that bot to every chat it has been added to.

Vulnerability classification

  • CWE-256 — Plaintext Storage of a Password
  • CWE-732 — Incorrect Permission Assignment for Critical Resource
  • CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor

Estimated CVSS v3.1: 5.5 (Medium) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N. Local, low-privilege, no user interaction; high confidentiality impact on the bound bot token.

Affected files and code paths

All paths below are inside the bundled Electron archive at:

<MINIMAX_INSTALL_DIR>/resources/app.asar
└── node_modules/@mavis/local-runtime/
    ├── src/channels/telegram.ts             # active store
    ├── src/channels/legacy-im-migration.ts  # one-time forward migration
    └── src/channels/adapters/telegram/      # consumer
└── node_modules/@mavis/oauth-core/
    └── dist/credential-store/file-store.js  # sibling OAuth store

Active store — writes the bot token in plaintext

LocalTelegramChannelStore.save() in src/channels/telegram.ts:

private async save(): Promise<void> {
  const payload: LocalTelegramChannelFile = {
    schemaVersion: 1,
    bindings: Object.fromEntries(
      [...this.bindings.entries()].sort(([left], [right]) => left.localeCompare(right)),
    ),
  };
  await mkdir(dirname(this.filePath), { recursive: true });
  await writeFile(this.filePath, yaml.dump(payload, { lineWidth: 120, noRefs: true }), {
    encoding: 'utf8',
    mode: 0o600,
  });
  // Explicit chmod — on some filesystems (e.g. FAT32 mounts inside CI) the
  // `mode` option is silently ignored.
  await chmod(this.filePath, 0o600);
}

private get filePath(): string {
  return join(this.dataDir(), 'telegram-channel.yaml');
}

The mode: 0o600 and the follow-up chmod(0o600) are no-ops on Windows NTFS. NTFS ignores POSIX mode bits; fs.chmod on Windows can only toggle the read-only attribute. The file inherits the parent directory's DACL, which on most installations grants Read to the local Users group (every interactive account on the host).

Resulting on-disk files (verified on a test install):

$MINIMAX_DATA_DIR/telegram-channel.yaml             # active store, botToken plaintext
$MINIMAX_DATA_DIR/credentials/mavis/telegram.json   # legacy copy, botToken plaintext
$MINIMAX_DATA_DIR/auth.json                         # OAuth bearer + refresh, FileStore path

Legacy copy is never removed after migration

src/channels/legacy-im-migration.ts reads the legacy JSON and migrates into the new YAML store:

async function migrateTelegram(options, agent, result): Promise<void> {
  const cred = await readLegacyCredential(options.dataDir, agent, 'telegram');
  if (!cred) return;
  const botToken = readFirstString(cred, ['botToken', 'bot_token', 'token']);
  if (!botToken) { result.skipped += 1; return; }
  if (await options.telegramStore.get(agent)) {
    result.skipped += 1;
  } else {
    await options.telegramStore.bind({ agentName: agent, botToken, /* ... */ suppressFamilyMutation: true });
    result.migrated += 1;
  }
  await ensureRoute(options, 'telegram', agent, telegramClientId(agent), result);
}

There is no unlink of the legacy file. It remains on disk with its original permissions.

Sibling OAuth store has the same flaw, explicitly

@mavis/oauth-core/dist/credential-store/file-store.js:

async assertPrivatePermissions() {
    if (process.platform === 'win32')
        return;                          // ← silent skip on Windows
    try {
        const [directory, file] = await Promise.all([stat(this.authHome), stat(this.path)]);
        if ((directory.mode & 0o077) !== 0 || (file.mode & 0o077) !== 0) {
            throw new CredentialStorePermissionError();
        }
    } catch (error) { ... }
}

The win32 early return makes the permission guarantee a fiction on the most common desktop OS.

Impact

  1. Token theft. Any local user (including a low-privilege service account) on the host can cat $MINIMAX_DATA_DIR/telegram-channel.yaml and obtain the bot token. The same is true for the OAuth bearer/refresh token in auth.json.
  2. Bot impersonation. A stolen Telegram token gives full access to the bot's conversations, files, and inline keyboards. If the bot has been added to group chats, the attacker can read and post in them until the token is revoked.
  3. Lateral exposure. If the same host is used by multiple people (shared workstation, RDP host, kiosk mode), every user with a local account is implicitly in scope.
  4. Persistence. The legacy credentials/<agent>/<platform>.json file is never deleted after migration, doubling the on-disk footprint and remaining readable by other local users.

Steps to reproduce (anonymised)

On any host with Mavis / MiniMax Code installed and a Telegram bot bound via the channel UI:

# 1. Locate the user data dir (default on Windows: %USERPROFILE%\\.minimax)
ls "$USERPROFILE/.minimax/"

# 2. Confirm a world-readable copy of the bot token exists
ls -l "$USERPROFILE/.minimax/telegram-channel.yaml"
# expected (MSYS): -rw-r--r--   # mode bits 0644, inherited Users-group DACL

# 3. Confirm the legacy copy is still present after migration
ls -l "$USERPROFILE/.minimax/credentials/<agent>/telegram.json"
# expected (MSYS): -rw-r--r--

# 4. Grep for a Telegram bot token format (digit:base64)
grep -E '"botToken"\\s*:\\s*"[0-9]+:[A-Za-z0-9_-]+"' \\
     "$USERPROFILE/.minimax/telegram-channel.yaml" \\
     "$USERPROFILE/.minimax/credentials/<agent>/telegram.json"

No secret values are reproduced in this report.

Recommended remediation

A complete fix touches the Mavis monorepo (@mavis/local-runtime, @mavis/oauth-core). Suggested order:

  1. Move secrets out of plaintext files. Default to an OS keyring (Windows Credential Manager / macOS Keychain / libsecret) via a thin cross-platform wrapper (@napi-rs/keyring is a known-good choice). Make the plaintext file path a fallback that emits a one-shot startup warning.
  2. Persist a reference, not the secret. LocalTelegramBindingRecord carries a botTokenRef instead of botToken; the per-call sender factory resolves the reference through the keyring.
  3. Fix FileStore.assertPrivatePermissions() on Windows. Drop the process.platform === 'win32' early return. Apply an explicit NTFS DACL via icacls or a Node native addon (e.g. @napi-rs/win32-acl), and fail startup when the resulting ACL still grants read access to non-owners.
  4. Shred the legacy file on successful migration. After legacy-im-migration.ts upserts into the new store, overwrite the source JSON with zeros then fs.unlink it. Add a legacy-shred-at marker so re-runs are no-ops.
  5. Run the migration once on existing installs. A cleanupTokenMigrationV1 flag, set when the user next opens the desktop app, decrypts the on-disk YAML in memory, moves each token into the keyring, and rewrites the YAML with botTokenRef only.
  6. Audit other channels. The same pattern applies to LocalFeishuChannelStore and LocalWeChatChannelStore, and to @mavis/oauth-core's OAuth credential file. Verify each one before declaring the fix complete.

A sketch diff (illustrative, not drop-in) for the active store:

--- a/src/channels/telegram.ts
+++ b/src/channels/telegram.ts
@@ class LocalTelegramChannelStore
-      botToken: input.botToken.trim(),
+      // Persist an opaque reference; the real token lives in the OS keyring.
+      botTokenRef: await this.credentialRef.put({
+        agentName,
+        service: 'telegram',
+        account: clientId,
+        secret: input.botToken.trim(),
+      }),

And for the legacy shred step:

--- a/src/channels/legacy-im-migration.ts
+++ b/src/channels/legacy-im-migration.ts
@@ async function migrateTelegram(...)
     await options.telegramStore.bind({ ... });
+    await shred(join(options.dataDir, 'credentials', agent, 'telegram.json'));
     result.migrated += 1;

Suggested coordinated disclosure timeline

  • T+0 (now): report sent to GitHub via this issue.
  • T+30: suggested public disclosure date if a fix is in progress, or sooner if you prefer.
  • T+90: hard deadline for public disclosure if no fix has shipped.

I am happy to coordinate timing, validate candidate fixes against a throwaway install, and to keep the technical details confidential until a release window is confirmed.


No real bot token values are included; on-disk file paths shown are intentionally parameterised.

Activity

  1. added
    bugSomething isn't working
    desktopMiniMax Code desktop app
    needs-triageAwaiting maintainer assessment
    and removed
    desktopMiniMax Code desktop app
    on Sep 19, 2026
  2. hetaoBackend commented on Sep 21, 2026

    @hetaoBackend
    Collaborator

    This was generated by AI during triage.

    Thank you for the responsible, detailed disclosure. Acknowledged on both counts:

    1. POSIX: config/credential writes are being tightened to owner-only modes, but coverage should be audited across all credential files;
    2. Windows: file-mode calls don't provide real ACL protection — DPAPI or explicit ACL restriction is the right direction, and at-rest encryption (OS keychain/DPAPI) for IM tokens is a valid ask.

    Forwarded to security review. For future sensitive findings, feel free to note in the issue that details are available privately and we'll provide a channel.

  3. Ralle1976 commented on Sep 23, 2026

    @Ralle1976
    Contributor

    Credential-write audit as requested in the triage ("coverage should be audited across all credential files") — every row read at the cited path:line against current main. Data only; the Windows DPAPI-vs-ACL design call stays yours.

    Two helpers exist and cover part of the surface:

    • packages/config/src/private-config-file.ts:16 writePrivateConfigFileSync (+ restrictConfigFileSync:7, which returns early on win32 at :8) — used by config.ts, byok-config.ts, cu-backend-io.ts, and the migration backups.
    • packages/oauth-core/src/fs/atomic-write.ts:9 atomicWritePrivateFile (+ ensurePrivateDirectory:4 — the only helper that does dir 0o700) — used by oauth-core file-store.ts:80,119 and state-store.ts:140.

    Everything else re-implements its own temp+chmod dance (or does nothing). Inventory of secret-bearing writes:

    Write site Secret Handling Verdict
    channels/telegram.ts:234-241 Telegram botToken writeFile mode 0o600 + chmod 0o600, mkdir without mode file covered (POSIX); dir-0700 missing
    channels/feishu.ts:285-290 Feishu appSecret/verificationToken/encryptKey same same
    channels/wechat.ts:303-308 WeChat iLink tokens same same
    channels/owner-store.ts:269-274 owner↔client binding same same
    channels/durable-yaml.ts:65,72,81 (→ channel-routes.yaml, channel-bindings.yaml) IM binding state temp open wx 0o600 + rename; mkdir :65 without mode dir-0700 missing
    channels/access-control-store.ts:382-383 owner allow/deny policy writeFile, no mode, no chmod nothing
    config/local-runtime-auth-context.ts:76-92 OAuth accessToken mkdir 0o700 + tmp 0o600 + chmod covered (POSIX)
    tui/src/auth/storage.ts:272-286,359-368 OAuth accessToken mkdir 0o700 + 0o600 + chmod covered (POSIX)
    tui/src/auth/identity-storage.ts:143-150 account identity same covered (POSIX)
    local-runtime-v2/.../miniapp/workspace-storage.ts:395 init marker token wx 0o600, no chmod, mkdir without mode partial
    local-runtime-v2/src/infra/runtime-owner/identity.ts:88-90 lease startToken wx 0o600, no chmod partial
    tui/src/auth/region-preference.ts:40-53 region pref (non-secret) 0o600 + rename, no chmod partial

    Already test-covered (packages/config/test/config-file-permissions.test.ts, note: describe.skipIf(process.platform === "win32")): the five config.yaml writers (config.ts:1655,1851-1919, byok-config.ts:247,495-513, cu-backend-io.ts:54-55, local-model-provider-write.ts:230-268, tui-status-line-write.ts:17-43, local-runtime/config/update.ts:82-305). Non-secret writers with only cosmetic gaps: model-cache.ts:154-161, review-link/store.ts:177-181.

    Legacy-migration leftovers (as the report states):

    • channels/legacy-im-migration.ts reads credentials/<agent>/{feishu,telegram,wechat}.json (:166, :186) and its imports are readdir/readFile only — no unlink anywhere in the file, so migrated plaintext copies survive and are re-scanned every repair pass (listLegacyImCredentialCandidates:116-133).
    • persistence/migration/agent-name-conflict-migration-backup.ts:114-122 cpSync-copies credentials/<agent> into the backup dir — a second plaintext copy that is likewise never deleted.

    Windows note (confirming the disclosure): every 0o600 above is either explicitly skipped on win32 (private-config-file.ts:8, atomic-write.ts:6,23) or an unconditional no-op; there is no DPAPI or ACL code anywhere in packages/ (grep for chmod/ACL/DPAPI), so all of these files inherit the parent directory ACL with no owner-only restriction.

    Summary counts: 12 secret-bearing write sites — 5 covered (POSIX) via the two helpers, 5 pattern-covered but untested and missing dir-0700 or chmod, 2 with partial protection, 1 (access-control-store.ts) with none. Not found in the public source: legacy credentials/<agent>/*.json writers (only readers/migrators exist here).

  4. Ralle1976 commented on Sep 26, 2026

    @Ralle1976
    Contributor

    Follow-up research to the write-site audit comment above (2026-09-23): a full storage & credential write-site inventory — channel stores, legacy migration, auth storage, and the composition layer — with file:line evidence for ≈145 write sites plus 10 consistency findings (F1–F10) proposed as follow-ups. Read-only analysis of the public source at ci/windows-package-acceptance @ b75ff66; no repository files were changed (9/9 spot-checks re-verified verbatim before posting).

    One note on the draft below: its "publication target to be decided" line refers to the choice between this thread and a new data-safety issue — resolved here in favor of this thread so the #141 audit line stays in one place. F1–F10 are tabled at the end with suggested handling; happy to split them into dedicated issues if you prefer them triaged that way.


    Storage & Credential Write-Site Inventory — channel stores, legacy migration, auth

    Read-only analysis of MiniMax-AI/minimax-code public source. Provenance: branch ci/windows-package-acceptance, HEAD b75ff66, analyzed 2026-09-26 (Windows 11 x64 26200, Node 24, no Developer Mode). Every row is file:line evidence; 9/9 spot-check samples were re-verified verbatim against the source before publishing this table. No repository files were changed by this analysis.

    Scope (one inventory across four areas): (A) channel store write sites (telegram/feishu/wechat/owner/route + adjacent stores), (B) legacy migration startup + receipts incl. a deletion-safety check ("confirm no deletion of legacy files"), (C) oauth/tui auth storage + local-runtime-v2 credential writes (+ v1 parallels), (D) host-channel-composition.ts / channel-context.ts. Prepared as research material in the #141-audit tradition; publication target to be decided (follow-up research comment or a new data-safety issue).


    Key facts (TL;DR)

    1. All channel stores live in packages/local-runtime/src/channels/ — 5 named stores (feishu, telegram, wechat, owner, route) + 4 adjacent (bindings, im-connections, access-control, outbound-audit). agent-modules/agent-core/mcode-tools-host contain no store code.
    2. Legacy migration is copy-not-move: no legacy files are deleted in the normal migration path. Five documented destructive exceptions exist (session-delete row cleanup inside the legacy DB, name-collision rename with mandatory full backup, one lazy memory-file rename, v2-derivative cache cleanup, destructive SQL confined to the v2 DB copy). See Part B for the full exception table.
    3. Four credential write families: oauth-core auth.json (atomic 0o600), TUI cli-auth/* matrix + shared projection, config.yaml BYOK keys (plaintext, 0o600), and v1 channel YAMLs (botToken/appSecret plaintext, 0o600). auth-state.json is token-free by an explicit regex guard.
    4. host-channel-composition.ts is pure wiring (zero store writes); channel-context.ts is a pure 22-line mapper. All persistence in the composition layer goes through session/conversation-DB lifecycle ports, not channel YAML.
    5. Consistency risks (F1–F8): 5 stores use non-atomic whole-file writeFile rewrites while 3 sibling stores use temp+fsync+rename; 3 stores still have the async load race feishu already fixed; credential-store mutators have no write lane; access-control.yaml is saved without 0o600; channel-routes.yaml is rewritten on every boot.

    Part A — Channel store write inventory

    Base path B = packages/local-runtime/src/. Store state keys: feishu per bare agentName (feishu.ts:823-825), telegram per telegram:<agent> (telegram.ts:973-975), wechat per wechat:<agent> (wechat.ts:996-998), owner per clientName, route global (schemaVersion:2).

    A.0 Store overview

    Store Definition State held Persists to Save mechanism
    LocalFeishuChannelStore B/channels/feishu.ts:75 appId/appSecret/verificationToken/encryptKey/botName, connected, enabled, mode webhook|websocket|mock, timestamps <dataDir>/feishu-channel.yaml (:293-295) save() :278-291 plain writeFile :286 + chmod 0o600 :290
    LocalTelegramChannelStore B/channels/telegram.ts:66 botToken, botName, connected, enabled, mode mock|sdk <dataDir>/telegram-channel.yaml (:244-246) save() :227-242 plain writeFile :235 + chmod :241
    LocalWeChatChannelStore B/channels/wechat.ts:86 botToken / pending:<session>, ilinkBotId, baseUrl, webhookToken, botName, bindSessionId, qrcodeToken/qrcodeUrl, getUpdatesBuf (long-poll cursor), connected, enabled, mode mock|polling <dataDir>/wechat-channel.yaml (:311-313) save() :296-309 plain writeFile :304 + chmod :308
    LocalChannelOwnerStore B/channels/owner-store.ts:85 per clientName {ownerSenderId, ownerName?, bootstrappedAt} (first-DM owner gate) <dataDir>/channel-owner.yaml (:277-279) save() :262-275 plain writeFile :270 + chmod :274
    LocalChannelRouteStore B/channels/route-api.ts:199 rules[] (id/platform/match/target/enabled/priority/requireMention) + defaultRoute per platform (:55-59) <dataDir>/channel-routes.yaml (:70, filePath :212) mutate() :532-548 → mutateDurableYaml (atomic)
    LocalChannelBindingStore B/channels/infra.ts:265-745 per-chat route cache bindings + messageFilters <dataDir>/channel-bindings.yaml (:742-744) mutate() :686-698 → mutateDurableYaml (atomic)
    LocalImConnectionStore B/channels/im-connection-store.ts:68 connections, physicalBindings (cursor = updatedAt generation), legacyImMigrationTombstones same file channel-bindings.yaml (:188) mutateDurableYaml (atomic, per-file lane)
    LocalAccessControlStore B/channels/access-control-store.ts:153 per ${platform}:${clientName} ACL policy <dataDir>/access-control.yaml (:386-388) save() :373-384 plain writeFile :383, no chmod
    LocalChannelOutboundStore B/channels/runner.ts:66-146 outbound message audit log (append-only) <dataDir>/channel-outbound-messages.yaml (:143-145) append

    Scratch attachments: <dataDir>/tmp/im-attachments/<scope>/<ts>-<file> (wechat.ts:1413-1426). Legacy seed source for imports: <dataDir>/credentials/<agent>/{feishu,telegram,wechat}.json (legacy-im-migration.ts:186).

    A.1 LocalFeishuChannelStore write sites

    file:line operation what is written calling context
    feishu.ts:146-147 bindings.set + save() full credential record (upsert) bind()
    feishu.ts:163-164 bindings.delete + save() row removal unbind()
    feishu.ts:205-206 bindings.set + save() cloned credential group cloneBindingForAgent()
    feishu.ts:221-222 bindings.set + save() enabled + updatedAt setEnabled()
    feishu.ts:278-291 full-file rewrite entire sorted bindings map private save()
    feishu.ts:505 store.bind binding record from HTTP body LocalFeishuChannelApi.bindBody
    feishu.ts:554 store.unbind delete row LocalFeishuChannelApi.unbindBody
    adapters/feishu/feishu-adapter.ts:407 store.bind binding record FeishuPlatformAdapter.bind
    adapters/feishu/feishu-adapter.ts:444 store.unbind delete row FeishuPlatformAdapter.unbind
    primary-agent-family-platforms.ts:78 cloneBindingForAgent cloned creds (staged enabled) port.cloneCredential (feishu)
    primary-agent-family-platforms.ts:80 setEnabled enable flag port.setEnabled (feishu)
    primary-agent-family-platforms.ts:82 unbind({suppressFamilyMutation:true}) delete port.deleteCredential (feishu)
    legacy-im-migration.ts:268 bind({…, suppressFamilyMutation:true}) migrated legacy creds migrateFeishu

    Indirect drivers: channel-bind-routes.ts:80 (bindFeishuConnect), :146 (bindFeishu), :128 (disconnectChannel), :193 (unbindFeishu); feishu-onboard.ts:373 (bindBot); primary-agent-family-reconcile-runner.ts:145-161,338,391 (port ops); primary-agent-family-unbind-cleanup.ts:101.

    A.2 LocalTelegramChannelStore write sites

    Internal mutators: bind telegram.ts:116-117, unbind :131-132, cloneBindingForAgent :169-170, setEnabled :185-186, save() :227-242.

    file:line operation what is written calling context
    telegram.ts:580 store.bind record (after live getMe token verify :537-578) LocalTelegramChannelApi.bind
    telegram.ts:611 store.unbind delete row LocalTelegramChannelApi.unbind
    adapters/telegram/telegram-adapter.ts:236 store.bind record TelegramPlatformAdapter.bind
    adapters/telegram/telegram-adapter.ts:342 store.unbind delete row TelegramPlatformAdapter.unbind
    primary-agent-family-platforms.ts:98 cloneBindingForAgent cloned token group port.cloneCredential (telegram)
    primary-agent-family-platforms.ts:100 setEnabled enable flag port.setEnabled (telegram)
    primary-agent-family-platforms.ts:102 unbind({suppressFamilyMutation:true}) delete port.deleteCredential (telegram)
    legacy-im-migration.ts:305 bind({…, suppressFamilyMutation:true}) legacy botToken (deliberately bypasses getMe) migrateTelegram

    Indirect drivers: infra.ts:2032/:2036 (legacy HTTP bind/unbind), channel-bind-routes.ts:170/:114, adapters/telegram/telegram-adapter-routes.ts:175/:209, reconcile-runner port ops.

    A.3 LocalWeChatChannelStore write sites

    Internal mutators: cloneBindingForAgent wechat.ts:134-135, setEnabled :150-151, bind :200-201, unbind :219-220, setGetUpdatesBuf field-delete :255 / set :256 / save :257, save() :296-309.

    file:line operation what is written calling context
    wechat.ts:780 store.bind direct-token record (connected:true) LocalWeChatChannelApi.bindBody
    wechat.ts:805 store.unbind delete row LocalWeChatChannelApi.unbind
    adapters/wechat/wechat-onboard.ts:142 store.bind pending:<session> placeholder + qrcodeToken/qrcodeUrl (connected:false) startBindReal (QR issuance)
    adapters/wechat/wechat-onboard.ts:292 store.bind confirmed botToken/ilinkBotId (connected:true) finaliseBinding (from pollBindStatusReal :248)
    adapters/wechat/wechat-adapter.ts:169 store.bind direct-token record LocalWeChatChannelAdapter.bind
    adapters/wechat/wechat-adapter.ts:356 store.unbind delete row LocalWeChatChannelAdapter.unbind
    adapters/wechat/wechat-monitor-wire.ts:295 setGetUpdatesBuf long-poll resume cursor (fire-and-forget .catch) disconnectedListener
    api/host-channels.ts:1926 setEnabled(agentName,false) rollback disable after monitor-start failure onBindFinalised hook
    primary-agent-family-platforms.ts:118/120/122 clone / setEnabled / unbind clone / flag / delete port ops (wechat)
    legacy-im-migration.ts:338 bind({…, suppressFamilyMutation:true}) legacy creds migrateWeChat

    Indirect drivers: channel-bind-routes.ts:214/:215/:230/:119/:253, infra.ts:2044, reconcile-runner port ops.

    A.4 LocalChannelOwnerStore write sites

    Internal mutators: checkAndBootstrap insert+save owner-store.ts:138-139, clear delete+save :166-167, rekeyPrimaryFamily move/drop+save :212-217, save() :262-275.

    file:line operation what is written calling context
    access.ts:115 checkAndBootstrap insert owner on first DM (else compare-only) checkChannelAccess (owner gate)
    infra.ts:1007 checkAndBootstrap owner claim preflightInbound (AC-pipeline path)
    feishu.ts:564, telegram.ts:621, wechat.ts:813, adapters/telegram/telegram-adapter.ts:347, primary-agent-family-unbind-cleanup.ts:58 clearOwnerAllConventions (owner-store.ts:71-83 → clear :80) delete owner keys (both key conventions) unbind flows + family cleanup
    primary-agent-family-state.ts:89 rekeyPrimaryFamily move/dedupe owner records (both conventions :90-97) migratePrimaryFamilyState mode 'apply'

    A.5 LocalChannelRouteStore write sites (channel-routes.yaml)

    All mutators funnel through mutate() route-api.ts:532-548 → mutateDurableYaml: addRule push :308, updateRule replace :344, deleteRule splice :353, updateDefaults :385, rekeyPrimaryFamily rewrite/drop :513-523, migrateToV2 target rewrite :258-289.

    file:line operation what is written calling context
    route-api.ts:143 updateRule rule fields (match/target merge) routeLocalChannelRouteApi (PUT rules/:id)
    route-api.ts:149 deleteRule rule removal DELETE rules/:id
    route-api.ts:164 addRule new rule POST rules
    route-api.ts:175 updateDefaults defaultRoute per platform PUT defaults
    legacy-im-migration.ts:399 addRule rule legacy-<platform>-<agent> ensureRoute (legacy import, only when no rule exists :377)
    rootless-startup-migration.ts:179 migrateToV2 enrich all targets (owner/projectKey/routingMode) migrateRootlessChannelState (startup, every boot — see F6)
    primary-agent-family-state.ts:72 rekeyPrimaryFamily rewrite clientName/agentId (+ defaults :517-523) migratePrimaryFamilyState
    primary-agent-family-unbind-cleanup.ts:55 deleteRule (loop) remove rules with match.clientName === clientId cleanupPrimaryFamilyUnbound

    A.6 Adjacent stores — write sites (condensed)

    LocalChannelBindingStore (channel-bindings.yaml bindings/messageFilters, via mutate() infra.ts:686-698):

    file:line operation calling context
    channel-route-resolver.ts:94 upsert resolveWithLegacyFallback (legacy route creation)
    rootless-route-resolver.ts:236-237 advanceProjectMain / compareAndSet bindExplicit
    rootless-route-resolver.ts:261 attachProjectMain publish (project-main attach)
    rootless-route-resolver.ts:328-329 advanceProjectMain / compareAndSet publish (new cursor)
    infra.ts:1313 deleteByAgent routeAgentIm (DELETE agent/:name/im)
    infra.ts:1984 setMessageFilter PUT message-filter/:clientId
    infra.ts:2080 upsert POST bindings
    infra.ts:2090 deleteByKey DELETE bindings/:key
    api/host.ts:1297 deleteBySession deleteChannelBindingsForSession ← local-runtime-v2/src/compat/v1/session.ts:201 (session deletion)
    default-channel-binding.ts:50 deleteByKey (loop) deleteAgentPlatformChannelBindings
    feishu-onboard.ts:408 upsert bindDefaultSession (legacy no-connection path)
    rootless-startup-migration.ts:230 migrateToV2 startup enrichment
    primary-agent-family-state.ts:79 rekeyPrimaryFamily family migration
    primary-agent-family-unbind-cleanup.ts:57 deleteByAgentPlatform unbind cleanup

    (pin infra.ts:449, pinAllForAgent :463, migratePinnedSession(ForAgents) :488/:497 have no production callers — documented legacy compat API :484-487; see F7.)

    LocalImConnectionStore (channel-bindings.yaml connections/physicalBindings/legacyImMigrationTombstones, all via mutateDurableYaml): prepareImConnection im-connection-store-connection-operations.ts:67-157; clearImPendingLegacyMigration im-connection-store-operations.ts:133-146; bindImConnection im-connection-store-binding-operations.ts:260-325; deleteImBinding :355-372; moveImBindingSessionIfCurrent :132-180; advanceImBinding im-connection-store-conversations.ts:69-89; ensureImBindingSession :125-139; deleteImConnection (incl. tombstone write :269-280, deleteMessageFilter :278) im-connection-store-connection-operations.ts:255-282; repairTrustedImLegacyRoutes im-connection-store-legacy-operations.ts:173-218; migrateExistingImBindingConversationState :305-361. External drivers: channel-connection-binding.ts:266/:163/:170/:191/:209/:279/:294/:196/:143; channel-bind-routes.ts:77/:143/:167/:208/:270/:260/:267; feishu-onboard.ts:133/:183/:395/:211/:477/:480; channel-binding-target-route.ts:87; api/host-channels.ts:934 (migrateLegacy); cursor advance via channel-route-resolver.ts:293/:310.

    LocalAccessControlStore (access-control.yaml): set access-control-store.ts:193-208, delete :210-217, ensureDefaultPolicy :237-246 (never overwrites :241). Callers: access-control-routes.ts:124 (PUT), :149 (DELETE); default-channel-binding.ts:31, api/host-channel-restore.ts:141, feishu-onboard.ts:419 (seeds); primary-agent-family-state.ts:142/:146-147 (rekey set+delete); primary-agent-family-unbind-cleanup.ts:64.

    LocalChannelOutboundStore (append-only audit): append at feishu-outbound-client.ts:85, telegram.ts:320 (recordOutbound, called :300/:302), adapters/wechat/wechat-adapter.ts:390/:467/:482/:571/:672; clear runner.ts:116-122 (no production caller found).

    A.7 Shared write helpers

    helper file:line serves used by
    mutateDurableYaml + writeFileDurably (same-dir temp + file fsync + atomic replace + best-effort dir fsync; per-file mutationLane serialises durable-yaml.ts:8) channels/durable-yaml.ts:25-44, :59-92, :46-56 route, binding, IM connection stores route-api.ts:537, infra.ts:689, all IM-store mutators
    clearOwnerAllConventions (+ imGatewayInboundClientName) channels/owner-store.ts:71-83, :52-57 owner store (both key conventions) feishu/telegram/wechat unbind + telegram-adapter + family cleanup
    mergeChannelBindingsIntoDocument + validChannelBindingRawKeys channels/channel-binding-codec.ts:109-151, :93-107 binding store (preserves unknown raw rows, schemaVersion ≥2) infra.ts:690/:694
    writeBindingData / readBindingData / writeLegacyImMigrationTombstones / deleteMessageFilter im-connection-store-data-codec.ts:37-61, im-connection-store-data.ts:68-92, :219-225 IM store (schemaVersion ≥3 data-codec.ts:41) all IM mutators (writeConversationData data.ts:115 is dead — F7)
    attachProjectMainBinding / advanceProjectMainBindings channel-binding-project-main.ts:11-37, :39-97 binding store in-memory map infra.ts:321-330, :338-347
    PrimaryAgentPlatformPort (cloneCredential/setEnabled/deleteCredential → store mutators) primary-agent-family-platforms.ts:56-125 all 3 credential stores primary-agent-family-reconcile-runner.ts:145-391, primary-agent-family-unbind-cleanup.ts:101
    ChannelFamilyMutationHook write seam (isPrimaryFamilyAgent pre-save / afterMutation post-save) type channel-family-mutation.ts:32-50; impl primary-agent-family-reconciler.ts:131-255; installed api/host-channels.ts:733-744 all 3 credential stores (setFamilyMutationHook feishu.ts:93, telegram.ts:77, wechat.ts:102) fires inside every bind/unbind

    A.8 Notable behavior (Part A)

    • Every credential-store save() is a whole-file rewrite (sorted map → YAML): last-writer-wins per file. bind() is a full-record upsert that rotates credentials in place and preserves createdAt (feishu.ts:143, telegram.ts:113, wechat.ts:197).
    • WeChat had a real overwrite bug (auto startBind clobbering a confirmed binding back to pending:) — guard documented at wechat-onboard.ts:105-129.
    • Deletions in this layer: unbind() row deletes (3 stores), owner clear() on unbind (both key conventions), deleteRule loop on unbind cleanup, deleteByAgentPlatform, and IM deleteImConnection (which deletes connection+binding and writes a migration tombstone so stale legacy rows cannot recreate it, im-connection-store-connection-operations.ts:269-280).
    • Host rollback write: wechatStore.setEnabled(false) when the post-scan monitor fails to start (host-channels.ts:1926) before surfacing 503.
    • Two independent writer families share channel-bindings.yaml (BindingStore + ImConnectionStore) but both funnel through mutateDurableYaml's per-file lane; section merges preserve foreign sections and schemaVersion is max-preserved (channel-binding-codec.ts:142-148, im-connection-store-data-codec.ts:41).
    • channel-routes.yaml is written only by LocalChannelRouteStore; the IM store merely reads imConversations from it once (im-connection-store-legacy-operations.ts:304).
    • PrimaryAgentFamilyReconciler holds an in-process lock per canonical:platform (primary-agent-family-reconciler.ts:94/:311-323), but the four stores it migrates cannot be written atomically — mitigated only by inspect-then-apply two-phase (primary-agent-family-state.ts:49-56).

    Part B — Legacy migration startup, receipts, deletion safety

    "Legacy" here = v1 data: <dataDir>/local-runtime/**, <dataDir>/sqlite.db, agents/, credentials/, channel YAMLs, ~/.mavis, opencode DBs. The v2 DB v2/sqlite/runtime-state.sqlite counts as target (a copy), not legacy source.

    B.1 Startup flow (entry points & trigger order)

    # Step Entry (file:line) When What
    L0 Config dataDir migration ~/.mavis → ~/.minimax packages/config/src/data-dir.ts:384 (wrappers :395/:399) every process start, one-shot effect moves/links legacy dataDir (§B.3)
    L0b BYOK provider migration packages/config/src/config.ts:1921-1923 → byok-config.ts:178 every config load, flag-gated (config.ts:1906-1910) reads config.json (:184), backup copy (:513), provider-list rewrite
    L1 v2-layout migration (copy v1 stores → v2/) packages/local-runtime-v2/src/runtime.ts:174-179 → local-runtime/src/persistence/migration/v2-migration.ts:152-160; second caller local-runtime/src/api/host.ts:503 every start, before DB-open (runtime.ts:171-173), idempotent copies + asset-ref rewrite + root-dedup (§B.4)
    L2 v2 DB init (schema migration + legacy recovery) runtime.ts:191-197 → local-runtime-v2/src/infra/db/initialize.ts:60-147 every start; per-migration once (readAppliedVersions :202-223) backup if pending (:76-80) → disableLegacyCronsWithoutFutureRun (:83-86) → migrations <7 → recoverProjectPreferencesBeforeBackfill (:88-91) → recoverLegacySessionRecordsBeforeBackfill (:92-95) → migrations <15 → recoverSessionProjectMetadataBeforeRepair (:97-100) → recoverPublishedCronQueryCollapseCollision (:101-105) → all migrations (:106) → schema check (:109)
    L2b legacy-copy schema migrations migrations/cron/migration-0002-copy-legacy-cron-data.ts:7-128, migration-0004-copy-legacy-cron-run-history.ts:5-9 one-shot per version copies local_runtime_crons / _cron_session_history / purpose sessions into local_runtime_v2_scheduler_jobs / _cron_definitions / _cron_runs
    L3 agent cutover (cross-process lock) runtime.ts:208-227 → local-runtime-v2/src/service/agent/composition.ts:182-324; lock compat/v1/runtime.ts:125-201 → agent-name-conflict-migration.ts:191-241 (proper-lockfile on dataDir) every start (gates); import/collision once per dataDir target_preflight (composition.ts:232-247, fail-open :242/:484-496) → runGatedLegacyBootstrap (:248-257, :453-476) → builtin-name-conflict repair (:266-282) → owner_create (:284-299) → ensureBuiltinRows() (:302 → agent.service.ts:876-911, identity detach _migration-legacy-identity-detach.ts:110-128) → materializeLegacyCustomAgents() (:308 → legacy-custom-materialization.ts:26-72) → completed (:315)
    L4 channel subsystem (deferred) runtime.ts:321-333, 382-410 → api/host-channels.ts:759-800 every start, work receipt-/state-gated (a) IM credential migration :769-778 → legacy-im-migration.ts (b) migrateRootlessChannelState (c) migrateLegacyImConnectionState
    L5 cron .md import local-runtime/src/cron/legacy-md-migration.ts:92-121 cron start, marker-gated reads <dataDir>/agents/*/crons/<name>.md (:27-32)
    L6 lazy session import service/session-system/legacy-migration/importer.ts:105, wired session-system/owner.ts:459-497 on demand (session queries sessions/query/query-service.ts:70, message reads messages/query/query-service.ts:58 etc., ensureExecutionReady importer.ts:122) reads legacy sessions/messages, writes v2 session + history
    L7 lazy memory migration local-runtime/src/memory/local-memory-store-fs.ts:405-413 on memory access rename of one legacy memory file (§B.3)

    packages/tui / packages/agent-runtime have no legacy-migration startup hooks ("receipt" hits there are turn receipts / update-install receipts).

    B.2 Receipts

    # Receipt Location (file:line) Shape Skip logic
    R1 v2-layout manifest (+ in-memory brand) <dataDir>/v2/migration/manifests/<yyyy>/<mm>/<dd>/current-local-runtime-<stamp>.json (v2-migration.ts:951-961; paths shared/local-runtime-paths.ts:110-112); brand/carrier v2-migration.ts:70,:207-217,:76-78, runtime.ts:53-55,:244-253 V2MigrationManifest (v2-migration.ts:27-55): schemaVersion, assetRefMigrationVersion + DB identity (dev/ino/birthtimeMs), migrationId, status, timestamps, copied[]/skipped[], assetRefsUpdated, errors[], rootSessionsDeduped[] manifest written every run (audit :112/:127); only the asset-ref scan is skipped when isAssetRefMigrationCurrent (:340-355)
    R2 legacy-agent-bootstrap receipt …/manifests/legacy-agent-bootstrap-import-v1.json (legacy-agent-bootstrap-receipt.ts:6,68-70) exact content {"version":1,"kind":"legacy-agent-bootstrap-import"} (:7) isCompleted() (:40-47) exact compare, fail-open (composition.ts:478-496); written via publishFileIfAbsent (:49-65, hard-link, never overwrite); completed ⇒ collision scan + legacy import wholesale-skipped (skipCode:'bootstrap_receipt_completed' :503-516)
    R3 legacy-custom-identity receipt …/manifests/legacy-custom-agent-identity-reconcile-v1.json (legacy-custom-identity-receipt.ts:6,37-39) {"version":1,"kind":"legacy-custom-agent-identity-reconcile"} as R2; gates agent.repository.ts:451 (allowLegacyIdentityRecovery); written after a clean materialization pass (legacy-custom-materialization.ts:104-113)
    R4 builtin name-conflict receipt …/manifests/builtin-agent-name-conflicts-v1.json (v2-builtin-agent-name-conflict-migration.ts:25,94) {schemaVersion:1, migrationId, status:'prepared'|'in_progress'|'completed'|'failed', timestamps, backupDir, mappings[]} (:31-39) completed + 0 collisions → skip (:97-98); completed + new collisions → receipt discarded and re-run (:99-100)
    R5 legacy collision manifest …/manifests/agent-name-conflicts.json (agent-name-conflict-migration.ts:37,266-280) AgentNameConflictManifest (…-manifest.ts:52-71) incl. pinned mappings (:9-13, retries reuse the same suffix :381-384) getCompletedMigrationResult (:351-372): completed → no re-apply
    R6 per-session migration record v2 DB table legacy_migrations (infra/db/schema/legacy-session.ts; repo legacy-migration/repo/migration-repository.ts:16-79) LegacyMigrationRecord (infra/legacy-db/model.ts:195-220): ids, status:'discovered'|'metadata'|'migrated'|'failed'|'deleted', checksums, counts, report, readiness timestamps, warnings isDisplayReady/isHistoryReady (importer.ts:906-922, converter-version gate :918-922) short-circuit re-import; status:'deleted' is a tombstone (importer.ts:132,363)
    R7 history checkpoint v2 DB table file_history_migrations (legacy-migration/repo/history-checkpoint.ts:21-46) {sessionId, source:'ledger-snapshot'|'sqlite-rows'|'sqlite-blob'|'empty', migratedAtMs, messageCount, targetRevision} per session upsert
    R8 IM credential migration flag v2 DB preference legacy-im-credentials-migrated (service/channel-system/legacy-credential-receipt.ts:4-14) boolean preference run(action): true → return (:11); else run + set (:12-13)
    R9 cron .md import marker v2 DB table local_runtime_cron_legacy_imports (cron/legacy-import-store.ts:36-39) (agent_name, cron_name, imported_at_ms), INSERT OR IGNORE marker ⇒ never re-import (legacy-md-migration.ts:34-48, explicitly non-destructive)
    R10 per-item idempotence inside R1 copied[]/skipped[] e.g. *:source-missing, *:target-current, *:target-created-race fs.existsSync checks (v2-migration.ts:740-754, 920-943)

    First start vs. every start: L0–L5 run every start as idempotent checks; genuinely additive/destructive work happens once (R2, R3, R4, R5, R8, R9-gated). L6 is fully lazy per session (R6/R7).

    B.3 DELETION CHECK — "confirm no deletion of legacy files"

    Verdict: CONFIRMED for legacy files in the normal migration path — the import/copy paths are strictly copy-not-move and delete no legacy files. Evidence: VACUUM INTO copy (v2-migration.ts:760-768); copyDirIfMissing copies only missing targets (:932-949); legacy DB readers are readonly:true, fileMustExist:true (infra/legacy-db/agent-source.ts:185, readonly-source.ts:94/249, legacy-preferences.ts:31); cron .md import leaves sources untouched (legacy-md-migration.ts:35-48); importLegacyAgents has "no marker, overwrite, upsert, or dual-write" contract (_migration-legacy-agents.ts:63-71).

    NOT confirmed as an absolute "no deletion of legacy data" — five documented destructive exceptions exist:

    # file:line Operation Target When Legacy data?
    D1 local-runtime-v2/src/infra/legacy-db/cleanup.ts:37 DELETE FROM session_messages WHERE session_id = ? rows in legacy source <dataDir>/sqlite.db only on explicit session delete (importer.ts:139-159 → owner.ts:480-485 → initialize.ts:200); scope contract "never scans or prunes unrelated rows" (cleanup.ts:11-14); errors only logged (owner.ts:468-472) yes (legacy rows), user-initiated, exactly one session
    D2 local-runtime/src/persistence/migration/agent-name-conflict-migration-runtime.ts:288,318; …-backup.ts:300-305,319-339,365-368; …-files.ts:46,60,130 fs.rmSync of v2 snapshot caches; UPDATE renames in <dataDir>/sqlite.db; atomic rewrite of legacy YAML/JSON files (channel-bindings.yaml, feishu/telegram/wechat-channel.yaml, channel-routes.yaml, channel-owner.yaml, access-control.yaml, memory/tracking/*.json, plans/*/plan.json); renameSync of <dataDir>/agents/<from>, <dataDir>/credentials/<from> (…-backup.ts:295-308) legacy dirs + legacy DB + legacy files only on verified name collision (custom rows on explore/worker/verifier, manifest.ts:7,172-195) in the 1× bootstrap (composition.ts:463-469); mandatory full backup first to v2/migration/backups/<date>/<migrationId>/ (…-backup.ts:43-170) yes — in-place rename/rewrite of legacy sources, always after backup
    D3 local-runtime/src/memory/local-memory-store-fs.ts:413 rename(legacy, location.mainPath) one legacy user-memory file lazy, only when target missing (:410) yes — the only move-not-copy (content preserved, source path disappears)
    D4 agent-name-conflict-migration-runtime.ts snapshot deletes; assets/store.ts:343-345,392-394,480-481 (safeRmSync) v2-derivative caches / newly created v2 assets rename rewrite / failed-registration rollback ("only request-scoped assets that never reached durable admission" :473-474) no (v2 derivatives)
    D5 v2 DB schema migrations migrations/plugin/migration-0005-adopt-plugin-state.ts:23, runtime/migration-0026-add-cron-execution-fields.ts:128, session/migration-0007-backfill-session-storage.ts:882,925,935, …-frozen.ts:254,310, session/migration-0008-normalize-turn-ingress.ts:542,813, session/migration-0010-converge-session-queue.ts:63,396 DROP/DELETE destructive SQL exclusively the v2 DB copy v2/sqlite/runtime-state.sqlite one-shot schema migrations no (legacy sources local-runtime/local-runtime.sqlite + sqlite.db untouched; reconcile is additive INSERT OR IGNORE v2-migration.ts:810-813)

    Additional deletion sites outside migration proper (runtime session lifecycle, listed for completeness): history-checkpoint.ts:47-80 and messages/repo/drizzle.ts:546-568 delete/blank converted legacy rows in the v2 DB on session delete; queue/repo/drizzle.ts:550,:562-565 delete legacy queue rows after replace / on session delete. Config data-dir migration removes only links or a verified-empty dir (data-dir.ts:100,165-172,197,310-331), and its EXDEV fallback is copy-then-remove (:227-240).

    B.4 Migration write sites (condensed)

    file:line operation target what
    v2-migration.ts:762,769→858 VACUUM INTO + renameSync v2/sqlite/runtime-state.sqlite full DB copy
    v2-migration.ts:810-813 INSERT OR IGNORE … SELECT v2 DB (all user tables) additive row reconcile
    v2-migration.ts:506-509,561-563 UPDATE … SET jsonColumn local_runtime_message_rows.data_json, local_runtime_queue_items.data_json, local_runtime_messages display/pi-history attachment refs → v2 asset paths
    v2-migration.ts:652-661 → assets/store.ts:352-395 file create + writeFileSync(…asset.json) + assets.jsonl append v2/assets/<yyyy>/<mm>/<dd>/<time>-<assetId>-<name> attachment byte copies (source files remain)
    v2-migration.ts:944 copyFileSync (missing targets only) v2/chats/ledgers/**, v2/chats/snapshots/** ledger/snapshot copies
    v2-migration.ts:960 writeFileSync v2/migration/manifests/<date>/current-local-runtime-*.json R1 manifest
    v2-migration.ts:326 → v2-root-session-dedup.ts:165,182,201 UPDATE … record_json v2 DB root-session dedup/demote (UPDATEs only, no deletes)
    initialize.ts path: legacy-cron-recovery.ts:22-55; legacy-session-recovery.ts:117-168 UPDATE … config_json/record_json v2 DB disable expired crons; repair/degrade session records
    migrations/cron/migration-0002-copy-legacy-cron-data.ts:103-124,305-327 INSERT local_runtime_v2_scheduler_jobs/_cron_definitions/_cron_runs legacy cron copies
    session/migration-0007…:882-935 CREATE/INSERT/DROP/RENAME, DELETE FROM sqlite_sequence local_runtime_projects (v2 DB rebuild) reconstructed project catalog
    _migration-legacy-agents.ts:117-119 tx.insert(agents) v2 DB agents imported legacy agent rows (1:1, timestamps preserved)
    agent-name-conflict-migration-backup.ts:300-305,319-339,365-368; …-files.ts:46,60,130; …-runtime.ts:72-157 UPDATE / atomic rewrite / renameSync legacy DB + legacy files + v2 DB collision renames (D2, backup first)
    agent-name-conflict-migration.ts:413-494 → manifest.ts:129-135 writeJsonAtomic v2/migration/manifests/agent-name-conflicts.json R5 status phases
    agent-name-conflict-migration-backup.ts:50-168,247-257 VACUUM INTO / copyFileSync / cpSync v2/migration/backups/<date>/agent-name-conflicts-<ts>/** full backups (sqlite.db, runtime-state.sqlite, YAMLs, agent/credential dirs, plans, ledgers, memory/tracking)
    v2-builtin-agent-name-conflict-migration.ts:114-135,242-245 receipt writeJsonAtomic + renames + UPDATE definition_json R4 receipt + agents/ dirs + v2 DB + YAML/plan files R4 apply
    legacy-agent-bootstrap-receipt.ts:49-65; legacy-custom-identity-receipt.ts:22-35 publishFileIfAbsent (hard-link, never overwrite — infra/file/jsonl.ts:252-319) R2/R3 receipt files one-time receipts
    _migration-legacy-identity-detach.ts:161,168,269-270; …-backup.ts:201-208 v2 DB row update + profile file writes agents/<name>/ + v2 DB frozen PERSONA/system-prompt renderings, avatar clear only on exact frozen default (:181-219)
    agent.repository.ts:782-808 (writeMaterializedCustomConfig), materializeLegacyAvatar canonical config + avatar writes <dataDir>/agents/<name>/ legacy → canonical materialization
    importer.ts:336,371-383,632,421,447-455,504-551; display-import.ts:93,234-241; history-checkpoint.ts:31-46; migration-repository.ts:54-78 DB upserts / stream replace / asset register / history replace v2 DB + canonical history store lazy session import (R6/R7 writes; markDeleted tombstone :54-78)
    legacy-im-migration.ts:268,305,338,399 store.bind ×3 + routeStore.addRule channel YAML stores + channel-routes.yaml imported IM credentials (additiv, suppressFamilyMutation:true)
    legacy-credential-receipt.ts:13; legacy-import-store.ts:36-39 preference upsert / INSERT OR IGNORE v2 DB R8 flag / R9 markers
    byok-config.ts:513 + rewrite backup config.json + provider rewrite <dataDir>/config.json migrated BYOK providers
    data-dir.ts:86,147,211,259,227 symlinkSync / renameSync / cpSync ~/.mavis ↔ ~/.minimax dataDir migration / legacy link

    Part C — Credential & auth write sites

    C.1 oauth-core + oauth-lease-protocol (+ lease broker)

    file:line operation what is written storage target calling context
    oauth-core/src/credential-store/file-store.ts:67-71 (disk write :119 in writePayload :117-120) put (atomic whole-file rewrite) StoredCredential: accessToken + refreshToken + tokenType/scopes/audience/expiresAtMs/generation/subject/accountId/loginEpoch (types.ts:15-28) <namespaceHome>/auth.json (path :59) FileStore.put — all secrets land here
    oauth-core/src/auth-core.ts:510 credentialStore.put full credential from device grant (access+refresh) auth.json commitLogin
    oauth-core/src/auth-core.ts:522-525 credentialStore.put previous credential (rollback restore) auth.json commitLogin catch path
    oauth-core/src/auth-core.ts:527 credentialStore.delete removes record (rollback without predecessor) auth.json commitLogin catch path
    oauth-core/src/auth-core.ts:715 credentialStore.put refreshed credential (generation+1, loginEpoch kept) auth.json refreshCredential (via getAccessToken :268, handleUnauthorized :298, runLogin :455)
    oauth-core/src/auth-core.ts:349 credentialStore.delete deletes token record auth.json logout({revoke})
    oauth-core/src/auth-core.ts:695 credentialStore.delete deletes after invalid_grant auth.json refreshCredential invalidation path
    oauth-core/src/state-store.ts:138-141 AuthStateStore.write auth state (status/generation/lease with userCode) — no tokens (regex guard SENSITIVE_KEY_PATTERN :41-52) <namespaceHome>/auth-state.json commitLogin :513, refreshCredential :677/685/702/716, logout :320/335/350, publishAuthorizationProgress :588, releaseAuthorizationLease :609, recoverInterruptedCredentialCommit :649, runLogin :426
    oauth-core/src/namespace-migration.ts:19,32 stateStore.write migration/anonymous state → error (no secrets) auth-state.json / legacy auth/auth-state.json migrateLegacyAuthNamespace / retireMatchingLegacyRootState
    oauth-core/src/cross-process-lock.ts:27 open(lockPath,'a') lock file (no secret) <namespaceHome>/auth.lock CrossProcessAuthLock
    mcode-tools-host/src/lease-broker.ts:145 (helper atomicWriteCapability :246-266) atomic write 43-char base64url capability secret (randomBytes(32) :46) <dataDir>/run/mcode-auth-lease-v1.cap (oauth-lease-protocol/src/endpoints.ts:29/37) startMcodeToolsAuthLeaseBroker

    oauth-lease-protocol persists nothing itself (server writes only socket/endpoint node-server.ts:47-49/87; client only reads the capability node-client.ts:232-244).

    C.2 TUI auth storage

    file:line operation what is written storage target calling context
    packages/config/src/local-runtime-auth-context.ts:64-95 (write :83) writeLocalRuntimeAuthContext accessToken + realUserID/userEmail/userName/subUserName <dataDir>/local-runtime.auth.json (name :5, path :46-48) shared helper, called from tui/auth/storage.ts
    tui/src/auth/storage.ts:298 (writePrivateCliAuthContext :293-304) write accessToken + identity <dataDir>/cli-auth/<buildEnv>/<region>/local-runtime.auth.json writeCliAuthContext :103-126, importSharedCliAuthContext :178, migrateLegacyCliAuthContext :323
    tui/src/auth/storage.ts:120 write accessToken + identity (shared-runtime projection) <dataDir>/local-runtime.auth.json writeCliAuthContext
    tui/src/auth/storage.ts:199 write accessToken + identity <dataDir>/local-runtime.auth.json syncCliRuntimeAuthProjection
    tui/src/auth/storage.ts:281 (writeCliSharedProjection :266-287) write auth sha256 fingerprint + scope (no token) <dataDir>/cli-auth/shared-projection.json (:231-233) writeCliAuthContext :123, importSharedCliAuthContext :179
    tui/src/auth/storage.ts:363 (writeCliAuthScope :357-369) write scope record (region/buildEnv) <dataDir>/cli-auth/<env>/<region>/cli-auth.scope.json writePrivateCliAuthContext :301
    tui/src/auth/identity-storage.ts:145 (writeCliAccountIdentity :136-151) write token fingerprint (sha256 :153-155) + user identity + verified/rejected <dataDir>/cli-auth/<env>/<region>/account-identity.json (:36-38) writeVerifiedCliAccountIdentity :66-88 / writeRejectedCliAccountIdentity :90-103; callers runtime/lifecycle.ts:717,767,825
    tui/src/auth/region-preference.ts:49 (writeTuiRegionPreference :38-55) write region preference (no secret) <dataDir>/preferences/mcode-region.json auth/application.ts:149
    tui/runtime/auth-session.ts:23-39 wires createCredentialStore (oauth-core) access+refresh token oauth auth.json (C.1) McodeAuthApplication.login auth/application.ts:88 / .logout :123
    TUI → runtime API-key pipeline: cli/provider-command.ts:147/104/85-89 → provider/application.ts:80-90 → runtime/adapter.ts:480-481 → adapters/product-access.ts:197-203 → local-runtime-v2 cli-service.ts:740-750 → process-local-application.ts:145 passes apiKey through lands in config.yaml (C.3) runMcodeProviderCommand

    TUI legacy-matrix token renewal persists only via writeRefreshedAuthContext (runtime/lifecycle.ts:781-793, call-sites :703,:716,:753,:766).

    C.3 local-runtime-v2 credential writes

    file:line operation what is written storage target calling context
    service/model-system/management/service-minimax-operations.ts:255-265 (key set :261) updateByokConfig draft minimax_api.apiKey MiniMax API key (plaintext) <dataDir>/config.yaml → minimax_api.apiKey upsertMiniMaxApiKey
    service-custom-provider-operations.ts:78-107 (createUserProvider, options.apiKey :91) draft write custom-provider API key (+ optional header :94) config.yaml → custom_provider.<key>.options.apiKey createUserProvider
    service-custom-provider-operations.ts:197-198 (updatedProviderOptions :190-201) set/clear options.apiKey API key update (tri-state, masked-backwrite guard :116-117) config.yaml updateUserProvider
    service-custom-provider-operations.ts:242-245 structuredClone copies options.apiKey API key duplicate config.yaml duplicateUserProvider
    service-candidate-operations.ts:191-202 (persistCandidate) provider record incl. apiKey API key config.yaml saveUserModelProviderCandidate
    service/model-system/management/service-context.ts:93-94 (candidateOptions :83-97) set/clear apiKey in candidate options API key (precursor) flows into persistCandidate resolveUserProviderCandidate :242-271
    service/model-system/codex-oauth.ts:134 (storage.set, default authStorageFactory :104-138) persists Codex-OAuth credentials (access token + accountId via third-party AuthStorage) OAuth token <dataDir>/codex-auth.json (:395, confirmed config.ts:502) CodexOAuthManager.startLogin → finishLogin :360-375
    service/model-system/codex-oauth.ts:455-463 + configureCodexOAuthProvider :483-498 draft write provider catalog (authMode:'oauth', removes options.apiKey :517 — OAuth keys never land in config) provider metadata (no key) config.yaml refreshProviderCatalog
    disk backend of all config.yaml writes: packages/config/src/local-model-provider-write.ts:96-135 (updateLocalByokConfig, BYOK roots :114-125) → withLockedConfig :221-257 (write :242) → atomicWriteFile :259-277 (mode 0o600 :17) YAML rewrite minimax_api / custom_provider / minimaxModelSource / defaultModel config.yaml wired service/model-system/initialize.ts:44

    C.4 local-runtime (v1) parallel stores

    file:line operation what is written storage target calling context
    local-runtime/src/config/update.ts:226-290 (updateLocalByokConfig, write :282; atomicWriteFile :292-310) parallel BYOK writer (twin of packages/config) minimax_api.apiKey / custom_provider.*.options.apiKey <dataDir>/config.yaml no in-repo caller (F7) — legacy/export API
    local-runtime/src/channels/telegram.ts:91-119 (bind, botToken :108, save :113 → :235) botToken Telegram bot token telegram-channel.yaml (:245) LocalTelegramChannelStore.bind
    local-runtime/src/channels/wechat.ts:155-199 (bind, botToken :174, webhookToken :189, qrcodeToken :192 → save :304) botToken, webhookToken, qrcodeToken WeChat secrets wechat-channel.yaml (:312) LocalWeChatChannelStore.bind
    local-runtime/src/channels/feishu.ts:127-144 (bind, appId :129, appSecret :130, verificationToken :131-133, encryptKey :134 → save :286) appSecret, verificationToken, encryptKey Feishu secrets feishu-channel.yaml (:294) LocalFeishuChannelStore.bind
    local-runtime/src/channels/durable-yaml.ts:22-42 + :51-83 generic YAML RMW IM connection/binding records (incl. tokens during legacy migration) channel YAMLs im-connection-store-*-operations.ts

    Explicitly excluded as non-credential: miniapp workspace-storage.ts:395 (transient init-marker UUID, deleted on commit :406), agent/storage/identity-codec.ts (AES with hard-coded key :6, but only displayName/description/avatar — see F8), runtime-owner/identity.ts:89 (PID lease), review-link/store.ts (PR URLs only).

    C.5 Shared credential write helpers

    helper file:line users
    atomicWritePrivateFile + ensurePrivateDirectory (0o600/0o700, tmp+rename) oauth-core/src/fs/atomic-write.ts:4-29 file-store.ts:119, state-store.ts:140
    FileStore.put / .delete oauth-core/src/credential-store/file-store.ts:67-77 auth-core.ts:510,522,527,715 / :349,527,695
    AuthStateStore.write oauth-core/src/state-store.ts:138-141 auth-core (8 sites), namespace-migration.ts:19/32
    writeLocalRuntimeAuthContext / clearLocalRuntimeAuthContextFile packages/config/src/local-runtime-auth-context.ts:64-104 tui/auth/storage.ts:120,199,298 / :156,190,324
    TUI wrappers writeCliAuthContext (storage.ts:103), writePrivateCliAuthContext (:293), writeCliSharedProjection (:266), writeCliAuthScope (:357), writeCliAccountIdentity (identity-storage.ts:136), writeTuiRegionPreference (region-preference.ts:38) — runtime/lifecycle.ts:717/767/792/825, auth/application.ts:149
    updateLocalByokConfig → withLockedConfig → atomicWriteFile (lockfile + 0o600) packages/config/src/local-model-provider-write.ts:96/:221/:259 entire v2 model system (initialize.ts:44)
    atomicWriteCapability mcode-tools-host/src/lease-broker.ts:246-266 :145 (only broker-secret write)

    C.6 On-disk layout at runtime

    • DataDir: ~/.minimax or ~/.minimax-<profile> (packages/config/src/data-dir.ts:5,40-42); legacy ~/.mavis migrated + compat symlink (:83-90,384-393). Env overrides: MINIMAX_DATA_DIR > MAVIS_DATA_DIR (config.ts:1318; tui/runtime/data-dir.ts:31-37), plus --data-dir, DATA_DIR, PROFILE (config.ts:1341-1356).
    • OAuth: <dataDir>/auth/<buildEnv>/<region>/mcode-public/auth.json + auth-state.json + auth.lock; legacy locations auth/{auth-state.json,auth.lock,credentials.enc} (namespace.ts:22-41). buildEnv ∈ dev/test/staging/prod, region ∈ cn/en.
    • TUI legacy-matrix auth: <dataDir>/local-runtime.auth.json, <dataDir>/cli-auth/<buildEnv>/<region>/{local-runtime.auth.json,cli-auth.scope.json,account-identity.json}, cli-auth/shared-projection.json, preferences/mcode-region.json.
    • BYOK: <dataDir>/config.yaml (getConfigPath() config.ts:1511-1513) — minimax_api.apiKey + custom_provider.<key>.options.apiKey plaintext, 0o600.
    • Codex OAuth: <dataDir>/codex-auth.json.
    • Lease broker: <dataDir>/run/mcode-auth-lease-v1.cap + .sock (unix); Windows named pipe \\.\pipe\mcode-auth-lease-<sha256(dataDir)[:16]> (endpoints.ts:6-39).
    • v1 channels: <dataDir>/{telegram,wechat,feishu}-channel.yaml (secrets plaintext, 0o600).

    C.7 Read-back, delete, rotate, logout

    • Read-back: FileStore.get (file-store.ts:62-65, permission check :104-115 — unix requires 0o700/0o600, win32 skipped); AuthStateStore.read (state-store.ts:122-136); readLocalRuntimeAuthContext (local-runtime-auth-context.ts:50-62); TUI readCliAuthContext family (storage.ts:55-101,306-327 incl. legacy migration :314-327); readVerifiedCliAccountIdentity (identity-storage.ts:40-56); Codex getCredentials (codex-oauth.ts:108-121); broker capability read (node-client.ts:232-244).
    • Logout/delete: MCodeOAuthCore.logout (auth-core.ts:307-362 — revokeToken :333, credential delete :349, logout_pending state on revoke failure :335-345); invalid_grant deletes the credential after state publish (:685-696). TUI: clearCliAuthContext (storage.ts:128-135), invalidateCliAuthContext (:155-158), clearCliRuntimeAuthProjectionIfMatching (:183-193), clearLocalRuntimeAuthContextFile (local-runtime-auth-context.ts:97-104), clearCliAccountIdentity (identity-storage.ts:105-107), clearCliSharedProjection (storage.ts:289-291), clearCliAuthScope (:371-373); rejected-token path invalidateRejectedAuthContext (lifecycle.ts:817-832).
    • v2: deleteUserProvider (service-custom-provider-operations.ts:250-283) incl. removeProviderCredentials :259-275 (initialize.ts:90 → CodexOAuthManager.removeCredentials codex-oauth.ts:345-358 → storage.logout :123); key clear via updateUserProvider (kind==='clear' :198); capability removal lease-broker.ts:163,184.
    • Rotate: refreshCredential (auth-core.ts:673-720) replaces tokens at constant loginEpoch (:709-715, generation+1); TUI matrix renewal writes back via writeRefreshedAuthContext (lifecycle.ts:781-793).

    Part D — host-channel-composition.ts / channel-context.ts

    file verdict evidence
    packages/local-runtime/src/api/host-channel-composition.ts (370 lines) pure composition/wiring — zero direct channel-store writes, zero YAML persistence touchpoints wireHostChannelSubsystem (:62-357) delegates to wireChannelSubsystem (api/host-channels.ts:273); passes store instances/overrides through (ownerStoreOverride :296, feishuChannelStoreOverride :299-301, telegramChannelStoreOverride :302-304, wechatChannelStoreOverride :305-307, wechatRuntimeSdkOverride :308) plus family-state ports (primaryFamilyRootSessions :182-232, primaryFamilySessionModels :233-248). Its only persistence effects are session/conversation-DB lifecycle calls: lifecycle.updateSession :104-109 (revealMigratedSession), createSession/replaceRootSession/deleteSession :167-173, replaceRootSession :202-208, createRootSession :213-224, updateSession :244-247, ingress.submit :260-275, ingress.abort :285, maintenance.compact :287-294
    packages/local-runtime/src/api/channel-context.ts (22 lines) pure mapper — no state, no writes, no persistence channelContextFromMessageContext :4-17 (message context → LocalChannelContext) + platform validator :19-22

    Findings (proposed follow-ups — nothing changed by this analysis)

    # Finding Evidence Suggested handling
    F1 Non-atomic whole-file rewrites on 5 stores (crash mid-write can truncate YAML), while route/binding/IM stores use temp+fsync+atomic rename plain writeFile: feishu.ts:286, telegram.ts:235, wechat.ts:304, owner-store.ts:270, access-control-store.ts:383 vs durable-yaml.ts:59-92 route the 5 stores through writeFileDurably/mutateDurableYaml
    F2 Async load race on 3 stores (loaded flipped before the async read completes → second caller can observe a half-populated map); feishu already fixed via shared loadPromise telegram.ts:213-215, wechat.ts:282-284, owner-store.ts:244-246 vs feishu.ts:76-83,239-259 port the feishu loadPromise pattern
    F3 No write serialisation on credential-store mutators (bind/unbind/setEnabled/clone/setGetUpdatesBuf) nor rekeyPrimaryFamily; whole-map last-writer-wins. WeChat setGetUpdatesBuf is fire-and-forget on every monitor disconnect and can race bind/unbind wechat-monitor-wire.ts:289-301; owner-store.ts:187-219 (mutex only covers checkAndBootstrap/clear :231-242) per-file write lane (the mutationLane pattern)
    F4 access-control.yaml saved without 0o600 (every other channel store sets it) access-control-store.ts:373-384 add chmod like feishu.ts:290
    F5 Inconsistent secret tiers: oauth tokens get dedicated auth.json + sensitive-key guard, while channel secrets (botToken/appSecret/verificationToken/encryptKey/webhookToken/qrcodeToken) and BYOK API keys sit in YAML/plaintext feishu.ts:127-144, telegram.ts:91-119, wechat.ts:155-199, config.yaml writers vs state-store.ts:41-52 upstream design question (worth an issue); at minimum document the threat model
    F6 channel-routes.yaml is rewritten on every boot: migrateToV2 returns changed:true unconditionally route-api.ts:288, rootless-startup-migration.ts:179 return changed only when targets actually changed
    F7 Dead/unused write paths: pin/pinAllForAgent/migratePinnedSession(ForAgents) (infra.ts:449-497, documented compat), writeConversationData (im-connection-store-data.ts:115), v1 BYOK writer local-runtime/src/config/update.ts:226 (no in-repo caller), LocalChannelOutboundStore.clear (runner.ts:116-122) as listed cleanup candidates or explicit compat markers
    F8 identity-codec.ts:6 uses a hard-coded AES key (currently only cosmetic agent fields: displayName/description/avatar) agent/storage/identity-codec.ts:6, used agent.repository.ts:504-506 don't extend to real secrets without key management
    F9 Legacy source DB is not strictly read-only at runtime: session delete reaches into <dataDir>/sqlite.db and deletes session_messages rows cleanup.ts:37 (contract :11-14) documented + user-initiated; note in any data-safety statement
    F10 Name-collision bootstrap rewrites legacy DB rows and legacy YAML/JSON files in place — safe only via the mandatory full backup (v2/migration/backups/…) agent-name-conflict-migration-backup.ts:43-170, …-files.ts:46-130, …-backup.ts:295-339 keep backup-first invariant under test if touched

    Method & verification

    • Method: three independent read-only code sweeps (channel stores + composition files; migration/receipts/deletion audit; credential writes), each reading every matching file in full (no sampling). Synthesis + spot-check pass by the orchestrating run. No repository file was created, modified, or deleted (git status --porcelain empty before and after).
    • Spot checks (9/9 verbatim against source): feishu.ts:286 writeFile + :290 chmod 0o600; durable-yaml.ts:58-59 temp+fsync+atomic replace; cleanup.ts:37 DELETE FROM session_messages; auth-core.ts:510/522/527 credentialStore.put/delete; service-minimax-operations.ts:261 draft.minimax_api.apiKey write; local-memory-store-fs.ts:413 rename (move case); owner-store.ts:270/274 writeFile+chmod; host-channel-composition.ts grep for writeFile|.save(|mutateDurableYaml|.bind( → no matches (confirms D-table verdict); HEAD b75ff66.
    • pnpm verify (full, this machine): reached test:capabilities, then FAIL — Verification failed at: test:capabilities, exit 1. Result inside that gate: 15 failed / 4598 passed / 61 skipped (4674 tests, 174 files, 468s). Failure breakdown: 12× EPERM: operation not permitted, symlink (this machine has no Developer Mode), 2× POSIX-vs-Windows path assertions (observability.test.ts:86 expects /data/v2/…, gets \data\v2\…; :210 stringContaining /v2/observability/mcode/), 1× wslpath conversion (wsl-attachment-paths.test.ts:159). All 15 map to the documented [Bug]: Windows local verification: 30 test failures + 1 gate failure in 4 root causes — fixes on 4 prepared branches (+3 findings) #322 Windows classes and to the prepared fix branches test/symlink-privilege-gated-skip + test/symlink-privilege-warm-scan (EPERM incl. the warm-scan case in test/history-processing.test.ts:1573) and test/windows-observability-followup (observability paths + WSL case). The failures are pre-existing on ci/windows-package-acceptance @ b75ff66 (which does not contain those branches) and are unrelated to this analysis; the previously published 14/14-green proof was measured on the combined integration state with all prepared branches applied.
    • Boundaries: offline analysis only — no live-service behavior exercised, no cross-platform acceptance claimed. Line numbers refer to ci/windows-package-acceptance @ b75ff66.
  5. hetaoBackend commented on Oct 19, 2026

    @hetaoBackend
    Collaborator

    Closing as part of a backlog cleanup: this issue was filed more than a month ago against an older release, and the codebase has changed substantially since then. If it still reproduces on the latest version, please open a new issue with the current version and reproduction steps. Thank you for the report!

    批量清理积压:该 issue 创建已超过一个月,针对的是较旧版本,代码已有较大变化。如果在最新版本上仍能复现,欢迎附上当前版本号和复现步骤重新提交 issue。感谢反馈!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdesktopMiniMax Code desktop appneeds-triageAwaiting maintainer assessment

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions