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:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user