diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts index 7e41c926..169a7d36 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -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 => { + 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 => { @@ -210,10 +237,30 @@ describe('mosaic fleet agent new', (): void => { it('does not follow a managed credential link while comparing existing content', async (): Promise => { 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 => { + 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); + }); }); diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts index 4b36abf3..a0ce55f2 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts @@ -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) { diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 0e9948f8..aff36176 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -36,25 +36,28 @@ function fixture(profile: Record = { 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":"fred@example.com"}}\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 (fred@example.com) + credential: /user/auth/claude/fred_example.com/.credentials.json symlinks: - credentials: /user/fleet/agents/fred/.claude/.credentials.json -> /user/auth/claude/fred_example.com/.credentials.json plugin code-review: /user/fleet/agents/fred/.claude/plugins/code-review -> /user/plugins/code-review skill mosaic-tools: /user/fleet/agents/fred/.claude/skills/mosaic-tools -> /user/skills/mosaic-tools declared env: CLAUDE_CONFIG_DIR=/user/fleet/agents/fred/.claude + CLAUDE_SECURESTORAGE_CONFIG_DIR=/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); }); }); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index a012ecaf..7b308a9a 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -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 = { - 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 = CREDENTIAL_FILE_NAMES; +const CREDENTIAL_DIR_ENV: Partial> = 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 = { ...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}`); } diff --git a/packages/mosaic/src/fleet/credential-sharing.ts b/packages/mosaic/src/fleet/credential-sharing.ts new file mode 100644 index 00000000..eab88cf4 --- /dev/null +++ b/packages/mosaic/src/fleet/credential-sharing.ts @@ -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 = { + 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> = { + 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; +} diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts index ea1f1d78..215cca95 100644 --- a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -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 = { 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(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, + legacy?: LegacyCredentialShape, ): Promise { 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); }