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 { 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);
}