From 5d2e43e7ef53518bd52425206d06c5e277da3d88 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:50:15 +0200 Subject: [PATCH] opencode: vault custody for static API keys through an in-process auth proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode keeps provider API keys in auth.json and reads them per request. This moves custody of those keys into the vault: the key is replaced on disk by a non-secret tombstone (`claustrum-tombstone:v1:`), a capability handle lives in a mode-600 handle file, and a plugin on OpenCode's `config` hook injects `{apiKey: , fetch}` so the request-time closure substitutes the served credential wherever the SDK placed the sentinel. The plugin holds zero provider knowledge; ownership is the conjunction of a tombstone in auth.json and a `serve` claim in the handle file. Rust (ck-auth): - `migrate-opencode` (dry-run, `--provider`, `--restore`, `--replace`, `--force-shape`) and `opencode-account add|remove|list`, online against the daemon or offline on the lease; every write is temp+fsync+rename at mode 0600 in a checked parent; a `superseded` journal on the handle file makes a crash between tombstone write and revoke converge without rotating handles. - Shared `route_client.rs` transport and a capability-only `credential_client.rs`; no new admin op and no secret-returning surface. - Rust handle-file validation mirrors the TS parser rule for rule (provider/label charset, `ckh_` base64url handles, `superseded` entries); provider ids never `__proto__`/`constructor`/`prototype`. - `opencode-provider-shapes.json`: providers whose key leaves OpenCode's fetch seam (env copy, discovery, metadata) are refused at migrate time with the shape, the reason, the source citation, and the consequence of forcing; `ck auth usable` warns on an existing tombstone whose shape moved. Data with provenance (anomalyco/opencode@dc4449df0d, method and its edge stated), maintained by delta per OpenCode base update. - Vault import refuses a tombstone as credential material. - `opencode-test-seam` feature (two env seams) for crash-cut tests; compiles nothing into release, and gate.sh ends on a default build. TypeScript: - `@cortexkit/claustrum-client`: detect, wire, identity, reconnect and error classes extracted from anthropic-auth; policy-free. - `@cortexkit/claustrum-opencode`: seven-cell ownership table (split custody installs a REFUSING fetch; orphan injects nothing), ordered per-account failover with 401 reporting fenced on the served record_version, 429/402 cooldowns, manual same-origin-only redirects, bounded warm and a 60 s oauth tick for idle-account custody, redacted logging with canary tests, and a lifecycle suite driven through the exported plugin (`dist/opencode-plugin.js`, v1 `{id, server}` shape). - Fail-closed on every path that sees a tombstone: unreadable or oversized files, unrecognised native-runtime flag values, absent handle file. auth.json past the parse cap is scanned for sentinels with each hit becoming a refusal directly. `readAuth` mirrors `Auth.all` precedence including its error behaviour. - A containment property test asserts the plugin's refusal set is a superset of every provider OpenCode would load with a sentinel in it, over auth-source × handle-state rows, against a reference model of the host derived from its source (not from the plugin) and proven so by a two-sided mutation. Gates: bash scripts/gate.sh with bun arms (install, typecheck, build, test) ahead of the cargo arms; workspace floor measured in the profile the gate runs. Live acceptance in scripts/accept-opencode-custody.sh: migrate, serve a real model call through the vault, refuse a hand-restored key as split custody, restore — against the running daemon in a scratch XDG home. Review response folded in: provider ids are validated where they are MATERIALIZED (one function at every cfg.provider write; auth.json keys were a third, unvalidated site); parse errors on secret-bearing files (auth.json, the handle file, the daemon connection file) never echo parser text — Bun's SyntaxError quotes the input token, which put a handle verbatim into a thrown message; catch sites log a fixed code and the error name, never the message; the handle file is read once through an O_NOFOLLOW descriptor, fstat-validated, revision from the same bytes; query substitution percent-encodes the material and leaves untouched parameters byte-identical; 303-to-GET drops the RFC 9110 representation headers; connection-file discovery mirrors the daemon's order; the acceptance script arms rollback before migrating. Handle lifecycle (maintainer finding): a minted handle never outlives the operation that minted it. `mint_then_persist` revokes the handle if its file write fails; `with_scoped_handle` revokes a comparison-only handle on every exit; both name the credential id and the closing commands if the revoke itself fails. No bare mint remains outside the two helpers. Rebased onto master's redacted-Debug change (0679dea). Design: docs/opencode-custody-design.md. Follow-ups: #29. The TypeScript suite runs hermetically (no daemon, no HOME) and that run is the gate in scripts/gate.sh and both CI jobs; migrate-opencode and opencode-account add refuse keys carrying the reserved tombstone prefix; the auth.json read is single-descriptor bounded. A rejection from a stale handle revision cannot poison the replacement slot, a stalled get expires instead of pinning the slot, descriptor reads are bounded to the cap on the bytes actually read, percent-encoded sentinels match case-insensitively, the client ships Node-loadable ESM, and the hermetic suite runs on every CI leg including Windows. Connection discovery mirrors the daemon tier-for-tier and refuses an ambiguous match; the serve path renders error names only, with a structured code for callers and a canary covering the substitution-failure arm; a stalled tick warm expires like a request warm. --- .github/workflows/ci.yml | 85 +- .gitignore | 1 + bun.lock | 150 ++ crates/credentials-core/src/oauth.rs | 31 + .../src/bin/cli_support/admin_client.rs | 184 +- .../src/bin/cli_support/credential_client.rs | 155 ++ .../cli_support/opencode-provider-shapes.json | 41 + .../src/bin/cli_support/opencode_accounts.rs | 315 +++ .../src/bin/cli_support/opencode_files.rs | 535 ++++ .../src/bin/cli_support/opencode_migration.rs | 852 +++++++ .../src/bin/cli_support/route_client.rs | 166 ++ .../src/bin/credentials_cli.rs | 105 +- crates/credentials-module/tests/cli_admin.rs | 74 +- .../credentials-module/tests/cli_opencode.rs | 2164 +++++++++++++++++ crates/credentials-module/tests/common/mod.rs | 47 +- docs/opencode-custody-design.md | 405 +++ docs/operator-runbook.md | 4 + package.json | 16 + packages/client/README.md | 23 + packages/client/package.json | 24 + packages/client/src/detect.ts | 164 ++ packages/client/src/errors.ts | 84 + packages/client/src/identity.ts | 30 + packages/client/src/index.ts | 23 + packages/client/src/tests/client.test.ts | 492 ++++ packages/client/src/wire.ts | 270 ++ packages/client/tsconfig.build.json | 4 + packages/client/tsconfig.json | 8 + packages/opencode/README.md | 73 + packages/opencode/golden/handles.json | 37 + packages/opencode/golden/tombstone.json | 18 + packages/opencode/package.json | 31 + packages/opencode/src/bounded-read.ts | 27 + packages/opencode/src/contracts.ts | 12 + packages/opencode/src/errors.ts | 57 + packages/opencode/src/freshness.ts | 306 +++ packages/opencode/src/handles.ts | 221 ++ packages/opencode/src/index.ts | 9 + packages/opencode/src/log.ts | 46 + packages/opencode/src/opencode-plugin.ts | 7 + packages/opencode/src/plugin.ts | 475 ++++ packages/opencode/src/request.ts | 179 ++ packages/opencode/src/secret-json.ts | 14 + packages/opencode/src/serve.ts | 294 +++ .../opencode/src/tests/config-hook.test.ts | 1126 +++++++++ packages/opencode/src/tests/contracts.test.ts | 80 + packages/opencode/src/tests/freshness.test.ts | 524 ++++ packages/opencode/src/tests/lifecycle.test.ts | 260 ++ packages/opencode/src/tests/log.test.ts | 66 + packages/opencode/src/tests/serve.test.ts | 664 +++++ .../opencode/src/tests/shipped-plugin.test.ts | 32 + packages/opencode/src/tombstone.ts | 98 + packages/opencode/tsconfig.build.json | 4 + packages/opencode/tsconfig.json | 9 + scripts/accept-opencode-custody.sh | 212 ++ scripts/gate.sh | 35 +- scripts/spikes/README.md | 38 + scripts/spikes/opencode-config-fetch.sh | 275 +++ tsconfig.base.json | 14 + 59 files changed, 11453 insertions(+), 242 deletions(-) create mode 100644 bun.lock create mode 100644 crates/credentials-module/src/bin/cli_support/credential_client.rs create mode 100644 crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json create mode 100644 crates/credentials-module/src/bin/cli_support/opencode_accounts.rs create mode 100644 crates/credentials-module/src/bin/cli_support/opencode_files.rs create mode 100644 crates/credentials-module/src/bin/cli_support/opencode_migration.rs create mode 100644 crates/credentials-module/src/bin/cli_support/route_client.rs create mode 100644 crates/credentials-module/tests/cli_opencode.rs create mode 100644 docs/opencode-custody-design.md create mode 100644 package.json create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/detect.ts create mode 100644 packages/client/src/errors.ts create mode 100644 packages/client/src/identity.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/tests/client.test.ts create mode 100644 packages/client/src/wire.ts create mode 100644 packages/client/tsconfig.build.json create mode 100644 packages/client/tsconfig.json create mode 100644 packages/opencode/README.md create mode 100644 packages/opencode/golden/handles.json create mode 100644 packages/opencode/golden/tombstone.json create mode 100644 packages/opencode/package.json create mode 100644 packages/opencode/src/bounded-read.ts create mode 100644 packages/opencode/src/contracts.ts create mode 100644 packages/opencode/src/errors.ts create mode 100644 packages/opencode/src/freshness.ts create mode 100644 packages/opencode/src/handles.ts create mode 100644 packages/opencode/src/index.ts create mode 100644 packages/opencode/src/log.ts create mode 100644 packages/opencode/src/opencode-plugin.ts create mode 100644 packages/opencode/src/plugin.ts create mode 100644 packages/opencode/src/request.ts create mode 100644 packages/opencode/src/secret-json.ts create mode 100644 packages/opencode/src/serve.ts create mode 100644 packages/opencode/src/tests/config-hook.test.ts create mode 100644 packages/opencode/src/tests/contracts.test.ts create mode 100644 packages/opencode/src/tests/freshness.test.ts create mode 100644 packages/opencode/src/tests/lifecycle.test.ts create mode 100644 packages/opencode/src/tests/log.test.ts create mode 100644 packages/opencode/src/tests/serve.test.ts create mode 100644 packages/opencode/src/tests/shipped-plugin.test.ts create mode 100644 packages/opencode/src/tombstone.ts create mode 100644 packages/opencode/tsconfig.build.json create mode 100644 packages/opencode/tsconfig.json create mode 100755 scripts/accept-opencode-custody.sh create mode 100644 scripts/spikes/README.md create mode 100755 scripts/spikes/opencode-config-fetch.sh create mode 100644 tsconfig.base.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9028ea7..17c473e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,49 @@ jobs: with: path: claustrum + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Bun install + run: bun install --frozen-lockfile + + - name: Bun build + run: bun run build + + - name: Bun typecheck + run: bun run typecheck + + - name: Bun test packages + # The Unix-only files below use POSIX mode bits (0o600 / 0o640 / 0o777) that + # `chmod()` cannot express on Windows and that `handles.ts` validates against; + # the `/proc/self/fd` descriptor-closure probe is Linux-only. They would + # either no-op the write and fail the mode check, or read a directory that + # does not exist there. The platform-agnostic subset still exercises wire + # codecs, sentinel substitution, the four-cell injection table, freshness, + # oauth tick, exhaustion, and split-custody refusal — the bulk of the suite. + # Unix-only files excluded on Windows: + # - packages/opencode/src/tests/config-hook.test.ts (chmod mode bits, /proc/self/fd) + # - packages/opencode/src/tests/serve.test.ts (chmod mode bits for handles fixture) + # Pinned to bash: Windows runners default to pwsh and the matrix-arm `[[ ]]` test would + # not run there at all. GitHub Windows images ship bash; the same shell choice is used + # by the inbound-contract / endpoint-hosts / path-rendering / threshold-controls steps + # for the same reason. + # The hermetic env (XDG_RUNTIME_DIR / CLAUSTRUM_SUBC_CONNECTION) is set on both legs + # so a stray `bun test packages` invocation cannot reach for the daemon's default + # connection path on a runner whose env is otherwise unset. + shell: bash + run: | + if [[ "${{ matrix.os }}" == "windows" ]]; then + XDG_RUNTIME_DIR=/nonexistent CLAUSTRUM_SUBC_CONNECTION=/nonexistent/x.json \ + bun test packages/opencode/src/tests/freshness.test.ts \ + packages/opencode/src/tests/lifecycle.test.ts \ + packages/opencode/src/tests/contracts.test.ts \ + packages/client + else + bun run test:hermetic + fi + # This repo path-deps the private cortexkit/subconscious (subc wire) and # cortexkit/commons (storage libs) siblings. The default GITHUB_TOKEN is # scoped to this repo only, so mint a short-lived token from the org-installed @@ -212,6 +255,12 @@ jobs: - name: Security-conformance suite (crash-safety seams) run: cargo test --locked --workspace --all-targets --features kill9-test-seam,rotate-test-seam,login-test-seam + - name: OpenCode custody crash cuts + if: matrix.os == 'ubuntu' + run: | + cargo test --locked -p credentials-module --test cli_opencode the_migrate_opencode_tombstone_reread_failure_keeps_the_old_handle_until_rerun + cargo test --locked -p credentials-module --test cli_opencode the_opencode_account_add_recovers_a_mint_before_handle_write_with_one_live_handle + # Run the #[ignore]'d real-daemon e2e (incl. the on-the-wire malicious-client # harness) under a real supervised subc-core. This is the ONLY layer that # catches cross-component contract bugs (e.g. a CLI/daemon lease-namespace @@ -228,24 +277,22 @@ jobs: CRED_REQUIRE_DAEMON: "1" run: cargo test --locked -p credentials-module --test real_daemon_e2e -- --ignored --test-threads=1 - # Assert the api-key validation bypass is absent from a REAL release binary. It + # Assert test-only environment hatches are absent from a REAL release binary. It # is #[ignore]'d because it builds the release profile, so without this step it # would never run and the guarantee would be nominal. Ubuntu-only: the property # is about the source gate, which is platform-independent, and the release build # is the expensive part. - name: Release-artifact assertions (ship gate) if: matrix.os == 'ubuntu' - run: cargo test --locked -p credentials-module --test cli_admin validation_bypass_is_absent -- --ignored + run: cargo test --locked -p credentials-module --test cli_admin test_escape_hatches_are_absent -- --ignored # WHAT A FORK PR CAN ACTUALLY BE TOLD, given that the job above cannot run for it. # - # THE NAME IS THE CONTRACT. It says "no build, no tests" because a green check on a - # PR reads as "the suite passed", and a green that means less than it looks is worse - # than the red it replaces -- the contributor stops looking, and so do I. Everything - # here is a source scan; nothing compiles, nothing runs the vault. + # The cargo suite cannot run for a fork, but the public Bun workspace can. A green + # check still does not exercise the vault or its private sibling dependencies. # - # These four are the entire fork-safe set, and that is a measured claim rather than a - # convenient one: every other gate arm either shells to cargo (which loads the + # The source scans and Bun workspace are the fork-safe set. Every remaining gate arm + # either shells to cargo (which loads the # workspace manifest, which path-deps the private siblings, which a fork cannot check # out) or reads ../subconscious directly, as the inbound-contract check does. Verified # by reading each script for sibling references and cargo invocations -- 0 and 0 for @@ -271,17 +318,33 @@ jobs: - name: Checkout claustrum uses: actions/checkout@v5 - - name: Source scans that need no build + - name: Source scans run: | python3 scripts/check-doc-status.py python3 scripts/check-path-rendering.py python3 scripts/threshold-controls.py python3 scripts/endpoint-hosts.py + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Bun install + run: bun install --frozen-lockfile + + - name: Bun build + run: bun run build + + - name: Bun typecheck + run: bun run typecheck + + - name: Bun test packages + run: bun run test:hermetic + - name: State what this job did NOT check run: | - echo "Source scans only. This job did NOT build, ran no test, and did not" - echo "exercise the vault. The full suite needs private sibling repositories" + echo "Source scans and Bun package checks only. This job did NOT exercise the vault." + echo "The full Rust suite needs private sibling repositories" echo "that a fork PR cannot check out, so a maintainer runs scripts/gate.sh" echo "on the merge candidate before it lands. Green here is necessary and" echo "nowhere near sufficient." diff --git a/.gitignore b/.gitignore index 5fb0dfa..c00db59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Node node_modules +packages/*/dist package-lock.json bun.lockb diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..27e0686 --- /dev/null +++ b/bun.lock @@ -0,0 +1,150 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@cortexkit/claustrum", + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "7.0.2", + }, + }, + "packages/client": { + "name": "@cortexkit/claustrum-client", + "version": "0.1.0", + "dependencies": { + "@cortexkit/subc-client": "^0.8.1", + }, + }, + "packages/opencode": { + "name": "@cortexkit/opencode-claustrum", + "dependencies": { + "@cortexkit/claustrum-client": "workspace:*", + }, + "devDependencies": { + "@opencode-ai/plugin": "1.18.25", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@cortexkit/claustrum-client": ["@cortexkit/claustrum-client@workspace:packages/client"], + + "@cortexkit/opencode-claustrum": ["@cortexkit/opencode-claustrum@workspace:packages/opencode"], + + "@cortexkit/subc-client": ["@cortexkit/subc-client@0.8.1", "", {}, "sha512-8U9w3AnSff0QYlLVzcKOuTULakRtVpcGJrquGHxOQQlWZGzXZdCs8nroLMeoM2xhFGwxIh8xFk832w1KG6akAA=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.25", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.25", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Kb34zFqYosFNiMd1IuYiZGjX17z+18Srm7tHZMCz+uMVRTYNkEw1FTrfAK2FLbggwYdgzifGwKMNF1slLT8eLw=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.25", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.4.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/crates/credentials-core/src/oauth.rs b/crates/credentials-core/src/oauth.rs index f18ecef..fc0a1ca 100644 --- a/crates/credentials-core/src/oauth.rs +++ b/crates/credentials-core/src/oauth.rs @@ -14,6 +14,12 @@ use serde::{Deserialize, Serialize}; +pub const CUSTODY_TOMBSTONE_PREFIX: &str = "claustrum-tombstone:v1:"; + +fn is_custody_tombstone(value: &str) -> bool { + value.starts_with(CUSTODY_TOMBSTONE_PREFIX) +} + /// A canonical OAuth credential: the provider-agnostic fields a refresh exchange /// needs, plus the current tokens. Importers map each source format into this; /// refresh adapters read and update it. @@ -162,6 +168,9 @@ impl OAuthCredential { .filter(|s| !s.is_empty()) .ok_or(ImportError::MissingField("refresh"))?; let access_token = entry.access.unwrap_or_default(); + if is_custody_tombstone(&refresh_token) || is_custody_tombstone(&access_token) { + return Err(ImportError::CustodyTombstone); + } Ok(OAuthCredential { access_token, refresh_token, @@ -320,6 +329,9 @@ pub fn import_api_key( .and_then(|k| k.as_str()) .filter(|s| !s.is_empty()) .ok_or(ImportError::MissingField("key"))?; + if is_custody_tombstone(key) { + return Err(ImportError::CustodyTombstone); + } Ok(key.as_bytes().to_vec()) } other => Err(ImportError::UnknownSource(other.to_string())), @@ -337,6 +349,8 @@ pub enum ImportError { MissingField(&'static str), /// The requested provider key was not present in a multi-provider auth file. ProviderNotFound(String), + /// Claustrum's tombstone is an ownership marker, never importable credential material. + CustodyTombstone, } impl std::fmt::Display for ImportError { @@ -350,6 +364,10 @@ impl std::fmt::Display for ImportError { ImportError::ProviderNotFound(p) => { write!(f, "provider '{p}' not found in auth file") } + ImportError::CustodyTombstone => write!( + f, + "refusing Claustrum tombstone material; run ck auth migrate-opencode or ck auth migrate-opencode --restore" + ), } } } @@ -465,6 +483,19 @@ mod tests { )); } + #[test] + fn import_refuses_claustrum_tombstone_material() { + let api = br#"{"deepseek":{"type":"api","key":"claustrum-tombstone:v1:deepseek"}}"#; + let error = + import_api_key("opencode", api, "deepseek").expect_err("tombstone api key must refuse"); + assert!(error.to_string().contains("migrate-opencode")); + + let oauth = br#"{"anthropic":{"type":"oauth","refresh":"claustrum-tombstone:v1:anthropic","access":"claustrum-tombstone:v1:anthropic","expires":0}}"#; + let error = OAuthCredential::import_provider("opencode", oauth, "anthropic") + .expect_err("tombstone oauth tokens must refuse"); + assert!(error.to_string().contains("migrate-opencode")); + } + #[test] fn imports_antigravity_accounts_store_and_packs_managed_project() { // The version:4 accounts-array store the antigravity plugin writes. diff --git a/crates/credentials-module/src/bin/cli_support/admin_client.rs b/crates/credentials-module/src/bin/cli_support/admin_client.rs index daed559..3fc76d8 100644 --- a/crates/credentials-module/src/bin/cli_support/admin_client.rs +++ b/crates/credentials-module/src/bin/cli_support/admin_client.rs @@ -16,19 +16,16 @@ //! never silently retries or falls back after dispatch; it returns a distinct error //! the CLI surfaces as "verify with list/verify-audit before retrying". -use std::time::Duration; - use credentials_core::admin_auth::{AdminMacKey, TranscriptParts, ADMIN_NONCE_LEN, VAULT_ID_LEN}; use credentials_core::admin_ops::AdminOpBody; use credentials_core::resolver::{self, ResolverConfig}; -use credentials_core::{vault_id_for, MODULE_ID}; +use credentials_core::vault_id_for; use serde_json::{json, Value}; -use subc_protocol::{BindIdentity, Flags, Frame, FrameType, Priority, RouteTarget}; -use subc_transport::{authenticate_client, connection_file, read_frame, write_frame}; +use subc_protocol::FrameType; +use subc_transport::write_frame; use tokio::net::TcpStream; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); -const RPC_TIMEOUT: Duration = Duration::from_secs(15); +use crate::route_client; /// The outcome of attempting a route-plane commit. pub enum RouteCommit { @@ -56,10 +53,6 @@ pub fn commit( conn_path: &std::path::Path, op: &AdminOpBody, ) -> RouteCommit { - let conn = match connection_file::read(conn_path) { - Ok(c) => c, - Err(e) => return RouteCommit::NoLiveModule(format!("no subc connection file: {e}")), - }; let vault_id = match vault_id_for(data_dir) { Some(v) => v, None => return RouteCommit::NoLiveModule("cannot derive vault id".into()), @@ -69,7 +62,7 @@ pub fn commit( Err(e) => return RouteCommit::Refused(format!("encoding op: {e}")), }; - run_async(async move { commit_async(&conn, &vault_id, config, &op_bytes).await }) + run_async(async move { commit_async(conn_path, &vault_id, config, &op_bytes).await }) } fn run_async>(fut: F) -> RouteCommit { @@ -84,30 +77,18 @@ fn run_async>(fut: F) -> RouteCommi } async fn commit_async( - conn: &connection_file::ConnectionInfo, + conn_path: &std::path::Path, vault_id: &[u8; VAULT_ID_LEN], config: &ResolverConfig, op_bytes: &[u8], ) -> RouteCommit { - let Some(endpoint) = conn.endpoints.first() else { - return RouteCommit::NoLiveModule("connection file has no endpoint".into()); - }; - let mut stream = match tokio::time::timeout( - CONNECT_TIMEOUT, - TcpStream::connect((endpoint.host.as_str(), endpoint.port)), - ) - .await - { - Ok(Ok(s)) => s, - Ok(Err(e)) => return RouteCommit::NoLiveModule(format!("connect: {e}")), - Err(_) => return RouteCommit::NoLiveModule("connect timed out".into()), + let mut stream = match route_client::connect(conn_path).await { + Ok(stream) => stream, + Err(e) => return RouteCommit::NoLiveModule(e), }; - if let Err(e) = authenticate_client(&mut stream, conn, CONNECT_TIMEOUT).await { - return RouteCommit::NoLiveModule(format!("client handshake: {e}")); - } // The vault module must be catalog-live; otherwise there is no module to admin. - match catalog_has_vault(&mut stream).await { + match route_client::catalog_has_module(&mut stream).await { Ok(true) => {} Ok(false) => { return RouteCommit::NoLiveModule("vault module not in catalog".into()); @@ -117,10 +98,13 @@ async fn commit_async( // Wire v2: route identity is (channel, epoch); every route frame must carry // the epoch the route was opened under, or the daemon's relay drops it. - let (route_channel, route_epoch) = match route_open(&mut stream, &config.data_dir).await { - Ok(pair) => pair, + let route = match route_client::open_route(stream, &config.data_dir, "ck-auth", "admin").await { + Ok(route) => route, Err(e) => return RouteCommit::NoLiveModule(format!("route.open: {e}")), }; + let route_channel = route.channel; + let route_epoch = route.epoch; + let mut stream = route.stream; // admin.challenge: fetch a nonce + the module's key_id (so we resolve the SAME // key) + its vault_id (so we confirm we are talking to the intended vault). @@ -199,7 +183,7 @@ async fn challenge( route_channel: u16, route_epoch: u32, ) -> Result<([u8; ADMIN_NONCE_LEN], String, String), RpcFail> { - let frame = route_request( + let frame = route_client::route_request( route_channel, route_epoch, 100, @@ -208,11 +192,11 @@ async fn challenge( if let Err(e) = write_frame(stream, &frame).await { return Err(RpcFail::Transport(format!("write admin.challenge: {e}"))); } - let resp = read_route_response(stream, 100) + let resp = route_client::read_route_response(stream, 100) .await .map_err(RpcFail::Transport)?; if resp.header.ty == FrameType::Error { - return Err(RpcFail::Refused(error_reason(&resp.body))); + return Err(RpcFail::Refused(route_client::error_reason(&resp.body))); } let value: Value = serde_json::from_slice(&resp.body) .map_err(|e| RpcFail::Transport(format!("decode challenge: {e}")))?; @@ -248,7 +232,7 @@ async fn admin_op( Ok(s) => s.to_string(), Err(_) => return RouteCommit::Refused("op body is not valid utf-8".into()), }; - let frame = route_request( + let frame = route_client::route_request( route_channel, route_epoch, 101, @@ -261,9 +245,9 @@ async fn admin_op( // Failed BEFORE the bytes left us: safe to treat as not-dispatched. return RouteCommit::NoLiveModule(format!("write admin.op: {e}")); } - match read_route_response(stream, 101).await { + match route_client::read_route_response(stream, 101).await { Ok(resp) if resp.header.ty == FrameType::Error => { - RouteCommit::Refused(error_reason(&resp.body)) + RouteCommit::Refused(route_client::error_reason(&resp.body)) } Ok(resp) => match serde_json::from_slice::(&resp.body) { Ok(v) => RouteCommit::Committed(v["result"].clone()), @@ -278,132 +262,6 @@ async fn admin_op( } } -async fn catalog_has_vault(stream: &mut TcpStream) -> Result { - let frame = control_request(1, json!({ "op": "catalog.list" })); - write_frame(stream, &frame) - .await - .map_err(|e| format!("write catalog.list: {e}"))?; - let resp = read_control_response(stream, 1).await?; - let value: Value = serde_json::from_slice(&resp.body).map_err(|e| e.to_string())?; - Ok(value["modules"] - .as_array() - .map(|ms| ms.iter().any(|m| m["module_id"] == MODULE_ID)) - .unwrap_or(false)) -} - -async fn route_open(stream: &mut TcpStream, root: &std::path::Path) -> Result<(u16, u32), String> { - let target = RouteTarget::ManagementSurface { - module_id: MODULE_ID.to_string(), - }; - let identity = BindIdentity { - project_root: root.to_path_buf(), - harness: "ck-auth".to_string(), - session: "admin".to_string(), - }; - let frame = control_request( - 2, - json!({ "op": "route.open", "target": target, "identity": identity }), - ); - write_frame(stream, &frame) - .await - .map_err(|e| format!("write route.open: {e}"))?; - let resp = read_control_response(stream, 2).await?; - if resp.header.ty == FrameType::Error { - return Err(error_reason(&resp.body)); - } - let value: Value = serde_json::from_slice(&resp.body).map_err(|e| e.to_string())?; - let channel = value["route_channel"] - .as_u64() - .map(|c| c as u16) - .ok_or_else(|| "route.open returned no route_channel".to_string())?; - // Wire v2: the daemon names the binding's epoch alongside the channel. - let epoch = value["route_epoch"] - .as_u64() - .map(|e| e as u32) - .ok_or_else(|| "route.open returned no route_epoch".to_string())?; - Ok((channel, epoch)) -} - -fn control_request(corr: u64, body: Value) -> Frame { - // Channel-0 control frames carry the reserved epoch 0 (wire v2 §3.1). - Frame::build( - FrameType::Request, - Flags::new(false, Priority::Passive, false), - 0, - 0, - corr, - serde_json::to_vec(&body).unwrap(), - ) - .unwrap() -} - -fn route_request(channel: u16, epoch: u32, corr: u64, body: Value) -> Frame { - Frame::build( - FrameType::Request, - Flags::new(false, Priority::Interactive, false), - channel, - epoch, - corr, - serde_json::to_vec(&body).unwrap(), - ) - .unwrap() -} - -async fn read_control_response(stream: &mut TcpStream, corr: u64) -> Result { - read_matching(stream, 0, corr).await -} - -async fn read_route_response(stream: &mut TcpStream, corr: u64) -> Result { - // Route responses arrive on the route channel; match by corr only (the channel - // is whatever route.open returned). - tokio::time::timeout(RPC_TIMEOUT, async { - loop { - let frame = read_frame(stream) - .await - .map_err(|e| format!("read: {e}"))? - .ok_or_else(|| "connection closed".to_string())?; - if frame.header.corr == corr - && matches!(frame.header.ty, FrameType::Response | FrameType::Error) - { - return Ok(frame); - } - } - }) - .await - .map_err(|_| "response timed out".to_string())? -} - -async fn read_matching(stream: &mut TcpStream, channel: u16, corr: u64) -> Result { - tokio::time::timeout(RPC_TIMEOUT, async { - loop { - let frame = read_frame(stream) - .await - .map_err(|e| format!("read: {e}"))? - .ok_or_else(|| "connection closed".to_string())?; - if frame.header.channel == channel - && frame.header.corr == corr - && matches!(frame.header.ty, FrameType::Response | FrameType::Error) - { - return Ok(frame); - } - } - }) - .await - .map_err(|_| "response timed out".to_string())? -} - -fn error_reason(body: &[u8]) -> String { - serde_json::from_slice::(body) - .ok() - .and_then(|v| { - v.get("message") - .or_else(|| v.get("detail")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| "module refused the op".to_string()) -} - fn hex(bytes: &[u8]) -> String { use std::fmt::Write; let mut s = String::with_capacity(bytes.len() * 2); diff --git a/crates/credentials-module/src/bin/cli_support/credential_client.rs b/crates/credentials-module/src/bin/cli_support/credential_client.rs new file mode 100644 index 0000000..dced6f7 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/credential_client.rs @@ -0,0 +1,155 @@ +use std::{fmt, path::Path}; + +use serde_json::{json, Value}; +use subc_protocol::FrameType; +use subc_transport::write_frame; + +use crate::route_client; + +pub struct ServedCredential { + pub payload: Vec, + pub record_version: u64, + pub expires_at_ms: Option, +} + +impl fmt::Debug for ServedCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServedCredential") + .field( + "payload", + &format_args!("<{} bytes redacted>", self.payload.len()), + ) + .field("record_version", &self.record_version) + .field("expires_at_ms", &self.expires_at_ms) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CredentialReadError { + NeedsReauth, + NotFound, + RefreshUnsupported, + RefreshFailed, + VaultLocked, + Corrupt, + TtlUnsatisfiable, + Refused, + Transport(String), +} + +impl fmt::Display for CredentialReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::NeedsReauth => "credential needs reauthentication", + Self::NotFound => "credential capability was not found", + Self::RefreshUnsupported => "credential refresh is unsupported", + Self::RefreshFailed => "credential refresh failed", + Self::VaultLocked => "credential vault is unavailable", + Self::Corrupt => "credential record is corrupt", + Self::TtlUnsatisfiable => "credential cannot meet the requested lifetime", + Self::Refused => "credential read was refused", + Self::Transport(cause) => return write!(f, "credential route is unavailable: {cause}"), + }; + f.write_str(message) + } +} + +impl std::error::Error for CredentialReadError {} + +pub fn get_online( + connection_file: &Path, + project_root: &Path, + handle: &str, +) -> Result { + std::env::remove_var("SUBC_MODULE_ID"); + std::env::remove_var("SUBC_LAUNCH_NONCE"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| CredentialReadError::Transport(format!("runtime: {e}")))?; + runtime.block_on(get_online_async(connection_file, project_root, handle)) +} + +async fn get_online_async( + connection_file: &Path, + project_root: &Path, + handle: &str, +) -> Result { + let stream = route_client::connect(connection_file) + .await + .map_err(|e| CredentialReadError::Transport(format!("connect: {e}")))?; + let mut route = route_client::open_route(stream, project_root, "ck-auth", "opencode-read") + .await + .map_err(|e| CredentialReadError::Transport(format!("route.open: {e}")))?; + let frame = route_client::route_request( + route.channel, + route.epoch, + 10, + json!({ + "method": "credential.get", + "params": { "handle": handle, "force_refresh": false, "min_ttl_ms": 0 }, + }), + ); + write_frame(&mut route.stream, &frame) + .await + .map_err(|e| CredentialReadError::Transport(format!("write credential.get: {e}")))?; + let response = route_client::read_route_response(&mut route.stream, 10) + .await + .map_err(|e| CredentialReadError::Transport(format!("read credential.get: {e}")))?; + if response.header.ty == FrameType::Error { + return Err(CredentialReadError::Refused); + } + decode_response(&response.body) +} + +fn decode_response(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body) + .map_err(|e| CredentialReadError::Transport(format!("decode response: {e}")))?; + let result = value + .get("result") + .ok_or_else(|| CredentialReadError::Transport("response omitted result".into()))?; + if let Some(error) = result.get("error") { + return Err(map_error(error)); + } + let payload = result + .get("payload") + .and_then(Value::as_array) + .ok_or_else(|| CredentialReadError::Transport("response omitted payload".into()))? + .iter() + .map(|byte| byte.as_u64().and_then(|byte| u8::try_from(byte).ok())) + .collect::>>() + .ok_or_else(|| CredentialReadError::Transport("response payload was invalid".into()))?; + let record_version = result + .get("record_version") + .and_then(Value::as_u64) + .ok_or_else(|| CredentialReadError::Transport("response omitted record version".into()))?; + let expires_at_ms = + match result.get("expires_at_ms") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_i64().ok_or_else(|| { + CredentialReadError::Transport("response expiry was invalid".into()) + })?), + }; + Ok(ServedCredential { + payload, + record_version, + expires_at_ms, + }) +} + +fn map_error(error: &Value) -> CredentialReadError { + match ( + error.get("class").and_then(Value::as_str), + error.get("code").and_then(Value::as_str), + ) { + (Some("auth_required"), Some("needs_reauth")) => CredentialReadError::NeedsReauth, + (_, Some("not_found")) => CredentialReadError::NotFound, + (_, Some("refresh_unsupported")) => CredentialReadError::RefreshUnsupported, + (_, Some("refresh_failed")) => CredentialReadError::RefreshFailed, + (_, Some("vault_locked")) => CredentialReadError::VaultLocked, + (_, Some("corrupt")) => CredentialReadError::Corrupt, + (_, Some("ttl_unsatisfiable")) => CredentialReadError::TtlUnsatisfiable, + _ => CredentialReadError::Refused, + } +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json b/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json new file mode 100644 index 0000000..8b03110 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode-provider-shapes.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "derived_from": { + "repo": "anomalyco/opencode", + "commit": "dc4449df0d", + "derivation": "grep `auth?.type === \"api\"` across provider/provider.ts and plugin/, attribute by walking the custom() map keys backwards from each hit, cross-reference the oauth-gate list", + "scope": "providers consuming a type:\"api\" key outside options.fetch in provider/provider.ts and plugin/ as of the named commit; a consumer elsewhere in the tree would be missed — this is the method's edge, not a proof over all of opencode", + "maintained_by": "OpenCode Source seat, delta per base update" + }, + "shape_definitions": { + "api-env": { + "why": "OpenCode copies the key into process.env at provider load; serving it would put material in the environment (opencode#46745)", + "if_forced": "the sentinel lands in process.env (e.g. AWS_BEARER_TOKEN_BEDROCK) and every child process inherits it; requests 401" + }, + "api-discovery": { + "why": "key used for model discovery or a loader closure outside the fetch seam; serving it would put material into provider options, which Provider.Info serializes", + "if_forced": "model discovery authenticates with the sentinel; the provider may appear configured but broken rather than failing plainly" + }, + "api-metadata": { + "why": "provider reads auth.metadata for api entries; a metadata-less tombstone breaks it", + "if_forced": "the provider reads auth.metadata (e.g. azure resourceName) that a tombstone does not carry, so it fails to construct at load — a different symptom from api-env/api-discovery, which construct and then 401 on the sentinel; still availability-only, the sentinel is non-secret" + } + }, + "providers": { + "amazon-bedrock": { "shapes": ["api-env"], "sites": ["provider.ts:324"] }, + "sap-ai-core": { "shapes": ["api-env"], "sites": ["provider.ts:583"] }, + "gitlab": { "shapes": ["api-discovery"], "sites": ["provider.ts:620,675"] }, + "cloudflare-workers-ai": { "shapes": ["api-discovery"], "sites": ["provider.ts:753"] }, + "cloudflare-ai-gateway": { "shapes": ["api-discovery"], "sites": ["provider.ts:800"] }, + "snowflake-cortex": { "shapes": ["api-discovery"], "sites": ["provider.ts:911"] }, + "modal": { "shapes": ["api-discovery"], "sites": ["plugin/modal/modal.ts:9"] }, + "azure": { "shapes": ["api-metadata"], "sites": ["provider.ts:252"] } + }, + "examined_servable": { + "github-copilot": "copilot.ts discovery reads ctx.auth.key but is gated by `if (ctx.auth?.type !== \"oauth\")`; an api-type entry never reaches the key path — deliberately servable" + }, + "maintainer_note": [ + "attribute each hit by walking the custom() map keys backwards, not by eyeballing (first pass put two gitlab sites under sap-ai-core; PRIVATE-TOKEN header was the only tell)", + "cross-reference the oauth-gate list — it is what keeps false entries like copilot out; a delta without both checks stated is refusable" + ] +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs b/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs new file mode 100644 index 0000000..da9e12b --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_accounts.rs @@ -0,0 +1,315 @@ +use std::collections::BTreeMap; +use std::io::Read; +use std::path::PathBuf; + +use super::{ + commit_admin, opencode_files, opencode_migration, request_admin_status, store_op, CliError, + GlobalArgs, +}; +use credentials_core::admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP_SCHEMA_V1}; +use credentials_core::oauth::CUSTODY_TOMBSTONE_PREFIX; +use credentials_core::record::{CredentialKind, VaultRecord}; + +pub(crate) fn cmd_opencode_account(global: &GlobalArgs, raw: &[String]) -> Result<(), CliError> { + let subcommand = raw + .first() + .ok_or_else(|| CliError::Usage("opencode-account requires add, remove, or list".into()))?; + match subcommand.as_str() { + "add" => add(global, &raw[1..]), + "remove" => remove(global, &raw[1..]), + "list" => list(global, &raw[1..]), + other => Err(CliError::Usage(format!( + "unknown opencode-account verb '{other}'" + ))), + } +} + +fn add(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider = required(args, "--provider")?; + let label = required(args, "--label")?; + validate_label(&label)?; + let key_file = required(args, "--key-file")?; + let before = optional(args, "--before"); + let handle_path = handle_path(args); + + if let Some(shape) = opencode_migration::unsafe_provider_shape(&provider)? { + return Err(CliError::Usage(format!( + "refusing opencode-account add for {provider}: shape={} why={} source={}; this is availability-only (the sentinel is non-secret), but account failover cannot make a provider outside the fetch seam safe; run migrate-opencode --restore {provider}", + shape.shape_names(), + shape.why(), + shape.sites(), + ))); + } + + let mut handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let provider_entry = handles + .providers + .iter() + .find(|entry| entry.provider == provider) + .ok_or_else(|| { + CliError::Usage(format!( + "provider {provider} is not migrated; run migrate-opencode first" + )) + })?; + if !matches!(provider_entry.shape, opencode_files::HandleShape::Api) + || provider_entry.serve.is_empty() + { + return Err(CliError::Usage(format!( + "provider {provider} has an unsupported handle shape; migrate-opencode supports api entries" + ))); + } + let existing_account = provider_entry + .accounts + .iter() + .find(|account| account.label == label) + .cloned(); + let insert_at = before + .as_deref() + .map(|wanted| { + provider_entry + .accounts + .iter() + .position(|account| account.label == wanted) + .ok_or_else(|| { + CliError::Usage(format!( + "--before label '{wanted}' does not exist for provider {provider}" + )) + }) + }) + .transpose()?; + + let material = read_key_material(&key_file)?; + if material.is_empty() { + return Err(CliError::Usage( + "--key-file contains no key material".into(), + )); + } + if material.starts_with(CUSTODY_TOMBSTONE_PREFIX.as_bytes()) { + return Err(CliError::Usage(format!( + "refusing reserved prefix={CUSTODY_TOMBSTONE_PREFIX} in --key-file" + ))); + } + let id = format!("apikey:{provider}:{label}"); + if let Some(account) = existing_account { + if global.subc_conn.is_none() { + return Err(CliError::Usage(format!( + "account label '{label}' already exists for provider {provider}" + ))); + } + if account.credential_id != id { + return Err(CliError::Io( + "existing handle account points at another credential".into(), + )); + } + let existing = opencode_migration::get_material(global, &account.handle)? + .ok_or_else(|| CliError::Io("existing account handle was revoked".into()))?; + if existing != material { + return Err(CliError::Usage(format!( + "existing credential {id} differs; remove the account before replacing it" + ))); + } + opencode_migration::finalize_superseded(global, &mut handles, &provider, &handle_path)?; + println!( + "provider={provider} label={label} credential_id={id} identical handle_file={}", + handle_path.display() + ); + return Ok(()); + } + + let exists = super::parse_inventory(&super::request_admin_status(global)?)? + .iter() + .any(|(_, _, candidate)| candidate == &id); + if exists { + opencode_migration::with_scoped_handle(global, &id, |verification_handle| { + let existing = opencode_migration::get_material(global, verification_handle)? + .ok_or_else(|| { + CliError::Io("fresh capability was revoked before comparison".into()) + })?; + if existing != material { + return Err(CliError::Usage(format!( + "existing credential {id} differs; remove the account before replacing it" + ))); + } + Ok(()) + })?; + opencode_migration::revoke_all_handles(global, &id)?; + } else { + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::Create, + ), + )?; + } + opencode_migration::mint_then_persist(global, &id, |handle| { + let provider_entry = handles + .providers + .iter_mut() + .find(|entry| entry.provider == provider) + .expect("provider validated before store"); + let account = opencode_files::HandleAccount { + label: label.clone(), + handle: handle.into(), + credential_id: id.clone(), + superseded: Vec::new(), + }; + if let Some(index) = insert_at { + provider_entry.accounts.insert(index, account); + } else { + provider_entry.accounts.push(account); + } + opencode_migration::write_and_verify_handles(&handle_path, &handles) + })?; + opencode_migration::finalize_superseded(global, &mut handles, &provider, &handle_path)?; + println!( + "provider={provider} label={label} credential_id={id} added handle_file={}", + handle_path.display() + ); + Ok(()) +} + +fn remove(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider = required(args, "--provider")?; + let label = required(args, "--label")?; + validate_label(&label)?; + let handle_path = handle_path(args); + let mut handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let provider_entry = handles + .providers + .iter() + .find(|entry| entry.provider == provider) + .ok_or_else(|| CliError::Usage(format!("no handle entry for provider {provider}")))?; + if !matches!(provider_entry.shape, opencode_files::HandleShape::Api) { + return Err(CliError::Usage(format!( + "remove for {provider} accepts only api entries" + ))); + } + let account = provider_entry + .accounts + .iter() + .find(|account| account.label == label) + .cloned() + .ok_or_else(|| { + CliError::Usage(format!( + "no account label '{label}' for provider {provider}" + )) + })?; + if provider_entry.accounts.len() == 1 { + return Err(CliError::Usage(format!( + "refusing to remove the last account for {provider}; use migrate-opencode --restore" + ))); + } + + let mut handles_to_revoke = vec![account.handle]; + handles_to_revoke.extend(account.superseded); + for handle in handles_to_revoke { + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + } + opencode_migration::remove_account(&mut handles, &provider, &label)?; + opencode_migration::write_and_verify_handles(&handle_path, &handles)?; + println!( + "provider={provider} label={label} removed handle_file={}", + handle_path.display() + ); + Ok(()) +} + +fn list(global: &GlobalArgs, args: &[String]) -> Result<(), CliError> { + let provider_filter = optional(args, "--provider"); + let handle_path = handle_path(args); + let handles = opencode_migration::read_handles_or_empty(&handle_path)?; + let status = super::parse_inventory(&request_admin_status(global)?)?; + let metadata: BTreeMap = status + .into_iter() + .map(|(state, version, id)| (id, (state, version))) + .collect(); + + for provider_entry in handles.providers { + if provider_filter + .as_deref() + .is_some_and(|wanted| wanted != provider_entry.provider) + { + continue; + } + for account in provider_entry.accounts { + let (state, version) = metadata.get(&account.credential_id).ok_or_else(|| { + CliError::Io(format!( + "handle account {} points at missing credential {}", + account.label, account.credential_id + )) + })?; + println!( + "provider={} label={} credential_id={} {state} v{version}", + provider_entry.provider, account.label, account.credential_id + ); + } + } + Ok(()) +} + +fn validate_label(label: &str) -> Result<(), CliError> { + if label.contains(':') { + return Err(CliError::Usage("account label must not contain ':'".into())); + } + if label.is_empty() + || label.len() > 64 + || matches!(label, "__proto__" | "constructor" | "prototype") + || !label.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' => true, + b'.' | b'_' | b'-' => index > 0, + _ => false, + }) + { + return Err(CliError::Usage( + "account label must match [a-z0-9][a-z0-9._-]{0,63}".into(), + )); + } + Ok(()) +} + +fn read_key_material(path: &str) -> Result, CliError> { + if path == "-" { + let mut material = Vec::new(); + std::io::stdin() + .read_to_end(&mut material) + .map_err(|error| CliError::Io(format!("read key material from stdin: {error}")))?; + trim_terminal_newline(&mut material); + return Ok(material); + } + std::fs::read(path).map_err(|error| CliError::Io(format!("read key file {path}: {error}"))) +} + +fn trim_terminal_newline(material: &mut Vec) { + if material.ends_with(b"\r\n") { + material.truncate(material.len() - 2); + } else if material.ends_with(b"\n") { + material.pop(); + } +} + +fn handle_path(args: &[String]) -> PathBuf { + optional(args, "--handle-file") + .map(PathBuf::from) + .unwrap_or_else(opencode_files::default_handle_path) +} + +fn required(args: &[String], flag: &str) -> Result { + optional(args, flag).ok_or_else(|| CliError::Usage(format!("{flag} is required"))) +} + +fn optional(args: &[String], flag: &str) -> Option { + args.iter() + .position(|arg| arg == flag) + .and_then(|index| args.get(index + 1)) + .filter(|value| !value.starts_with("--")) + .cloned() +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_files.rs b/crates/credentials-module/src/bin/cli_support/opencode_files.rs new file mode 100644 index 0000000..09c6cc1 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_files.rs @@ -0,0 +1,535 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); +const AUTH_FILE_MAX_BYTES: u64 = 1024 * 1024; +const HANDLE_FILE_MAX_BYTES: u64 = 256 * 1024; + +#[derive(Debug)] +pub enum OpenCodeFilesError { + Io { + action: &'static str, + source: std::io::Error, + }, + Json(serde_json::Error), + InsecureParent { + path: PathBuf, + reason: &'static str, + }, + Invalid(String), +} + +impl fmt::Display for OpenCodeFilesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { action, source } => write!(f, "{action}: {source}"), + Self::Json(source) => write!(f, "JSON: {source}"), + Self::InsecureParent { path, reason } => { + write!( + f, + "parent directory {} is insecure: {reason}", + path.display() + ) + } + Self::Invalid(message) => f.write_str(message), + } + } +} + +impl std::error::Error for OpenCodeFilesError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TombstoneFixture { + pub provider: String, + pub entry: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TombstoneFixtures { + pub api: TombstoneFixture, + pub oauth: TombstoneFixture, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleFile { + pub version: u64, + pub providers: Vec, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleProvider { + pub provider: String, + pub shape: HandleShape, + #[serde(default)] + pub serve: String, + pub accounts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HandleShape { + Api, + Oauth, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandleAccount { + pub label: String, + pub handle: String, + pub credential_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub superseded: Vec, +} + +impl fmt::Debug for HandleFile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleFile") + .field("version", &self.version) + .field("providers", &self.providers) + .finish() + } +} + +impl fmt::Debug for HandleProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleProvider") + .field("provider", &self.provider) + .field("shape", &self.shape) + .field("serve", &self.serve) + .field("accounts", &self.accounts) + .finish() + } +} + +impl fmt::Debug for HandleAccount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HandleAccount") + .field("label", &self.label) + .field("handle", &"ckh_[redacted]") + .field("credential_id", &self.credential_id) + .field( + "superseded", + &format_args!("<{} ckh_[redacted]>", self.superseded.len()), + ) + .finish() + } +} + +pub fn default_auth_path() -> PathBuf { + let data_home = std::env::var_os("XDG_DATA_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".local/share")) + }) + .unwrap_or_else(|| PathBuf::from(".local/share")); + data_home.join("opencode").join("auth.json") +} + +pub fn default_handle_path() -> PathBuf { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".config")) + }) + .unwrap_or_else(|| PathBuf::from(".config")); + config_home.join("cortexkit").join("opencode-handles.json") +} + +pub fn golden_tombstone_fixtures() -> Result { + let golden: Value = serde_json::from_str(include_str!( + "../../../../../packages/opencode/golden/tombstone.json" + )) + .map_err(OpenCodeFilesError::Json)?; + let fixture = |shape: &str| -> Result { + let item = &golden["fixtures"][shape]; + let provider = item["provider"] + .as_str() + .filter(|provider| !provider.is_empty()) + .ok_or_else(|| { + OpenCodeFilesError::Invalid(format!("golden {shape} provider is invalid")) + })? + .to_string(); + let entry = item["entry"].clone(); + validate_auth_entry(&entry)?; + Ok(TombstoneFixture { provider, entry }) + }; + Ok(TombstoneFixtures { + api: fixture("api")?, + oauth: fixture("oauth")?, + }) +} + +pub fn read_auth_entries(path: &Path) -> Result, OpenCodeFilesError> { + validate_secure_file(path)?; + let bytes = read_limited(path, AUTH_FILE_MAX_BYTES, "auth file")?; + let entries: BTreeMap = + serde_json::from_slice(&bytes).map_err(OpenCodeFilesError::Json)?; + for (provider, entry) in &entries { + validate_identifier(provider, "provider")?; + validate_auth_entry(entry)?; + } + Ok(entries) +} + +pub fn write_auth_entry( + path: &Path, + provider: &str, + entry: Value, +) -> Result<(), OpenCodeFilesError> { + validate_identifier(provider, "provider")?; + validate_auth_entry(&entry)?; + let mut entries = if path.exists() { + read_auth_entries(path)? + } else { + BTreeMap::new() + }; + entries.insert(provider.to_string(), entry); + let bytes = serde_json::to_vec(&entries).map_err(OpenCodeFilesError::Json)?; + write_atomic(path, &bytes, false) +} + +pub fn verify_auth_written( + path: &Path, + provider: &str, + expected: &Value, +) -> Result<(), OpenCodeFilesError> { + let entries = read_auth_entries(path)?; + if entries.get(provider) != Some(expected) { + return Err(OpenCodeFilesError::Invalid( + "auth entry did not persist exactly".into(), + )); + } + Ok(()) +} + +pub fn read_handle_file(path: &Path) -> Result { + validate_secure_file(path)?; + let bytes = read_limited(path, HANDLE_FILE_MAX_BYTES, "handle file")?; + let file: HandleFile = serde_json::from_slice(&bytes).map_err(OpenCodeFilesError::Json)?; + validate_handle_file(&file)?; + Ok(file) +} + +pub fn write_handle_file(path: &Path, file: &HandleFile) -> Result<(), OpenCodeFilesError> { + validate_handle_file(file)?; + let bytes = serde_json::to_vec(file).map_err(OpenCodeFilesError::Json)?; + write_atomic(path, &bytes, true) +} + +pub fn verify_handle_written(path: &Path, expected: &HandleFile) -> Result<(), OpenCodeFilesError> { + if &read_handle_file(path)? != expected { + return Err(OpenCodeFilesError::Invalid( + "handle file did not persist exactly".into(), + )); + } + Ok(()) +} + +fn validate_auth_entry(entry: &Value) -> Result<(), OpenCodeFilesError> { + let object = entry + .as_object() + .ok_or_else(|| OpenCodeFilesError::Invalid("auth entry must be an object".into()))?; + match object.get("type").and_then(Value::as_str) { + Some("api") | Some("oauth") | Some("wellknown") => Ok(()), + _ => Err(OpenCodeFilesError::Invalid("unknown auth shape".into())), + } +} + +fn validate_handle_file(file: &HandleFile) -> Result<(), OpenCodeFilesError> { + if file.version != 1 { + return Err(OpenCodeFilesError::Invalid( + "handle file must have version 1".into(), + )); + } + let mut provider_ids = BTreeSet::new(); + for (index, provider) in file.providers.iter().enumerate() { + if !identifier_is_valid(&provider.provider) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} has invalid provider" + ))); + } + if !provider_ids.insert(&provider.provider) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} duplicates provider {}", + provider.provider + ))); + } + match provider.shape { + HandleShape::Api | HandleShape::Oauth => {} + } + if provider.serve.is_empty() { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} requires serve" + ))); + } + let mut labels = BTreeSet::new(); + for account in &provider.accounts { + if !identifier_is_valid(&account.label) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} has an invalid account label" + ))); + } + if !labels.insert(&account.label) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} duplicates account label {}", + account.label + ))); + } + if !valid_handle(&account.handle) { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid handle", + account.label + ))); + } + if account.credential_id.is_empty() { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid credential id", + account.label + ))); + } + if account + .superseded + .iter() + .any(|handle| !valid_handle(handle)) + { + return Err(OpenCodeFilesError::Invalid(format!( + "provider {index} account {} has invalid superseded handle", + account.label + ))); + } + } + } + Ok(()) +} + +fn valid_handle(handle: &str) -> bool { + handle.starts_with("ckh_") && handle.len() == 47 +} + +fn identifier_is_valid(value: &str) -> bool { + !matches!(value, "__proto__" | "constructor" | "prototype") + && !value.is_empty() + && value.len() <= 64 + && value.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'0'..=b'9' => true, + b'.' | b'_' | b'-' => index > 0, + _ => false, + }) +} + +fn validate_identifier(value: &str, kind: &str) -> Result<(), OpenCodeFilesError> { + if identifier_is_valid(value) { + Ok(()) + } else { + Err(OpenCodeFilesError::Invalid(format!( + "{kind} must match [a-z0-9][a-z0-9._-]{{0,63}}" + ))) + } +} + +fn write_atomic(path: &Path, bytes: &[u8], secure_parent: bool) -> Result<(), OpenCodeFilesError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no parent".into()))?; + fs::create_dir_all(parent).map_err(|source| OpenCodeFilesError::Io { + action: "create parent directory", + source, + })?; + validate_secure_parent(parent)?; + if secure_parent { + set_mode(parent, 0o700)?; + } + let name = path + .file_name() + .ok_or_else(|| OpenCodeFilesError::Invalid("file path has no filename".into()))?; + let temp = parent.join(format!( + ".{}.{}.{}.tmp", + name.to_string_lossy(), + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| -> Result<(), OpenCodeFilesError> { + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp) + .map_err(|source| OpenCodeFilesError::Io { + action: "create temporary file", + source, + })? + }; + #[cfg(not(unix))] + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|source| OpenCodeFilesError::Io { + action: "create temporary file", + source, + })?; + set_mode(&temp, 0o600)?; + file.write_all(bytes) + .map_err(|source| OpenCodeFilesError::Io { + action: "write temporary file", + source, + })?; + file.sync_all().map_err(|source| OpenCodeFilesError::Io { + action: "sync temporary file", + source, + })?; + fs::rename(&temp, path).map_err(|source| OpenCodeFilesError::Io { + action: "rename temporary file", + source, + })?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|source| OpenCodeFilesError::Io { + action: "sync parent directory", + source, + })?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn validate_secure_file(path: &Path) -> Result<(), OpenCodeFilesError> { + let metadata = fs::symlink_metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat file", + source, + })?; + if !metadata.file_type().is_file() { + return Err(OpenCodeFilesError::Invalid( + "file must be a regular file".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != current_uid()? { + return Err(OpenCodeFilesError::Invalid( + "file is not owned by the current uid".into(), + )); + } + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(OpenCodeFilesError::Invalid( + "file mode must be exactly 0600".into(), + )); + } + } + Ok(()) +} + +fn read_limited(path: &Path, max_bytes: u64, kind: &str) -> Result, OpenCodeFilesError> { + let metadata = fs::metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat file for read limit", + source, + })?; + if metadata.len() > max_bytes { + let limit = if max_bytes == AUTH_FILE_MAX_BYTES { + "1 MiB".into() + } else { + format!("{} KiB", max_bytes / 1024) + }; + return Err(OpenCodeFilesError::Invalid(format!( + "{kind} exceeds {limit}", + ))); + } + fs::read(path).map_err(|source| OpenCodeFilesError::Io { + action: "read file", + source, + }) +} + +fn validate_secure_parent(path: &Path) -> Result<(), OpenCodeFilesError> { + let metadata = fs::symlink_metadata(path).map_err(|source| OpenCodeFilesError::Io { + action: "stat parent directory", + source, + })?; + if !metadata.file_type().is_dir() { + return Err(OpenCodeFilesError::Invalid( + "parent directory must be a directory".into(), + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if metadata.uid() != current_uid()? { + return Err(OpenCodeFilesError::InsecureParent { + path: path.into(), + reason: "not owned by the current uid", + }); + } + let mode = metadata.permissions().mode(); + if mode & 0o002 != 0 && mode & 0o1000 == 0 { + return Err(OpenCodeFilesError::InsecureParent { + path: path.into(), + reason: "world-writable without sticky bit", + }); + } + } + Ok(()) +} + +fn set_mode(path: &Path, mode: u32) -> Result<(), OpenCodeFilesError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| { + OpenCodeFilesError::Io { + action: "set file mode", + source, + } + })?; + } + #[cfg(not(unix))] + { + let _ = (path, mode); + } + Ok(()) +} + +#[cfg(unix)] +fn current_uid() -> Result { + std::process::Command::new("/usr/bin/id") + .arg("-u") + .output() + .map_err(|source| OpenCodeFilesError::Io { + action: "determine current uid", + source, + }) + .and_then(|output| { + if !output.status.success() { + return Err(OpenCodeFilesError::Invalid( + "determine current uid failed".into(), + )); + } + String::from_utf8(output.stdout) + .map_err(|_| OpenCodeFilesError::Invalid("current uid was not UTF-8".into()))? + .trim() + .parse() + .map_err(|_| OpenCodeFilesError::Invalid("current uid was invalid".into())) + }) +} diff --git a/crates/credentials-module/src/bin/cli_support/opencode_migration.rs b/crates/credentials-module/src/bin/cli_support/opencode_migration.rs new file mode 100644 index 0000000..b64cca6 --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/opencode_migration.rs @@ -0,0 +1,852 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::{ + commit_admin, credential_client, opencode_files, parse_inventory, request_admin_status, + store_op, CliError, GlobalArgs, +}; +use credentials_core::admin_ops::{AdminAuditOp, AdminOpBody, StoreMode, ADMIN_OP_SCHEMA_V1}; +use credentials_core::oauth::CUSTODY_TOMBSTONE_PREFIX as TOMBSTONE_PREFIX; +use credentials_core::record::{CredentialKind, VaultRecord}; + +const ACCOUNT: &str = "main"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ProviderShape { + Api, + ApiEnv, + ApiDiscovery, + ApiMetadata, +} + +impl ProviderShape { + fn as_str(self) -> &'static str { + match self { + Self::Api => "api", + Self::ApiEnv => "api-env", + Self::ApiDiscovery => "api-discovery", + Self::ApiMetadata => "api-metadata", + } + } +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeDefinition { + why: String, + if_forced: String, +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeEntry { + shapes: Vec, + sites: Vec, +} + +#[derive(Debug, Deserialize)] +struct ProviderShapeTable { + version: u8, + shape_definitions: BTreeMap, + providers: BTreeMap, + examined_servable: BTreeMap, + maintainer_note: Vec, +} + +#[derive(Debug)] +pub(crate) struct UnsafeProviderShape { + shapes: Vec, + why: Vec, + if_forced: Vec, + sites: Vec, +} + +impl UnsafeProviderShape { + pub(crate) fn shape_names(&self) -> String { + self.shapes + .iter() + .map(|shape| shape.as_str()) + .collect::>() + .join(",") + } + + pub(crate) fn why(&self) -> String { + self.why.join(" | ") + } + + pub(crate) fn sites(&self) -> String { + self.sites.join(",") + } + + fn if_forced(&self) -> String { + self.if_forced.join(" | ") + } +} + +fn provider_shape_table() -> Result<&'static ProviderShapeTable, CliError> { + static TABLE: OnceLock> = OnceLock::new(); + TABLE + .get_or_init(|| { + let table: ProviderShapeTable = + serde_json::from_str(include_str!("opencode-provider-shapes.json")) + .map_err(|error| format!("provider shape table is invalid JSON: {error}"))?; + if table.version != 1 { + return Err(format!( + "provider shape table has unsupported version {}", + table.version + )); + } + if !table.examined_servable.contains_key("github-copilot") { + return Err( + "provider shape table must record github-copilot as examined and servable" + .into(), + ); + } + if table.maintainer_note.len() != 2 { + return Err( + "provider shape table must retain both derivation traps for maintainers".into(), + ); + } + for (provider, entry) in &table.providers { + if entry.shapes.is_empty() || entry.sites.is_empty() { + return Err(format!( + "provider shape table has an empty shape or site list for {provider}" + )); + } + for shape in &entry.shapes { + if *shape == ProviderShape::Api || !table.shape_definitions.contains_key(shape) + { + return Err(format!( + "provider shape table has an undefined non-api shape for {provider}" + )); + } + } + } + Ok(table) + }) + .as_ref() + .map_err(|error| CliError::Io(error.clone())) +} + +pub(crate) fn unsafe_provider_shape( + provider: &str, +) -> Result, CliError> { + let Some(entry) = provider_shape_table()?.providers.get(provider) else { + return Ok(None); + }; + if entry.shapes.as_slice() == [ProviderShape::Api] { + return Ok(None); + } + let definitions = &provider_shape_table()?.shape_definitions; + let mut why = Vec::new(); + let mut if_forced = Vec::new(); + for shape in &entry.shapes { + let definition = definitions.get(shape).ok_or_else(|| { + CliError::Io(format!( + "provider shape table is missing {}", + shape.as_str() + )) + })?; + why.push(definition.why.clone()); + if_forced.push(definition.if_forced.clone()); + } + Ok(Some(UnsafeProviderShape { + shapes: entry.shapes.clone(), + why, + if_forced, + sites: entry.sites.clone(), + })) +} + +struct MigrationArgs { + dry_run: bool, + replace: bool, + force_shape: bool, + restore: Option, + auth_file: PathBuf, + handle_file: PathBuf, + providers: Vec, + serve_by: String, +} + +pub fn cmd_migrate_opencode(global: &GlobalArgs, raw: &[String]) -> Result<(), CliError> { + let args = MigrationArgs::parse(raw)?; + if let Some(provider) = &args.restore { + return restore_provider(global, &args, provider); + } + migrate_providers(global, &args) +} + +impl MigrationArgs { + fn parse(raw: &[String]) -> Result { + let mut dry_run = false; + let mut replace = false; + let mut force_shape = false; + let mut restore = None; + let mut auth_file = None; + let mut handle_file = None; + let mut providers = Vec::new(); + let mut serve_by = None; + let mut index = 0; + while index < raw.len() { + match raw[index].as_str() { + "--dry-run" => dry_run = true, + "--replace" => replace = true, + "--force-shape" => force_shape = true, + "--restore" | "--auth-file" | "--handle-file" | "--provider" | "--serve-by" => { + let value = raw + .get(index + 1) + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| CliError::Usage(format!("{} requires a value", raw[index])))? + .clone(); + match raw[index].as_str() { + "--restore" => restore = Some(value), + "--auth-file" => auth_file = Some(PathBuf::from(value)), + "--handle-file" => handle_file = Some(PathBuf::from(value)), + "--provider" => providers.push(value), + "--serve-by" => serve_by = Some(value), + _ => unreachable!(), + } + index += 1; + } + _ => {} + } + index += 1; + } + if restore.is_some() && (dry_run || replace || force_shape || !providers.is_empty()) { + return Err(CliError::Usage( + "--restore is mutually exclusive with --dry-run, --replace, --force-shape, and --provider".into(), + )); + } + Ok(Self { + dry_run, + replace, + force_shape, + restore, + auth_file: auth_file.unwrap_or_else(opencode_files::default_auth_path), + handle_file: handle_file.unwrap_or_else(opencode_files::default_handle_path), + providers, + serve_by: serve_by.unwrap_or_else(|| "opencode-claustrum".into()), + }) + } +} + +fn migrate_providers(global: &GlobalArgs, args: &MigrationArgs) -> Result<(), CliError> { + let auth = opencode_files::read_auth_entries(&args.auth_file).map_err(files_error)?; + let providers = selected_api_providers(&auth, &args.providers)?; + if providers.is_empty() { + println!("no eligible OpenCode api entries"); + return Ok(()); + } + for provider in providers { + if let Some(shape) = unsafe_provider_shape(&provider)? { + if !args.force_shape { + println!( + "provider={provider} refused shape={} why={} source={}; use --force-shape to override. availability-only, sentinel non-secret.", + shape.shape_names(), + shape.why(), + shape.sites(), + ); + continue; + } + println!( + "provider={provider} force_shape shape={} consequence={}; availability-only, sentinel non-secret.", + shape.shape_names(), + shape.if_forced(), + ); + } + let entry = auth.get(&provider).expect("selected provider exists"); + if is_api_tombstone(entry, &provider) { + if args.dry_run { + println!("provider={provider} tombstone=api dry_run pending_revoke_check"); + } else { + let mut handles = read_handles_or_empty(&args.handle_file)?; + let recovered = + finalize_superseded(global, &mut handles, &provider, &args.handle_file)?; + println!( + "provider={provider} tombstone=api {}", + if recovered { "recovered" } else { "identical" } + ); + } + continue; + } + let material = api_material(entry, &provider)?; + if material.starts_with(TOMBSTONE_PREFIX.as_bytes()) { + println!("provider={provider} refused reserved prefix={TOMBSTONE_PREFIX}"); + continue; + } + migrate_one(global, args, &provider, material)?; + } + Ok(()) +} + +fn migrate_one( + global: &GlobalArgs, + args: &MigrationArgs, + provider: &str, + material: Vec, +) -> Result<(), CliError> { + let id = format!("apikey:{provider}:{ACCOUNT}"); + let status = parse_inventory(&request_admin_status(global)?)?; + let exists = status.iter().any(|(_, _, candidate)| candidate == &id); + let mut handles = read_handles_or_empty(&args.handle_file)?; + if !args.dry_run { + finalize_superseded(global, &mut handles, provider, &args.handle_file)?; + } + let known_handle = account_handle(&handles, provider, ACCOUNT, &id)?; + + if args.dry_run { + let verdict = if !exists { + "absent" + } else if known_handle.is_none() { + "requires_capability_mint" + } else { + "requires_capability_read" + }; + println!("provider={provider} credential_id={id} dry_run compare={verdict}"); + return Ok(()); + } + + let mut old_handle = known_handle; + let mut same = false; + if exists { + let handle = match old_handle.as_deref() { + Some(handle) => handle.to_owned(), + None => { + let handle = mint_then_persist(global, &id, |handle| { + update_handle(&mut handles, provider, &args.serve_by, &id, handle, false)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(handle.to_owned()) + })?; + old_handle = Some(handle.clone()); + handle + } + }; + match get_material(global, &handle)? { + Some(existing) => same = existing == material, + None => { + let replacement = mint_then_persist(global, &id, |replacement| { + update_handle( + &mut handles, + provider, + &args.serve_by, + &id, + replacement, + false, + )?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(replacement.to_owned()) + })?; + old_handle = Some(replacement.clone()); + same = get_material(global, &replacement)?.ok_or_else(|| { + CliError::Io("a freshly minted capability was revoked".into()) + })? == material; + } + } + if !same && !args.replace { + return Err(CliError::Usage(format!( + "existing credential {id} differs; rerun with --replace" + ))); + } + } + + let outcome = if !exists { + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::Create, + ), + )?; + "created" + } else if same { + "identical" + } else { + let reread = opencode_files::read_auth_entries(&args.auth_file).map_err(files_error)?; + if reread + .get(provider) + .and_then(|entry| entry.get("key")) + .and_then(Value::as_str) + .map(str::as_bytes) + != Some(material.as_slice()) + { + return Err(CliError::Io( + "OpenCode auth entry changed before replacement".into(), + )); + } + commit_admin( + global, + store_op( + &id, + VaultRecord::new_static(CredentialKind::ApiKey, "opencode", material, None), + AdminAuditOp::Import, + StoreMode::ReplaceUnconditional, + ), + )?; + "replaced" + }; + + let replacement_required = !exists || outcome == "replaced"; + if replacement_required { + mint_then_persist(global, &id, |handle| { + update_handle(&mut handles, provider, &args.serve_by, &id, handle, true)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(()) + })? + } else { + let handle = old_handle + .clone() + .ok_or_else(|| CliError::Io("missing current handle after comparison".into()))?; + let superseded = + update_handle(&mut handles, provider, &args.serve_by, &id, &handle, false)?; + if superseded.as_deref() != Some(handle.as_str()) { + write_and_verify_handles(&args.handle_file, &handles)?; + } + } + + let tombstone = api_tombstone(provider); + opencode_files::write_auth_entry(&args.auth_file, provider, tombstone.clone()) + .map_err(files_error)?; + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_TOMBSTONE_REREAD").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode files: auth entry did not persist exactly; re-run converges from the written tombstone" + .into(), + )); + } + opencode_files::verify_auth_written(&args.auth_file, provider, &tombstone) + .map_err(files_error)?; + finalize_superseded(global, &mut handles, provider, &args.handle_file)?; + println!( + "provider={provider} credential_id={id} {outcome} handle_file={} tombstone=api", + args.handle_file.display() + ); + Ok(()) +} + +fn restore_provider( + global: &GlobalArgs, + args: &MigrationArgs, + provider: &str, +) -> Result<(), CliError> { + let mut handles = read_handles_or_empty(&args.handle_file)?; + let provider_index = handles + .providers + .iter() + .position(|item| item.provider == provider) + .ok_or_else(|| CliError::Usage(format!("no handle entry for provider {provider}")))?; + if !matches!( + handles.providers[provider_index].shape, + opencode_files::HandleShape::Api + ) { + return Err(CliError::Usage(format!( + "restore for {provider} accepts only api entries" + ))); + } + let accounts = handles.providers[provider_index].accounts.clone(); + for account in accounts { + let mut handle = account.handle.clone(); + let material = match get_material(global, &handle) { + Ok(Some(material)) => material, + Ok(None) => { + handle = mint_then_persist(global, &account.credential_id, |handle| { + update_specific_handle(&mut handles, provider, &account.label, handle)?; + write_and_verify_handles(&args.handle_file, &handles)?; + Ok(handle.to_owned()) + })?; + get_material(global, &handle)? + .ok_or_else(|| CliError::Io("a freshly minted capability was revoked".into()))? + } + Err(CliError::Io(message)) if message == "credential needs reauthentication" => { + return Err(CliError::Io(format!( + "refusing restore for {}: vault record needs re-authentication", + account.credential_id + ))); + } + Err(error) => return Err(error), + }; + let key = String::from_utf8(material) + .map_err(|_| CliError::Io("api credential material was not UTF-8".into()))?; + let entry = json!({"type": "api", "key": key}); + opencode_files::write_auth_entry(&args.auth_file, provider, entry.clone()) + .map_err(files_error)?; + opencode_files::verify_auth_written(&args.auth_file, provider, &entry) + .map_err(files_error)?; + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + remove_account(&mut handles, provider, &account.label)?; + write_and_verify_handles(&args.handle_file, &handles)?; + } + println!( + "provider={provider} restored handle_file={}", + args.handle_file.display() + ); + Ok(()) +} + +fn selected_api_providers( + auth: &BTreeMap, + filters: &[String], +) -> Result, CliError> { + let candidates: Vec = if filters.is_empty() { + auth.iter() + .filter(|(_, entry)| entry.get("type").and_then(Value::as_str) == Some("api")) + .map(|(provider, _)| provider.clone()) + .collect() + } else { + let mut seen = BTreeSet::new(); + filters + .iter() + .filter(|provider| seen.insert((*provider).clone())) + .filter_map(|provider| auth.get(provider).map(|entry| (provider, entry))) + .filter(|(_, entry)| entry.get("type").and_then(Value::as_str) == Some("api")) + .map(|(provider, _)| provider.clone()) + .collect() + }; + for provider in filters { + if !auth.contains_key(provider) { + return Err(CliError::Usage(format!( + "OpenCode auth has no provider {provider}" + ))); + } + } + Ok(candidates) +} + +fn api_material(entry: &Value, provider: &str) -> Result, CliError> { + entry + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .map(|key| key.as_bytes().to_vec()) + .ok_or_else(|| CliError::Usage(format!("OpenCode api entry for {provider} has no key"))) +} + +fn api_tombstone(provider: &str) -> Value { + json!({"type": "api", "key": format!("{TOMBSTONE_PREFIX}{provider}")}) +} + +pub(crate) fn is_api_tombstone(entry: &Value, provider: &str) -> bool { + entry == &api_tombstone(provider) +} + +pub(crate) fn read_handles_or_empty(path: &Path) -> Result { + if path.exists() { + opencode_files::read_handle_file(path).map_err(files_error) + } else { + Ok(opencode_files::HandleFile { + version: 1, + providers: Vec::new(), + }) + } +} + +fn account_handle( + handles: &opencode_files::HandleFile, + provider: &str, + label: &str, + credential_id: &str, +) -> Result, CliError> { + let Some(provider) = handles + .providers + .iter() + .find(|item| item.provider == provider) + else { + return Ok(None); + }; + if !matches!(provider.shape, opencode_files::HandleShape::Api) || provider.serve.is_empty() { + return Err(CliError::Io( + "handle provider is not an api custody entry".into(), + )); + } + match provider + .accounts + .iter() + .find(|account| account.label == label) + { + Some(account) if account.credential_id != credential_id => Err(CliError::Io( + "handle account credential id does not match the api main record".into(), + )), + Some(account) => Ok(Some(account.handle.clone())), + None => Ok(None), + } +} + +pub(crate) fn finalize_superseded( + global: &GlobalArgs, + handles: &mut opencode_files::HandleFile, + provider: &str, + handle_path: &Path, +) -> Result { + let pending: Vec = handles + .providers + .iter() + .find(|item| item.provider == provider) + .map(|item| { + item.accounts + .iter() + .flat_map(|account| account.superseded.iter().cloned()) + .collect() + }) + .unwrap_or_default(); + if pending.is_empty() { + return Ok(false); + } + for handle in pending { + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle, + }, + )?; + } + let item = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + .ok_or_else(|| { + CliError::Io("handle provider disappeared before superseded revoke".into()) + })?; + for account in &mut item.accounts { + account.superseded.clear(); + } + write_and_verify_handles(handle_path, handles)?; + Ok(true) +} + +fn update_handle( + handles: &mut opencode_files::HandleFile, + provider: &str, + serve_by: &str, + credential_id: &str, + handle: &str, + retain_superseded: bool, +) -> Result, CliError> { + if let Some(item) = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + { + if !matches!(item.shape, opencode_files::HandleShape::Api) { + return Err(CliError::Io("provider handle shape is not api".into())); + } + item.serve = serve_by.into(); + if let Some(account) = item + .accounts + .iter_mut() + .find(|account| account.label == ACCOUNT) + { + if account.credential_id != credential_id { + return Err(CliError::Io( + "main handle account points at another credential".into(), + )); + } + let old = std::mem::replace(&mut account.handle, handle.into()); + if retain_superseded && old != handle && !account.superseded.contains(&old) { + account.superseded.push(old.clone()); + } + return Ok(Some(old)); + } + item.accounts.push(opencode_files::HandleAccount { + label: ACCOUNT.into(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }); + return Ok(None); + } + handles.providers.push(opencode_files::HandleProvider { + provider: provider.into(), + shape: opencode_files::HandleShape::Api, + serve: serve_by.into(), + accounts: vec![opencode_files::HandleAccount { + label: ACCOUNT.into(), + handle: handle.into(), + credential_id: credential_id.into(), + superseded: Vec::new(), + }], + }); + Ok(None) +} + +fn update_specific_handle( + handles: &mut opencode_files::HandleFile, + provider: &str, + label: &str, + handle: &str, +) -> Result<(), CliError> { + let account = handles + .providers + .iter_mut() + .find(|item| item.provider == provider) + .and_then(|item| { + item.accounts + .iter_mut() + .find(|account| account.label == label) + }) + .ok_or_else(|| CliError::Io("handle account disappeared before restore".into()))?; + account.handle = handle.into(); + Ok(()) +} + +pub(crate) fn remove_account( + handles: &mut opencode_files::HandleFile, + provider: &str, + label: &str, +) -> Result<(), CliError> { + let index = handles + .providers + .iter() + .position(|item| item.provider == provider) + .ok_or_else(|| CliError::Io("handle provider disappeared before restore".into()))?; + let accounts = &mut handles.providers[index].accounts; + let account = accounts + .iter() + .position(|account| account.label == label) + .ok_or_else(|| CliError::Io("handle account disappeared before restore".into()))?; + accounts.remove(account); + if handles.providers[index].accounts.is_empty() { + handles.providers.remove(index); + } + Ok(()) +} + +pub(crate) fn write_and_verify_handles( + path: &Path, + handles: &opencode_files::HandleFile, +) -> Result<(), CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_HANDLE_WRITE").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode files: handle file write interrupted; re-run converges from the stored credential" + .into(), + )); + } + opencode_files::write_handle_file(path, handles).map_err(files_error)?; + opencode_files::verify_handle_written(path, handles).map_err(files_error) +} + +pub(crate) fn mint_handle(global: &GlobalArgs, id: &str) -> Result { + commit_admin( + global, + AdminOpBody::MintHandle { + v: ADMIN_OP_SCHEMA_V1, + id: id.into(), + }, + )? + .get("handle") + .and_then(Value::as_str) + .filter(|handle| !handle.is_empty()) + .map(Into::into) + .ok_or_else(|| CliError::Io("mint did not return a handle".into())) +} + +/// Mints a handle for `id` and runs `persist` with it. This is for handles meant to outlive the +/// call: if `persist` fails, the handle is revoked before the error propagates, so a failed file +/// write never strands a live bearer capability. Use `with_scoped_handle` when the handle is only +/// needed during the closure and must die before it returns. If the revoke also fails, the returned +/// error names the credential id and the two commands that close the window (`ck auth audit` shows +/// the mint; `ck auth revoke-all-handles ` revokes it). The `superseded` journal covers the +/// replace-then-crash case; this covers mint-then-crash, whose journal precondition is what failed. +pub(crate) fn mint_then_persist( + global: &GlobalArgs, + id: &str, + persist: impl FnOnce(&str) -> Result, +) -> Result { + let handle = mint_handle(global, id)?; + match persist(&handle) { + Ok(value) => Ok(value), + Err(persist_error) => match revoke_handle(global, &handle) { + Ok(()) => Err(persist_error), + Err(revoke_error) => Err(CliError::Io(format!( + "failed to persist minted handle for credential {id}: {persist_error}; cleanup also failed: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + ))), + }, + } +} + +/// Mints a handle for a single operation and revokes it on every return path. +pub(crate) fn with_scoped_handle( + global: &GlobalArgs, + id: &str, + operation: impl FnOnce(&str) -> Result, +) -> Result { + let handle = mint_handle(global, id)?; + match operation(&handle) { + Ok(value) => revoke_handle(global, &handle).map_err(|revoke_error| { + CliError::Io(format!( + "verification handle cleanup failed for credential {id}: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + )) + }) + .map(|()| value), + Err(operation_error) => match revoke_handle(global, &handle) { + Ok(()) => Err(operation_error), + Err(revoke_error) => Err(CliError::Io(format!( + "operation using verification handle for credential {id} failed: {operation_error}; cleanup also failed: {revoke_error}; run `ck auth audit` to find the mint, then `ck auth revoke-all-handles {id}` to revoke it" + ))), + }, + } +} + +pub(crate) fn revoke_handle(global: &GlobalArgs, handle: &str) -> Result<(), CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_REVOKE").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode test seam: handle revoke interrupted".into(), + )); + } + commit_admin( + global, + AdminOpBody::RevokeHandle { + v: ADMIN_OP_SCHEMA_V1, + handle: handle.into(), + }, + )?; + Ok(()) +} + +pub(crate) fn revoke_all_handles(global: &GlobalArgs, id: &str) -> Result<(), CliError> { + commit_admin( + global, + AdminOpBody::RevokeAllHandles { + v: ADMIN_OP_SCHEMA_V1, + id: id.into(), + }, + )?; + Ok(()) +} + +pub(crate) fn get_material(global: &GlobalArgs, handle: &str) -> Result>, CliError> { + #[cfg(debug_assertions)] + if std::env::var("CK_OPENCODE_TEST_FAIL_GET_MATERIAL").as_deref() == Ok("1") { + return Err(CliError::Io( + "OpenCode test seam: material read interrupted".into(), + )); + } + let connection = global.subc_conn.as_deref().ok_or_else(|| { + CliError::Usage("migrate-opencode needs --subc for capability reads".into()) + })?; + match credential_client::get_online(connection, &global.data_dir, handle) { + Ok(credential) => Ok(Some(credential.payload)), + Err(credential_client::CredentialReadError::NotFound) => Ok(None), + Err(credential_client::CredentialReadError::NeedsReauth) => { + Err(CliError::Io("credential needs reauthentication".into())) + } + Err(error) => Err(CliError::Io(error.to_string())), + } +} + +fn files_error(error: opencode_files::OpenCodeFilesError) -> CliError { + CliError::Io(format!("OpenCode files: {error}")) +} diff --git a/crates/credentials-module/src/bin/cli_support/route_client.rs b/crates/credentials-module/src/bin/cli_support/route_client.rs new file mode 100644 index 0000000..02868bb --- /dev/null +++ b/crates/credentials-module/src/bin/cli_support/route_client.rs @@ -0,0 +1,166 @@ +use std::{path::Path, time::Duration}; + +use credentials_core::MODULE_ID; +use serde_json::{json, Value}; +use subc_protocol::{BindIdentity, Flags, Frame, FrameType, Priority, RouteTarget}; +use subc_transport::{authenticate_client, connection_file, read_frame, write_frame}; +use tokio::net::TcpStream; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const RPC_TIMEOUT: Duration = Duration::from_secs(15); + +pub struct OpenRoute { + pub stream: TcpStream, + pub channel: u16, + pub epoch: u32, +} + +pub async fn connect(connection_file_path: &Path) -> Result { + let conn = connection_file::read_for_client(connection_file_path) + .map_err(|e| format!("no subc connection file: {e}"))?; + let endpoint = conn + .endpoints + .first() + .ok_or_else(|| "connection file has no endpoint".to_string())?; + let mut stream = match tokio::time::timeout( + CONNECT_TIMEOUT, + TcpStream::connect((endpoint.host.as_str(), endpoint.port)), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => return Err(format!("connect: {e}")), + Err(_) => return Err("connect timed out".into()), + }; + authenticate_client(&mut stream, &conn, CONNECT_TIMEOUT) + .await + .map_err(|e| format!("client handshake: {e}"))?; + Ok(stream) +} + +pub async fn catalog_has_module(stream: &mut TcpStream) -> Result { + let frame = control_request(1, json!({ "op": "catalog.list" })); + write_frame(stream, &frame) + .await + .map_err(|e| format!("write catalog.list: {e}"))?; + let response = read_control_response(stream, 1).await?; + let value: Value = serde_json::from_slice(&response.body).map_err(|e| e.to_string())?; + Ok(value["modules"] + .as_array() + .map(|modules| { + modules + .iter() + .any(|module| module["module_id"] == MODULE_ID) + }) + .unwrap_or(false)) +} + +pub async fn open_route( + stream: TcpStream, + project_root: &Path, + harness: &str, + session: &str, +) -> Result { + let mut stream = stream; + let target = RouteTarget::ManagementSurface { + module_id: MODULE_ID.to_string(), + }; + let identity = BindIdentity { + project_root: project_root.to_path_buf(), + harness: harness.to_string(), + session: session.to_string(), + }; + let frame = control_request( + 2, + json!({ "op": "route.open", "target": target, "identity": identity }), + ); + write_frame(&mut stream, &frame) + .await + .map_err(|e| format!("write route.open: {e}"))?; + let response = read_control_response(&mut stream, 2).await?; + if response.header.ty == FrameType::Error { + return Err(error_reason(&response.body)); + } + let value: Value = serde_json::from_slice(&response.body).map_err(|e| e.to_string())?; + let channel = value["route_channel"] + .as_u64() + .and_then(|channel| u16::try_from(channel).ok()) + .ok_or_else(|| "route.open returned no route_channel".to_string())?; + let epoch = value["route_epoch"] + .as_u64() + .and_then(|epoch| u32::try_from(epoch).ok()) + .ok_or_else(|| "route.open returned no route_epoch".to_string())?; + Ok(OpenRoute { + stream, + channel, + epoch, + }) +} + +pub fn control_request(corr: u64, body: Value) -> Frame { + Frame::build( + FrameType::Request, + Flags::new(false, Priority::Passive, false), + 0, + 0, + corr, + serde_json::to_vec(&body).expect("JSON control request"), + ) + .expect("valid control frame") +} + +pub fn route_request(channel: u16, epoch: u32, corr: u64, body: Value) -> Frame { + Frame::build( + FrameType::Request, + Flags::new(false, Priority::Interactive, false), + channel, + epoch, + corr, + serde_json::to_vec(&body).expect("JSON route request"), + ) + .expect("valid route frame") +} + +pub async fn read_control_response(stream: &mut TcpStream, corr: u64) -> Result { + read_matching(stream, Some(0), corr).await +} + +pub async fn read_route_response(stream: &mut TcpStream, corr: u64) -> Result { + read_matching(stream, None, corr).await +} + +async fn read_matching( + stream: &mut TcpStream, + required_channel: Option, + corr: u64, +) -> Result { + tokio::time::timeout(RPC_TIMEOUT, async { + loop { + let frame = read_frame(stream) + .await + .map_err(|e| format!("read: {e}"))? + .ok_or_else(|| "connection closed".to_string())?; + if required_channel.is_none_or(|channel| frame.header.channel == channel) + && frame.header.corr == corr + && matches!(frame.header.ty, FrameType::Response | FrameType::Error) + { + return Ok(frame); + } + } + }) + .await + .map_err(|_| "response timed out".to_string())? +} + +pub fn error_reason(body: &[u8]) -> String { + serde_json::from_slice::(body) + .ok() + .and_then(|value| { + value + .get("message") + .or_else(|| value.get("detail")) + .and_then(Value::as_str) + .map(String::from) + }) + .unwrap_or_else(|| "module refused the operation".to_string()) +} diff --git a/crates/credentials-module/src/bin/credentials_cli.rs b/crates/credentials-module/src/bin/credentials_cli.rs index 89e2d3d..52e0f69 100644 --- a/crates/credentials-module/src/bin/credentials_cli.rs +++ b/crates/credentials-module/src/bin/credentials_cli.rs @@ -10,8 +10,8 @@ //! `open_sqlite`: if the daemon is running it holds the lease, so the CLI's acquire //! fails and the operator is told to stop the daemon, making "while the daemon is //! stopped" a structural precondition rather than an honor-system one. A plain route -//! consumer (transport key only, no master key, no lease) cannot reach this path at -//! all — there is no running-vault admin surface. +//! consumer (transport key only, no master key, no lease) cannot reach these writes; +//! secret reads remain capability-gated on the consumer route. //! //! Every write goes through the epoch-fenced path and appends an audit-chain entry //! (flagged as an admin write) atomically with the mutation. Bootstrap (first run) @@ -25,6 +25,7 @@ //! mint-signing-key --id signing:[:] [--replace] //! import --source opencode|pi|antigravity --id --json //! set-identity --account-id [--email ] [--org-name ] | --clear +//! migrate-opencode [--dry-run] [--replace] [--force-shape] [--restore ] //! invalidate --id //! rotate-master-key //! mint-handle --id print a fresh handle (once) @@ -45,12 +46,24 @@ use std::process::ExitCode; mod admin_client; #[path = "cli_support/api_key_login.rs"] mod api_key_login; +#[allow(dead_code)] +#[path = "cli_support/credential_client.rs"] +mod credential_client; #[path = "cli_support/google_login.rs"] mod google_login; #[path = "cli_support/login_listener.rs"] mod login_listener; +#[path = "cli_support/opencode_accounts.rs"] +mod opencode_accounts; +#[allow(dead_code)] +#[path = "cli_support/opencode_files.rs"] +mod opencode_files; +#[path = "cli_support/opencode_migration.rs"] +mod opencode_migration; #[path = "cli_support/provider_login.rs"] mod provider_login; +#[path = "cli_support/route_client.rs"] +mod route_client; use base64::Engine; use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor, StoreError}; @@ -267,6 +280,8 @@ fn run() -> Result<(), CliError> { "mint-signing-key" => cmd_mint_signing_key(&global, &args), "import" => cmd_import(&global, &args), "set-identity" => cmd_set_identity(&global, &args), + "migrate-opencode" => opencode_migration::cmd_migrate_opencode(&global, &args), + "opencode-account" => opencode_accounts::cmd_opencode_account(&global, &args), "login" => cmd_login(&global, &args), "invalidate" => cmd_invalidate(&global, &args), "reactivate" => cmd_reactivate(&global, &args), @@ -352,6 +367,20 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { "--org-name", ], "set-identity" => &["--account-id", "--email", "--org-name"], + "migrate-opencode" => &[ + "--restore", + "--auth-file", + "--handle-file", + "--provider", + "--serve-by", + ], + "opencode-account" => &[ + "--provider", + "--label", + "--key-file", + "--before", + "--handle-file", + ], "login" => &["--provider", "--id", "--payload-file", "--account"], "invalidate" | "reactivate" | "mint-handle" | "revoke-all-handles" | "remove" => &["--id"], "logout" => &["--provider", "--id"], @@ -370,6 +399,7 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { "import" => &["--replace", "--clear-identity"], "set-identity" => &["--clear"], "login" => &["--replace", "--no-listener", "--device"], + "migrate-opencode" => &["--dry-run", "--replace", "--force-shape"], _ => &[], }; let mut i = @@ -380,6 +410,10 @@ fn reject_unknown_args(command: &str, args: &[String]) -> Result<(), CliError> { }; while i < args.len() { let arg = &args[i]; + if command == "opencode-account" && matches!(arg.as_str(), "add" | "remove" | "list") { + i += 1; + continue; + } if bool_flags.contains(&arg.as_str()) { i += 1; continue; @@ -417,6 +451,8 @@ fn usage_short() -> String { mint-signing-key generate and custody a new Ed25519 signing key\n\ import import from opencode/pi/gemini-cli/antigravity\n\ set-identity attach non-secret account metadata to one credential\n\ + migrate-opencode custody OpenCode api auth entries idempotently\n\ + opencode-account add/remove/list labeled OpenCode api accounts\n\ mint-handle mint a capability handle for a credential\n\ revoke-handle revoke one capability handle\n\ revoke-all-handles revoke every handle for a credential\n\ @@ -555,6 +591,36 @@ fn help_verb(verb: &str) -> String { and bumps record_version because the encrypted envelope changed. Works for any\n\ decryptable record, including needs-reauth or retired records." } + "migrate-opencode" => { + "ck auth migrate-opencode [--dry-run] [--replace] [--force-shape] [--restore ]\n\ + \x20 [--auth-file ] [--handle-file ] [--provider ]...\n\ + \x20 [--serve-by ]\n\ + \n\ + Move OpenCode api entries into the vault as apikey::main, write a\n\ + capability handle file, then replace the auth entry with a provider tombstone.\n\ + Re-running identical material is a no-op; different material refuses unless\n\ + --replace is explicit. --provider is repeatable and preserves the requested\n\ + provider order. OAuth and wellknown entries are skipped by default.\n\ + \n\ + --dry-run prints non-secret compare verdicts and stops before every write.\n\ + Providers whose api key leaves the generic fetch seam are refused with source\n\ + citations. --force-shape overrides that availability-only refusal and prints the\n\ + concrete sentinel consequence.\n\ + --restore safely writes an api entry back, revokes recorded handles,\n\ + and removes that provider from the handle file. --restore cannot combine with\n\ + --dry-run or --replace. The default --serve-by is opencode-claustrum." + } + "opencode-account" => { + "ck auth opencode-account add --provider --label