fleet: share Claude credentials by directory env, not a seat symlink

Claude Code saves credentials by writing a sibling temp file and rename()-ing
it over the target. rename(2) replaces a symlink rather than following it, so
the managed link W-F1/W-F2 planted at <seat>/.claude/.credentials.json is
destroyed by the first token refresh and the seat silently forks its
credentials. The in-place fallback arm opens with O_NOFOLLOW and would refuse
the link anyway. Evidence, quoting the 2.1.232 binary:
docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain).

CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the credential directory
independently of CLAUDE_CONFIG_DIR, so the temp file and the rename both land
inside the bundle. That is the property the design wanted -- share the
credential, never the transcripts -- with no symlink and no privileges.

- new fleet/credential-sharing.ts owns the harness -> credential-file and
  harness -> credential-directory-variable maps, so scaffold and launch cannot
  disagree about the mechanism. It also removes the duplicate credential-file
  name table the two already carried.
- launch composes CLAUDE_SECURESTORAGE_CONFIG_DIR from the resolved bundle
  directory and plans no credential link for Claude. The value is always the
  absolute bundle path: Claude reads an empty value as ~/.claude, which is the
  operator's own account.
- scaffold stops emitting the credential symlink and its manifest entry for
  Claude, and tolerates one left by an earlier scaffold rather than reporting
  it as a foreign file or rewriting it.
- FIRST_AUTH_REFUSAL still fires when a real file occupies the seat path.
- Harnesses absent from the map (pi, codex, opencode) keep managed links; the
  containment specs now exercise them on pi.

Answers promotion gate #1 negatively for the frozen mechanism and positively
for the replacement. E3.3 (two seats refreshing one bundle at once) is still
open.
This commit is contained in:
terra
2026-08-14 18:20:54 -05:00
parent 326a1a58b5
commit a12eeb4786
6 changed files with 245 additions and 47 deletions
@@ -23,6 +23,10 @@ import {
type RuntimeName,
} from './launch.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
import {
CREDENTIAL_DIR_ENV as CREDENTIAL_DIR_ENV_BY_HARNESS,
CREDENTIAL_FILE_NAMES,
} from '../fleet/credential-sharing.js';
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [
@@ -41,12 +45,9 @@ const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREDENTIAL_FILES: Record<RuntimeName, string> = {
claude: '.credentials.json',
pi: 'auth.json',
codex: 'auth.json',
opencode: 'auth.json',
};
// Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart.
const CREDENTIAL_FILES: Record<RuntimeName, string> = CREDENTIAL_FILE_NAMES;
const CREDENTIAL_DIR_ENV: Partial<Record<RuntimeName, string>> = CREDENTIAL_DIR_ENV_BY_HARNESS;
export type FleetLaunchErrorCode =
| 'SCHEMA_TOO_NEW'
@@ -125,8 +126,14 @@ export interface FleetLaunchComposition {
readonly display: string;
};
readonly credential: {
readonly link: string;
/**
* The seat-local managed link to the bundle credential. Absent for harnesses
* that reach the shared bundle by environment instead (see CREDENTIAL_DIR_ENV).
*/
readonly link?: string;
readonly target: string;
/** Resolved bundle directory holding the credential file. */
readonly dir: string;
};
readonly managedLinks: ManagedLinkState;
readonly installs: readonly PlannedLink[];
@@ -448,6 +455,10 @@ function resolveCredential(
`first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`,
);
}
// Environment-shared harnesses never read the seat-local path, so no link is
// planned for it. A leftover link from an earlier scaffold is inert: the harness
// resolves its credential directory from the environment instead.
const sharesByEnv = CREDENTIAL_DIR_ENV[profile.harness] !== undefined;
const resolvedName = basename(resolvedBundleDir);
const email = accountEmail(resolvedBundleDir);
@@ -462,7 +473,11 @@ function resolveCredential(
...(email === undefined ? {} : { email }),
display,
},
credential: { link: credentialLink, target: resolvedCredential },
credential: {
...(sharesByEnv ? {} : { link: credentialLink }),
target: resolvedCredential,
dir: resolvedBundleDir,
},
};
}
@@ -754,9 +769,15 @@ export function resolveFleetLaunchComposition(
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness];
const env: Record<string, string> = {
...profile.env,
[homeEnvName[profile.harness]]: seatHome,
// Only ever an absolute bundle path. Claude reads an empty value as ~/.claude,
// which is the operator's own account, so an empty value is never exported.
...(credentialDirEnvName === undefined
? {}
: { [credentialDirEnvName]: credential.credential.dir }),
MOSAIC_AGENT_NAME: name,
};
return {
@@ -839,7 +860,13 @@ function canonicalJson(value: unknown): unknown {
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
// All link-state checks must complete before the first filesystem mutation.
// This makes a late foreign/retargeted link refusal leave the seat untouched.
assertManagedLinkMutationAllowed(plan.credential.link, plan.credential.target, plan.managedLinks);
if (plan.credential.link !== undefined) {
assertManagedLinkMutationAllowed(
plan.credential.link,
plan.credential.target,
plan.managedLinks,
);
}
for (const path of plan.prune)
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
for (const install of plan.installs) {
@@ -854,7 +881,9 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
if (plan.credential.link !== undefined) {
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
}
for (const path of plan.prune) {
const info = lstatIfPresent(path);
if (info?.isSymbolicLink()) {
@@ -907,8 +936,13 @@ export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
lines.push('merged settings:');
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
lines.push(`bundle: ${plan.bundle.display}`);
lines.push(`credential: ${plan.credential.target}`);
lines.push('symlinks:');
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
// Environment-shared harnesses have no credential symlink; the exported
// credential-directory variable below is what points them at the bundle.
if (plan.credential.link !== undefined) {
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
}
for (const install of plan.installs) {
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
}