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