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
@@ -1,5 +1,14 @@
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises'; import {
lstat,
mkdtemp,
readFile,
readdir,
readlink,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Command } from 'commander'; import { Command } from 'commander';
@@ -60,9 +69,10 @@ describe('mosaic fleet agent new', (): void => {
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const agent = join(dataHome, 'fleet', 'agents', 'mira'); const agent = join(dataHome, 'fleet', 'agents', 'mira');
// Claude reaches its bundle through CLAUDE_SECURESTORAGE_CONFIG_DIR at launch,
// so no credential link is planted in the seat home.
expect(await files(agent)).toEqual([ expect(await files(agent)).toEqual([
'.claude/.claude.json', '.claude/.claude.json',
'.claude/.credentials.json',
'.claude/.mosaic-managed-links.json', '.claude/.mosaic-managed-links.json',
'.claude/CLAUDE.md', '.claude/CLAUDE.md',
'SOUL.md', 'SOUL.md',
@@ -87,13 +97,30 @@ describe('mosaic fleet agent new', (): void => {
}, },
}, },
}); });
const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget);
expect( expect(
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')), JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
).toEqual({ ).toEqual({ links: {} });
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget }, });
});
it('plants a managed credential link for a harness that is not shared by environment', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
'pi-seat',
'--harness',
'pi',
]);
const agent = join(dataHome, 'fleet', 'agents', 'pi-seat');
const credentialTarget = join(dataHome, 'auth', 'pi', 'primary', 'auth.json');
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(credentialTarget);
expect(
JSON.parse(await readFile(join(agent, '.pi', '.mosaic-managed-links.json'), 'utf8')),
).toEqual({ links: { [join(agent, '.pi', 'auth.json')]: credentialTarget } });
}); });
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => { it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
@@ -210,10 +237,30 @@ describe('mosaic fleet agent new', (): void => {
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => { it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
const dataHome = await fleetDataHome(); const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'pi-seat', '--harness', 'pi'];
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json'); await program(dataHome).parseAsync(command);
const credential = join(dataHome, 'fleet', 'agents', 'pi-seat', '.pi', 'auth.json');
expect((await lstat(credential)).isSymbolicLink()).toBe(true); expect((await lstat(credential)).isSymbolicLink()).toBe(true);
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); await program(dataHome).parseAsync(command);
expect(process.exitCode).toBeUndefined(); expect(process.exitCode).toBeUndefined();
}); });
it('tolerates a credential link left by a scaffold that predates environment sharing', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
await program(dataHome).parseAsync(command);
const seatHome = join(dataHome, 'fleet', 'agents', 'mira', '.claude');
const credential = join(seatHome, '.credentials.json');
const target = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
await symlink(target, credential);
await writeFile(
join(seatHome, '.mosaic-managed-links.json'),
`${JSON.stringify({ links: { [credential]: target } }, null, 2)}\n`,
);
await program(dataHome).parseAsync(command);
expect(process.exitCode).toBeUndefined();
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
});
}); });
@@ -46,7 +46,7 @@ export function registerFleetAgentScaffoldCommand(
); );
if (!result.credentialTargetExists) { if (!result.credentialTargetExists) {
console.log( console.log(
`Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`, `Notice: auth bundle "${result.profile['bundle']}" is not enrolled yet, so no credential exists at ${result.credentialTarget}. The seat will refuse to launch until it does.`,
); );
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -36,25 +36,28 @@ function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claud
userHome: string; userHome: string;
agentDir: string; agentDir: string;
namedBundleDir: 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-')); const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
roots.push(root); roots.push(root);
const systemHome = join(root, 'system'); const systemHome = join(root, 'system');
const userHome = join(root, 'user'); const userHome = join(root, 'user');
const agentDir = join(userHome, 'fleet', 'agents', 'fred'); const agentDir = join(userHome, 'fleet', 'agents', 'fred');
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com'); const namedBundleDir = join(userHome, 'auth', harness, 'fred_example.com');
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true }); mkdirSync(join(systemHome, 'runtime', harness), { recursive: true });
mkdirSync(agentDir, { recursive: true }); mkdirSync(agentDir, { recursive: true });
mkdirSync(namedBundleDir, { 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(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( writeFileSync(
join(namedBundleDir, 'account.json'), join(namedBundleDir, 'account.json'),
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n', '{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
); );
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir'); symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
return { root, systemHome, userHome, agentDir, namedBundleDir }; return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
} }
describe('fleet launch profile schema 1', () => { describe('fleet launch profile schema 1', () => {
@@ -381,6 +384,33 @@ describe('A3 credential validation', () => {
).toThrowError(/first-auth.*refusing to delete or overwrite/i); ).toThrowError(/first-auth.*refusing to delete or overwrite/i);
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false); 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', () => { describe('managed plugin and skill links', () => {
@@ -513,12 +543,14 @@ describe('managed plugin and skill links', () => {
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n'); 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', () => { it('refuses an exact-target unrecorded credential symlink', () => {
const fx = fixture(); const fx = fixture({ schema: 1, harness: 'pi' });
const seatHome = join(fx.agentDir, '.claude'); const seatHome = join(fx.agentDir, '.pi');
const link = join(seatHome, '.credentials.json'); const link = join(seatHome, fx.credentialName);
mkdirSync(seatHome, { recursive: true }); mkdirSync(seatHome, { recursive: true });
symlinkSync(join(fx.namedBundleDir, '.credentials.json'), link, 'file'); symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
const plan = resolveFleetLaunchComposition('fred', { const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome, systemHome: fx.systemHome,
userHome: fx.userHome, userHome: fx.userHome,
@@ -527,7 +559,7 @@ describe('managed plugin and skill links', () => {
expect(() => applyFleetLaunchComposition(plan)).toThrowError( expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/, /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)( it.each(['plugins', 'skills'] as const)(
@@ -553,12 +585,12 @@ describe('managed plugin and skill links', () => {
); );
it('refuses an unrecorded mismatched credential symlink', () => { it('refuses an unrecorded mismatched credential symlink', () => {
const fx = fixture(); const fx = fixture({ schema: 1, harness: 'pi' });
const seatHome = join(fx.agentDir, '.claude'); const seatHome = join(fx.agentDir, '.pi');
const foreignCredential = join(fx.root, 'foreign-credential.json'); const foreignCredential = join(fx.root, 'foreign-credential.json');
mkdirSync(seatHome, { recursive: true }); mkdirSync(seatHome, { recursive: true });
writeFileSync(foreignCredential, '{}\n', { mode: 0o600 }); writeFileSync(foreignCredential, '{}\n', { mode: 0o600 });
symlinkSync(foreignCredential, join(seatHome, '.credentials.json'), 'file'); symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
const plan = resolveFleetLaunchComposition('fred', { const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome, systemHome: fx.systemHome,
userHome: fx.userHome, userHome: fx.userHome,
@@ -567,7 +599,7 @@ describe('managed plugin and skill links', () => {
expect(() => applyFleetLaunchComposition(plan)).toThrowError( expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/, /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', () => { 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'], ['--model', 'opus'],
{ {
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'), CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
CLAUDE_SECURESTORAGE_CONFIG_DIR: fx.namedBundleDir,
MOSAIC_AGENT_NAME: 'fred', MOSAIC_AGENT_NAME: 'fred',
SEAT_FLAG: 'yes', SEAT_FLAG: 'yes',
}, },
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome }, { agentDir: fx.agentDir, mosaicHome: fx.systemHome },
); );
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe( // The bundle is reached by environment, so nothing is planted at the seat path.
true, expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false);
);
}); });
it('sets a non-zero exit code and never invokes the launcher', () => { 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]) bundle: primary -> fred_example.com ([email protected])
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
symlinks: 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 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 skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
declared env: declared env:
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/user/auth/claude/fred_example.com
MOSAIC_AGENT_NAME=fred MOSAIC_AGENT_NAME=fred
SEAT_FLAG=yes SEAT_FLAG=yes
argv: ["claude","--model","opus"]" argv: ["claude","--model","opus"]"
@@ -763,6 +796,7 @@ describe('dry-run composition', () => {
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe( expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
readFileSync(plan.settings.output, 'utf8'), 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);
}); });
}); });
@@ -23,6 +23,10 @@ import {
type RuntimeName, type RuntimeName,
} from './launch.js'; } from './launch.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.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; export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [ 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 BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREDENTIAL_FILES: Record<RuntimeName, string> = { // Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart.
claude: '.credentials.json', const CREDENTIAL_FILES: Record<RuntimeName, string> = CREDENTIAL_FILE_NAMES;
pi: 'auth.json', const CREDENTIAL_DIR_ENV: Partial<Record<RuntimeName, string>> = CREDENTIAL_DIR_ENV_BY_HARNESS;
codex: 'auth.json',
opencode: 'auth.json',
};
export type FleetLaunchErrorCode = export type FleetLaunchErrorCode =
| 'SCHEMA_TOO_NEW' | 'SCHEMA_TOO_NEW'
@@ -125,8 +126,14 @@ export interface FleetLaunchComposition {
readonly display: string; readonly display: string;
}; };
readonly credential: { 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; readonly target: string;
/** Resolved bundle directory holding the credential file. */
readonly dir: string;
}; };
readonly managedLinks: ManagedLinkState; readonly managedLinks: ManagedLinkState;
readonly installs: readonly PlannedLink[]; 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.`, `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 resolvedName = basename(resolvedBundleDir);
const email = accountEmail(resolvedBundleDir); const email = accountEmail(resolvedBundleDir);
@@ -462,7 +473,11 @@ function resolveCredential(
...(email === undefined ? {} : { email }), ...(email === undefined ? {} : { email }),
display, display,
}, },
credential: { link: credentialLink, target: resolvedCredential }, credential: {
...(sharesByEnv ? {} : { link: credentialLink }),
target: resolvedCredential,
dir: resolvedBundleDir,
},
}; };
} }
@@ -754,9 +769,15 @@ export function resolveFleetLaunchComposition(
codex: 'CODEX_HOME', codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME', opencode: 'XDG_CONFIG_HOME',
}; };
const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness];
const env: Record<string, string> = { const env: Record<string, string> = {
...profile.env, ...profile.env,
[homeEnvName[profile.harness]]: seatHome, [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, MOSAIC_AGENT_NAME: name,
}; };
return { return {
@@ -839,7 +860,13 @@ function canonicalJson(value: unknown): unknown {
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void { export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
// All link-state checks must complete before the first filesystem mutation. // All link-state checks must complete before the first filesystem mutation.
// This makes a late foreign/retargeted link refusal leave the seat untouched. // 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) for (const path of plan.prune)
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
for (const install of plan.installs) { 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`; const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 }); writeFileSync(plan.settings.output, settings, { mode: 0o600 });
writeFileSync(plan.settings.snapshot, 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) { for (const path of plan.prune) {
const info = lstatIfPresent(path); const info = lstatIfPresent(path);
if (info?.isSymbolicLink()) { if (info?.isSymbolicLink()) {
@@ -907,8 +936,13 @@ export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
lines.push('merged settings:'); lines.push('merged settings:');
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2)); lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
lines.push(`bundle: ${plan.bundle.display}`); lines.push(`bundle: ${plan.bundle.display}`);
lines.push(`credential: ${plan.credential.target}`);
lines.push('symlinks:'); 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) { for (const install of plan.installs) {
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`); lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
} }
@@ -0,0 +1,44 @@
/**
* How each harness reaches the credential stored in its auth bundle.
*
* Scaffolding and launch both act on this, so it lives in one module: a seat whose
* scaffold planted a credential symlink that launch never maintains (or the reverse)
* fails in a way that only shows up at the first token refresh.
*/
/** Mirrors RuntimeName in commands/launch.ts; assignability is asserted there. */
export type CredentialHarness = 'claude' | 'codex' | 'opencode' | 'pi';
/** Credential file each harness reads, relative to its credential directory. */
export const CREDENTIAL_FILE_NAMES: Record<CredentialHarness, string> = {
claude: '.credentials.json',
pi: 'auth.json',
codex: 'auth.json',
opencode: 'auth.json',
};
/**
* Harnesses that can be pointed at a shared credential directory by environment,
* and the variable that does it.
*
* Claude Code saves credentials by writing a sibling temp file and rename()-ing it
* over the target. rename() replaces a symlink rather than following it, so a managed
* link at the seat's credential path is destroyed by the first token refresh and the
* seat silently forks its credentials. CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the
* credential directory independently of CLAUDE_CONFIG_DIR, which keeps both the temp
* file and the rename inside the bundle where they belong. Evidence:
* docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain).
*
* The value is always an absolute bundle path. Claude reads an empty value as
* ~/.claude — the operator's own account — so an empty value must never be exported.
*
* Harnesses absent from this map keep the managed-link mechanism.
*/
export const CREDENTIAL_DIR_ENV: Partial<Record<CredentialHarness, string>> = {
claude: 'CLAUDE_SECURESTORAGE_CONFIG_DIR',
};
/** True when the harness reaches its bundle by environment instead of a seat-local link. */
export function sharesCredentialDirByEnv(harness: CredentialHarness): boolean {
return CREDENTIAL_DIR_ENV[harness] !== undefined;
}
@@ -3,6 +3,8 @@ import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'n
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path'; import { isAbsolute, join, relative, resolve } from 'node:path';
import { CREDENTIAL_FILE_NAMES, sharesCredentialDirByEnv } from './credential-sharing.js';
export type FleetAgentHarness = 'claude' | 'pi'; export type FleetAgentHarness = 'claude' | 'pi';
export interface FleetAgentScaffoldOptions { export interface FleetAgentScaffoldOptions {
@@ -54,7 +56,7 @@ export async function scaffoldFleetAgent(
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic')); const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
const agentDir = join(dataHome, 'fleet', 'agents', name); const agentDir = join(dataHome, 'fleet', 'agents', name);
const homeName = harness === 'claude' ? '.claude' : '.pi'; const homeName = harness === 'claude' ? '.claude' : '.pi';
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; const credentialName = CREDENTIAL_FILE_NAMES[harness];
const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName); const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
const profile: Record<string, unknown> = { const profile: Record<string, unknown> = {
schema: 1, schema: 1,
@@ -65,6 +67,7 @@ export async function scaffoldFleetAgent(
env: { MOSAIC_AGENT_NAME: name }, env: { MOSAIC_AGENT_NAME: name },
}; };
const credentialLink = join(agentDir, homeName, credentialName); const credentialLink = join(agentDir, homeName, credentialName);
const sharesByEnv = sharesCredentialDirByEnv(harness);
const entries: [string, ExpectedFile][] = [ const entries: [string, ExpectedFile][] = [
['profile.json', { type: 'file', content: json(profile) }], ['profile.json', { type: 'file', content: json(profile) }],
['SOUL.md', { type: 'file', content: soul(name) }], ['SOUL.md', { type: 'file', content: soul(name) }],
@@ -73,10 +76,18 @@ export async function scaffoldFleetAgent(
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'), join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
{ type: 'file', content: identityBootstrap(name) }, { type: 'file', content: identityBootstrap(name) },
], ],
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }], ...(sharesByEnv
? []
: ([[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }]] as [
string,
ExpectedFile,
][])),
[ [
join(homeName, '.mosaic-managed-links.json'), join(homeName, '.mosaic-managed-links.json'),
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) }, {
type: 'file',
content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }),
},
], ],
]; ];
if (harness === 'claude') { if (harness === 'claude') {
@@ -87,7 +98,19 @@ export async function scaffoldFleetAgent(
} }
const files = new Map<string, ExpectedFile>(entries); const files = new Map<string, ExpectedFile>(entries);
const differences = await findDifferences(agentDir, files); // Seats scaffolded before the harness moved to an environment-shared credential
// directory still hold a credential symlink and name it in their manifest. The link
// is inert once the harness resolves its credential directory from the environment,
// so it is tolerated rather than reported as a foreign file or silently rewritten.
const legacyCredentialShape = sharesByEnv
? {
path: join(homeName, credentialName),
manifestPath: join(homeName, '.mosaic-managed-links.json'),
manifestContent: json({ links: { [credentialLink]: credentialTarget } }),
}
: undefined;
const differences = await findDifferences(agentDir, files, legacyCredentialShape);
if (differences.length > 0) { if (differences.length > 0) {
throw new FleetAgentScaffoldError( throw new FleetAgentScaffoldError(
'agent-exists-different', 'agent-exists-different',
@@ -123,9 +146,18 @@ type ExpectedFile =
| { readonly type: 'file'; readonly content: string } | { readonly type: 'file'; readonly content: string }
| { readonly type: 'symlink'; readonly target: string }; | { readonly type: 'symlink'; readonly target: string };
interface LegacyCredentialShape {
/** Seat-relative path of the now-unused credential symlink. */
readonly path: string;
readonly manifestPath: string;
/** Manifest content written when that link was still maintained. */
readonly manifestContent: string;
}
async function findDifferences( async function findDifferences(
agentDir: string, agentDir: string,
expected: ReadonlyMap<string, ExpectedFile>, expected: ReadonlyMap<string, ExpectedFile>,
legacy?: LegacyCredentialShape,
): Promise<string[]> { ): Promise<string[]> {
let root; let root;
try { try {
@@ -148,6 +180,7 @@ async function findDifferences(
]); ]);
const differences: string[] = []; const differences: string[] = [];
for (const path of [...paths].sort()) { for (const path of [...paths].sort()) {
if (legacy && path === legacy.path) continue;
const required = expected.get(path); const required = expected.get(path);
if (!required) { if (!required) {
differences.push(path); differences.push(path);
@@ -156,10 +189,16 @@ async function findDifferences(
try { try {
const info = await lstat(join(agentDir, path)); const info = await lstat(join(agentDir, path));
if (required.type === 'file') { if (required.type === 'file') {
const content = info.isFile() ? await readFile(join(agentDir, path), 'utf8') : undefined;
const acceptable =
legacy && path === legacy.manifestPath
? [required.content, legacy.manifestContent]
: [required.content];
if ( if (
!info.isFile() || !info.isFile() ||
info.isSymbolicLink() || info.isSymbolicLink() ||
(await readFile(join(agentDir, path), 'utf8')) !== required.content content === undefined ||
!acceptable.includes(content)
) { ) {
differences.push(path); differences.push(path);
} }