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:
@@ -1,5 +1,14 @@
|
||||
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 { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
@@ -60,9 +69,10 @@ describe('mosaic fleet agent new', (): void => {
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', '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([
|
||||
'.claude/.claude.json',
|
||||
'.claude/.credentials.json',
|
||||
'.claude/.mosaic-managed-links.json',
|
||||
'.claude/CLAUDE.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(
|
||||
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
|
||||
).toEqual({
|
||||
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget },
|
||||
});
|
||||
).toEqual({ links: {} });
|
||||
});
|
||||
|
||||
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> => {
|
||||
@@ -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> => {
|
||||
const dataHome = await fleetDataHome();
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
|
||||
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'pi-seat', '--harness', 'pi'];
|
||||
await program(dataHome).parseAsync(command);
|
||||
const credential = join(dataHome, 'fleet', 'agents', 'pi-seat', '.pi', 'auth.json');
|
||||
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();
|
||||
});
|
||||
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
import { CREDENTIAL_FILE_NAMES, sharesCredentialDirByEnv } from './credential-sharing.js';
|
||||
|
||||
export type FleetAgentHarness = 'claude' | 'pi';
|
||||
|
||||
export interface FleetAgentScaffoldOptions {
|
||||
@@ -54,7 +56,7 @@ export async function scaffoldFleetAgent(
|
||||
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
|
||||
const agentDir = join(dataHome, 'fleet', 'agents', name);
|
||||
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 profile: Record<string, unknown> = {
|
||||
schema: 1,
|
||||
@@ -65,6 +67,7 @@ export async function scaffoldFleetAgent(
|
||||
env: { MOSAIC_AGENT_NAME: name },
|
||||
};
|
||||
const credentialLink = join(agentDir, homeName, credentialName);
|
||||
const sharesByEnv = sharesCredentialDirByEnv(harness);
|
||||
const entries: [string, ExpectedFile][] = [
|
||||
['profile.json', { type: 'file', content: json(profile) }],
|
||||
['SOUL.md', { type: 'file', content: soul(name) }],
|
||||
@@ -73,10 +76,18 @@ export async function scaffoldFleetAgent(
|
||||
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
|
||||
{ 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'),
|
||||
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
|
||||
{
|
||||
type: 'file',
|
||||
content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }),
|
||||
},
|
||||
],
|
||||
];
|
||||
if (harness === 'claude') {
|
||||
@@ -87,7 +98,19 @@ export async function scaffoldFleetAgent(
|
||||
}
|
||||
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) {
|
||||
throw new FleetAgentScaffoldError(
|
||||
'agent-exists-different',
|
||||
@@ -123,9 +146,18 @@ type ExpectedFile =
|
||||
| { readonly type: 'file'; readonly content: 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(
|
||||
agentDir: string,
|
||||
expected: ReadonlyMap<string, ExpectedFile>,
|
||||
legacy?: LegacyCredentialShape,
|
||||
): Promise<string[]> {
|
||||
let root;
|
||||
try {
|
||||
@@ -148,6 +180,7 @@ async function findDifferences(
|
||||
]);
|
||||
const differences: string[] = [];
|
||||
for (const path of [...paths].sort()) {
|
||||
if (legacy && path === legacy.path) continue;
|
||||
const required = expected.get(path);
|
||||
if (!required) {
|
||||
differences.push(path);
|
||||
@@ -156,10 +189,16 @@ async function findDifferences(
|
||||
try {
|
||||
const info = await lstat(join(agentDir, path));
|
||||
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 (
|
||||
!info.isFile() ||
|
||||
info.isSymbolicLink() ||
|
||||
(await readFile(join(agentDir, path), 'utf8')) !== required.content
|
||||
content === undefined ||
|
||||
!acceptable.includes(content)
|
||||
) {
|
||||
differences.push(path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user