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:
@@ -36,25 +36,28 @@ function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claud
|
||||
userHome: string;
|
||||
agentDir: string;
|
||||
namedBundleDir: string;
|
||||
credentialName: string;
|
||||
} {
|
||||
const harness = String(profile.harness ?? 'claude');
|
||||
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
|
||||
roots.push(root);
|
||||
const systemHome = join(root, 'system');
|
||||
const userHome = join(root, 'user');
|
||||
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
||||
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
|
||||
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
|
||||
const namedBundleDir = join(userHome, 'auth', harness, 'fred_example.com');
|
||||
mkdirSync(join(systemHome, 'runtime', harness), { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(namedBundleDir, { recursive: true });
|
||||
writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), '{}\n');
|
||||
writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n');
|
||||
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
||||
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||
writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(namedBundleDir, 'account.json'),
|
||||
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
||||
);
|
||||
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
|
||||
return { root, systemHome, userHome, agentDir, namedBundleDir };
|
||||
symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
|
||||
return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
|
||||
}
|
||||
|
||||
describe('fleet launch profile schema 1', () => {
|
||||
@@ -381,6 +384,33 @@ describe('A3 credential validation', () => {
|
||||
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
|
||||
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
|
||||
it('points Claude at the resolved bundle directory and plans no credential link', () => {
|
||||
const fx = fixture();
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
|
||||
expect(plan.credential.link).toBeUndefined();
|
||||
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
||||
// An empty value resolves to ~/.claude, which is the operator's own account,
|
||||
// so the exported value must always be the absolute bundle path.
|
||||
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toBe(fx.namedBundleDir);
|
||||
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).not.toBe('');
|
||||
});
|
||||
|
||||
it('keeps the managed credential link for a harness with no credential-directory variable', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
|
||||
expect(plan.credential.link).toBe(join(fx.agentDir, '.pi', 'auth.json'));
|
||||
expect(plan.credential.target).toBe(join(fx.namedBundleDir, 'auth.json'));
|
||||
expect(Object.keys(plan.env)).not.toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed plugin and skill links', () => {
|
||||
@@ -513,12 +543,14 @@ describe('managed plugin and skill links', () => {
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n');
|
||||
});
|
||||
|
||||
// Credential links exist only for harnesses that are not pointed at their bundle
|
||||
// by environment, so the containment rules are exercised on one of those.
|
||||
it('refuses an exact-target unrecorded credential symlink', () => {
|
||||
const fx = fixture();
|
||||
const seatHome = join(fx.agentDir, '.claude');
|
||||
const link = join(seatHome, '.credentials.json');
|
||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
||||
const seatHome = join(fx.agentDir, '.pi');
|
||||
const link = join(seatHome, fx.credentialName);
|
||||
mkdirSync(seatHome, { recursive: true });
|
||||
symlinkSync(join(fx.namedBundleDir, '.credentials.json'), link, 'file');
|
||||
symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
@@ -527,7 +559,7 @@ describe('managed plugin and skill links', () => {
|
||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||
/unrecorded or retargeted symlink/,
|
||||
);
|
||||
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, '.credentials.json'));
|
||||
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName));
|
||||
});
|
||||
|
||||
it.each(['plugins', 'skills'] as const)(
|
||||
@@ -553,12 +585,12 @@ describe('managed plugin and skill links', () => {
|
||||
);
|
||||
|
||||
it('refuses an unrecorded mismatched credential symlink', () => {
|
||||
const fx = fixture();
|
||||
const seatHome = join(fx.agentDir, '.claude');
|
||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
||||
const seatHome = join(fx.agentDir, '.pi');
|
||||
const foreignCredential = join(fx.root, 'foreign-credential.json');
|
||||
mkdirSync(seatHome, { recursive: true });
|
||||
writeFileSync(foreignCredential, '{}\n', { mode: 0o600 });
|
||||
symlinkSync(foreignCredential, join(seatHome, '.credentials.json'), 'file');
|
||||
symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
@@ -567,7 +599,7 @@ describe('managed plugin and skill links', () => {
|
||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
||||
/unrecorded or retargeted symlink/,
|
||||
);
|
||||
expect(readFileSync(join(seatHome, '.credentials.json'), 'utf8')).toBe('{}\n');
|
||||
expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n');
|
||||
});
|
||||
|
||||
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
||||
@@ -654,14 +686,14 @@ describe('fleet launch command outcomes', () => {
|
||||
['--model', 'opus'],
|
||||
{
|
||||
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
|
||||
CLAUDE_SECURESTORAGE_CONFIG_DIR: fx.namedBundleDir,
|
||||
MOSAIC_AGENT_NAME: 'fred',
|
||||
SEAT_FLAG: 'yes',
|
||||
},
|
||||
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
||||
);
|
||||
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
|
||||
true,
|
||||
);
|
||||
// The bundle is reached by environment, so nothing is planted at the seat path.
|
||||
expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('sets a non-zero exit code and never invokes the launcher', () => {
|
||||
@@ -743,12 +775,13 @@ describe('dry-run composition', () => {
|
||||
}
|
||||
}
|
||||
bundle: primary -> fred_example.com ([email protected])
|
||||
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
||||
symlinks:
|
||||
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
||||
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
|
||||
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
|
||||
declared env:
|
||||
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
|
||||
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/user/auth/claude/fred_example.com
|
||||
MOSAIC_AGENT_NAME=fred
|
||||
SEAT_FLAG=yes
|
||||
argv: ["claude","--model","opus"]"
|
||||
@@ -763,6 +796,7 @@ describe('dry-run composition', () => {
|
||||
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
|
||||
readFileSync(plan.settings.output, 'utf8'),
|
||||
);
|
||||
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
|
||||
expect(plan.credential.link).toBeUndefined();
|
||||
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user