Skip to content

Claude Token Orchestrator

jackin workspace claude-token glues five primitives together to take a workspace from “no token configured” to “OAuth-token mode active and end-to-end validated”, with the operator never seeing the token value:

  1. Probe — verify the upstream claude CLI is on PATH and capture its version. See host_claude::probe_claude_cli in src/host_claude.rs.

  2. Capture — drive claude setup-token interactively under a PTY. The operator completes the OAuth flow in their browser; the long-lived token is captured into secrecy::SecretString. Every line written to the operator’s stderr passes through a redactor that swaps the token span for <redacted> so the URL and prompts still display, but the token never echoes. See host_claude::capture_setup_token in src/host_claude.rs.

  3. Write — push the token into a new 1Password item via op item create -. The JSON template (item title, category, tags, notesPlain provenance stamp, and the field value) lands on the child’s stdin; the secret never crosses argv. See OpWriteRunner::item_create in src/operator_env.rs.

  4. Validate — re-read the just-written value through the same op:// reference and compare its SHA-256 prefix against what was captured. A vault-routing surprise would otherwise leave a wired-but-broken slot pointing at an item the operator never minted. On mismatch the orphan is best-effort deleted and the run aborts with no on-disk config changed.

  5. Persist — only after validation succeeds, the workspace’s [claude].auth_forward = "oauth_token" and [env].CLAUDE_CODE_OAUTH_TOKEN = "op://..." are written through ConfigEditor (comment-preserving). An expiry stamp is then cached locally so the launch banner can surface “expires in N days”.

This file documents each step’s load-bearing details, the failure modes, and the test seams.

WhatFile
PTY capture, ANSI redactor, version probesrc/host_claude.rs
Orchestrator state machines (run_setup / run_revoke / run_doctor)src/workspace/token_setup.rs
OpWriteRunner trait + OpCli impl (1P CLI driver)src/operator_env.rs
CLI dispatch (handle_claude_token, rotate cleanup)src/app/mod.rs
clap subcommand surfacesrc/cli/workspace.rs
Container provisioning of the OAuth-token onboarding skeletonsrc/instance/auth.rs
Launch-time mount selection per forward_authsrc/runtime/launch.rs
Expiry banner formattersrc/tui/output.rs

claude setup-token is interactive: it opens a browser for OAuth consent and reads keystrokes (paste codes, ENTER, Ctrl-C). It also queries the parent terminal via DA1 / XTVERSION escape sequences, expecting cooked-mode line buffering to be off so the responses flow back into its stdin. Capturing its output through a plain Command::new(...).output() pipe breaks every one of those contracts.

The orchestrator therefore runs claude setup-token under a pseudo-terminal:

  • A PTY pair is allocated via portable-pty. The child sees a real terminal on stdin / stdout.
  • The host’s terminal is switched into raw mode for the lifetime of the capture so individual keystrokes reach the child byte-for-byte. A RawModeGuard RAII type restores cooked mode on drop — including on panic via stack unwind. The operator’s terminal must not be left in raw mode after a crash, so the Drop impl logs a recovery hint (stty sane) on failure rather than swallowing the error.
  • A worker thread pumps the operator’s stdin into the PTY master. A real read error mid-flow (BrokenPipe, EIO, terminal detach) surfaces a [jackin] warning: stdin pump terminated mid-flow: … before the thread exits — without that notice, claude appears to hang while the operator’s keystrokes are dropped on the floor.
  • The parent reads from the PTY master and forwards each \n-terminated line through a redactor (next section). Tail bytes without a trailing newline are flushed when the child exits.

A PTY read error mid-capture kills the child, drains stderr, and bails with “any captured token must be considered compromised; re-run setup”. The token may have been partially emitted into the operator’s view; treating it as live would be unsafe.

The redactor in forward_redacted_line scans each line for the sk-ant-oat01- prefix (TOKEN_PREFIX). On match it captures the token bytes — alphanumerics or hyphens — into Option<String>, then writes the line to stderr with the matched span replaced by the literal text <redacted>. The redactor:

  • Only captures the first token. Subsequent matches are still redacted in the operator-visible output but do not overwrite the captured value. This prevents a “header line announces the token + body line repeats it” upstream re-design from silently swapping which value gets stored.
  • Walks past ANSI / VT escape sequences inside the token. The upstream CLI splits the 108-character token across two visual rows using cursor-down / cursor-position CSIs; a naive stop-at-first-control redactor captured 79 characters and produced “API Error: 401” at next launch. The hand-rolled skip_ansi_escape handles CSI (\x1b[), OSC (\x1b]), and bare two-byte escapes — enough for the cursor-movement sequences upstream emits between the prefix and the token body. DCS / SOS / PM / APC are not handled; if upstream ever uses them, swap to vte (the file’s hand-roll comment names it as the canonical alternative).
  • Operates on bytes, not strings. PTY chunks may arrive mid-UTF-8-codepoint; we line-buffer into a Vec<u8> and only forward complete \n-terminated lines, so a partial codepoint can never reach the prefix scanner mid-byte.

The captured token is wrapped in secrecy::SecretString before returning. SecretString’s Debug impl prints "[REDACTED]", so even an inadvertent tracing::debug!("{secret:?}") cannot leak the value.

The token never crosses argv. OpWriteRunner::item_create serialises the item template — title, category, tags, notesPlain provenance stamp, and the field value — to a single JSON payload, then spawns op item create --vault <id> --format json - and writes the payload on the child’s stdin. The trailing - tells upstream op to read the template from stdin.

Reasoning: argv is visible via /proc/<pid>/cmdline to any local unprivileged reader; the op item create --field value=<token> form would expose the token for the lifetime of the op process. Stdin is not visible the same way.

The trait deliberately omits an item_edit_field method:

The upstream op CLI has no documented stdin form for op item edit, and any argv form would violate the stdin-only contract this trait declares. Rotation is implemented as item_create (new item) + item_delete (old item once the new one is wired and validated).

OpWriteRunner doc comment in src/operator_env.rs

op item create echoes the created item back as JSON, including a fields[*].value for every field. Stripping the secret from a free-form JSON walk is fragile, so the orchestrator deserialises into RawCreatedItem — a struct that deliberately omits the value field. Serde-tolerant of unknown fields, the secret is discarded at the deserialisation boundary; the rest of the code path only ever sees ids, labels, and references.

If the JSON shape ever drifts (upstream renames a field, returns empty fields), the error message lists labels / ids only and points the operator at “the item may have been created but its layout is unrecognised; inspect or delete by hand”. The fallback never names the value.

OpCli::with_account(Some(id)) pins every subprocess invocation to op --account <id>. The orchestrator constructs one OpCli per run via the op_cli_for(config, workspace, explicit) -> OpCli helper which folds the rule “explicit --op-account flag wins over the workspace’s stored op_account field”. The same helper is used by every run_* entry point, so account resolution lives in one place.

OpWriteRunner::item_delete accepts a per-call account override that wins over the pinned OpCli::account — used by the orphan-cleanup path so a stale account context never causes a delete to land in the wrong vault.

This is the load-bearing safety net. After a successful item_create, the orchestrator does not persist any config. Instead it re-reads the item through the same op:// reference the writer returned, computes a SHA-256 prefix of the resolved value, and compares it against the prefix of the captured token.

If the comparison succeeds, persistence proceeds. If it fails (or the read itself errors), the orchestrator must do two things:

  1. Leave config untouched. A wired slot pointing at an item the operator never minted would silently inject a mystery token at the next launch. The persistence step is gated behind the validation result for exactly this reason.
  2. Best-effort delete the orphan. The just-created 1P item is live; abandoning it would accumulate dangling secrets in the operator’s vault.

The cleanup attempt’s outcome is folded into the bail message via the OrphanCleanup enum:

enum OrphanCleanup {
Deleted,
UnparseableRef { op: String },
DeleteFailed { err: String, hint: String },
}

OrphanCleanup implements Display. The bail message templates use a single ". {cleanup}" join; the enum’s Display impl emits a self-contained sentence per variant:

  • Deleted — “The just-created 1P item was deleted.”
  • UnparseableRef — “Orphan was NOT deleted: op-ref <op> did not parse into vault/item ids; remove the freshly-created item by hand from 1Password.”
  • DeleteFailed — “The just-created 1P item was NOT deleted (<err>); remove by hand: <hint>.” where <hint> is the exact op item delete <id> --vault <vault> recovery command produced by OpReferenceParts::manual_delete_hint.

The only constructor is OrphanCleanup::run(op_writer, &op_ref, account). Parse failure short-circuits before any delete attempt, so DeleteFailed is structurally unreachable when UnparseableRef would also be.

Once validation has succeeded, the orchestrator opens a ConfigEditor and applies, in order:

  1. set_workspace_auth_forward(workspace, Agent::Claude, AuthForwardMode::OAuthToken).
  2. set_env_var(EnvScope::Workspace(ws), CLAUDE_OAUTH_TOKEN_ENV, EnvValue::OpRef(op_ref)).
  3. If --op-account was passed AND differs from any stored op_account, set_workspace_op_account(ws, Some(account)).
  4. editor.save() — single atomic write back to disk.

ConfigEditor preserves the surrounding TOML’s comments and key ordering, so the operator’s hand-edits survive a setup run.

The editor is opened after validation succeeds. A failure between item-create and editor-open leaves the 1P item live but no config wired; re-running setup is safe because validation re-creates a fresh item rather than mutating the orphan.

The CLI’s config env unset and the TUI’s auth panel both refuse to delete CLAUDE_CODE_OAUTH_TOKEN while auth_forward = "oauth_token" is active; the only supported clear path is jackin workspace claude-token revoke, which switches both keys atomically.

OAuthToken provisioning inside the container

Section titled “OAuthToken provisioning inside the container”

When the launcher prepares the role-state directory for an agent whose effective auth_forward is oauth_token, provision_claude_auth (in src/instance/auth.rs) takes a different shape from the other modes:

  • Sync — copy host ~/.claude.json to account.json, write host credentials to credentials.json, forward_auth = true.
  • OAuthToken — remove any prior credentials.json (revokes forwarded creds from a previous Sync run) and write {"hasCompletedOnboarding":true} to account.json, forward_auth = true. Without that skeleton, the in-container Claude CLI shows its “Select login method” prompt even when CLAUDE_CODE_OAUTH_TOKEN is set in env.
  • ApiKey / Ignore — wipe both files, forward_auth = false.

agent_mounts then bind-mounts account.json (and credentials.json when present) into the container under /jackin/claude/. The per-file exists() guard keeps a stale credentials.json out of the container if a prior provision-step removal failed silently — defence in depth against a credential file surviving the mode switch.

The orchestrator stamps a YYYY-MM-DD file under <cache_dir>/claude-token-expiry/<workspace> after a successful setup or rotate. The launch banner reads the stamp via expiry_days_for_launch and renders an “expires in N days” suffix on the auth-mode notice; the suffix’s colour follows the days-remaining count (red ≤ 7, yellow ≤ 30, dim otherwise).

The function returns Result<Option<i64>> precisely so a malformed stamp surfaces a one-shot warning to the operator instead of silently degrading to “no expiry known”. The launch site explicitly matches the Err arm rather than .ok().flatten()-ing it — collapsing the error variant defeats the design.

The --reuse setup path does not write a stamp. jackin did not mint the token in that flow, so the issuance date is unknown and any stamp would mislead the operator.

revoke removes the stamp so the launch banner stops showing a countdown for a workspace whose managed token source is gone.

run_revoke(paths, config, workspace, delete_op_item):

  1. Read the prior CLAUDE_CODE_OAUTH_TOKEN slot from the workspace.
  2. If delete_op_item == true, the prior slot must hold a parseable op:// reference. If it holds a literal token or an unparseable URI, bail with an explicit error — the operator asked for a 1P-side delete and a silent no-op would let the secret survive in the vault. The --delete-op-item flag is never honoured implicitly.
  3. Issue op item delete <item> --vault <vault> via the pinned OpCli.
  4. Open ConfigEditor, remove CLAUDE_CODE_OAUTH_TOKEN from the workspace’s env block, set the workspace’s Claude auth_forward = ignore, save().
  5. Clear the cached expiry stamp.

item_delete failure propagates before editor.save runs, so the workspace config is unchanged. A re-run of revoke is safe once the underlying issue (auth, permission) is fixed.

rotate is setup + delete_prior_op_item:

  1. Read the prior workspace slot. If it holds an op:// reference, default --vault to the prior item’s vault — without this, the documented rotate <ws> form would hard-error inside create_op_item after the operator completes the PTY token capture. See vault_for_rotate for the precedence rule.
  2. Run run_setup end-to-end. Validation, config persistence, and the new expiry stamp all complete first.
  3. delete_prior_op_item(prior, &report.op_ref, account) — parses the prior op://, calls item_delete with the parsed UUIDs.
  4. If the delete fails, the rotate exits non-zero with a copy-pasteable op item delete <id> --vault <vault> recovery command. The new item is wired and live; the orphan needs hand-removal.
  5. The same-ref guard (prior_ref.op == new_ref.op) prevents rotate from deleting the new item it just created if a deeper bug ever causes them to match. The guard’s eprintln tells the operator the situation is unexpected and to run doctor to verify.

run_doctor is a structural / connectivity check — it does not contact Claude’s API. The cheapest reliable way to confirm an OAuth token is valid upstream is to launch a workspace and observe the auth banner; doctor’s job is to confirm the managed workspace env slot resolves without errors:

  1. Read the workspace’s CLAUDE_CODE_OAUTH_TOKEN. Missing slot → actionable “run setup first” error.
  2. If the slot holds an op:// reference, resolve through op read. The resolution failure is wrapped with the resolved path so the operator’s terminal output matches what they see in 1P.
  3. SHA-256-prefix the resolved value and emit it in the report so the operator can confirm the slot points at the item they expect.

Doctor is the right tool to run after the launch banner says “API Error: 401 Unauthorized”: doctor will return Ok if the slot plumbs cleanly, which means the token itself is invalid upstream (rotated externally, manually revoked); doctor will return Err if jackin’s wiring is the problem.

Every entry point that talks to the world (claude setup-token, op, the host filesystem) is split into a thin entry point and a _with_runner variant that takes injected runners:

Entry point_with_runner variantInjected dependencies
run_setuprun_setup_with_runnerOption<&ClaudeProbe>, capture closure, &dyn OpRunner, &dyn OpWriteRunner
run_revokerun_revoke_with_runner&dyn OpWriteRunner
run_doctorrun_doctor_with_runner&dyn OpRunner
delete_prior_op_item (rotate cleanup)delete_prior_op_item_with_runner&dyn OpWriteRunner

The unit tests inside mod tests of src/workspace/token_setup.rs spawn no claude, no op, no real PTY. They use FakeOpReader and FakeOpWriter (records every item_create / item_delete call, optional with_failing_delete() to exercise the DeleteFailed arm). Pre-resolved ClaudeProbe fixtures stand in for the host CLI probe.

The post-write SHA-mismatch and read-failure paths are the most load-bearing safety net in the orchestrator and are covered with the strictest assertions: error-message substring, no on-disk config change, exactly one cleanup-delete fired against the canonical UUIDs, no expiry stamp written.

When extending the orchestrator, prefer to extend an existing _with_runner shim and add a fake-injected test rather than plumbing a new global. The runners and their fakes are the project’s main lever for keeping the unit-test suite hermetic.

Hacks and load-bearing details, summarised

Section titled “Hacks and load-bearing details, summarised”
  • PTY raw mode + RAII guardclaude setup-token needs byte-for-byte stdin and unbuffered stdout for DA1/XTVERSION responses; cooked mode breaks the contract. RawModeGuard restores cooked mode on Drop and surfaces a recovery hint on restore-failure.
  • ANSI escape skipper inside the token — upstream splits the 108-char token across two visual rows with cursor-position CSIs. A naive control-stop redactor captures 79 chars and produces 401s at next launch.
  • Stdin-only secret pass to op item create - — argv is visible via /proc/<pid>/cmdline to local unprivileged readers; stdin is not.
  • RawCreatedItem deliberately omits value — the JSON echo from op item create carries the secret back. Discarding it at the deserialisation boundary is more robust than scrubbing later.
  • Post-write SHA round-trip — vault-routing surprises (item landed in the wrong vault, upstream op schema drift) must never leave a wired-but-broken slot. The validation read + prefix comparison is the safety net.
  • OrphanCleanup enum + Display — the cleanup-attempt outcome rides into the bail message as a structured value, not string concatenation. The “every arm starts with a leading space” implicit contract from the prior closure form is gone.
  • Config persisted last — a partial failure earlier must never leave a wired-but-broken slot. The editor open + save sequence is gated behind the validation result.
  • OAuthToken onboarding skeleton — without {"hasCompletedOnboarding":true} in account.json, the in-container Claude CLI ignores CLAUDE_CODE_OAUTH_TOKEN and shows the login wizard. The skeleton is jackin-managed and bind-mounted; it is the only file mounted into the container under OAuth-token mode.
  • expiry_days_for_launch returns Result<Option<i64>> — splitting “absent stamp” (the normal case) from “malformed stamp” (the should-warn case) so a corrupt cache surfaces once on the next launch instead of silently disappearing the countdown.
  • Revoke --delete-op-item is hard-error on literals — the operator opted into a 1P-side delete; a silent fall-through when the managed env slot can’t be deleted would let the secret survive.
  • Same-ref guard in rotate — protects against deleting the freshly-created item if the new and prior op:// references ever match.

When changing any of the above, update this page in the same PR.