From 4522adaa5e5de2122c44e586f2e6467a0a06f849 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 13 Aug 2026 11:25:25 -0500 Subject: [PATCH] feat(fleet): scaffold user-owned agent homes --- REPORT-T3.md | 46 +++ .../fleet-agent-scaffold-command.spec.ts | 189 ++++++++++++ .../commands/fleet-agent-scaffold-command.ts | 56 ++++ packages/mosaic/src/commands/fleet.ts | 10 + .../mosaic/src/fleet/fleet-agent-scaffold.ts | 270 ++++++++++++++++++ 5 files changed, 571 insertions(+) create mode 100644 REPORT-T3.md create mode 100644 packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts create mode 100644 packages/mosaic/src/commands/fleet-agent-scaffold-command.ts create mode 100644 packages/mosaic/src/fleet/fleet-agent-scaffold.ts diff --git a/REPORT-T3.md b/REPORT-T3.md new file mode 100644 index 00000000..6fb2ddd1 --- /dev/null +++ b/REPORT-T3.md @@ -0,0 +1,46 @@ +# T3 report: `mosaic fleet agent new` + +## Changed + +- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`. + - Creates user-owned seats at `~/.mosaic/fleet/agents/` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`). + - Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`. + - Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`. + - Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed. + - Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface. + - Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths. +- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`. +- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison. + +## Reconciliation + +`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged. + +No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch ` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message. + +## Validation + +```text +$ pnpm install --frozen-lockfile +Done in 4.1s using pnpm v10.6.2 + +$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts +✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms +Test Files 1 passed (1) +Tests 13 passed (13) + +$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts +(exit 0) + +$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts +All matched files use Prettier code style! + +$ git diff --check +(exit 0) +``` + +`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`. + +## Skipped ambiguity + +The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner. diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts new file mode 100644 index 00000000..97da8aa3 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -0,0 +1,189 @@ +import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetAgentScaffoldCommand } from './fleet-agent-scaffold-command.js'; + +let root: string | undefined; + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function fleetDataHome(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-agent-new-')); + return join(root, '.mosaic'); +} + +function program(dataHome: string): Command { + const result = new Command(); + result.exitOverride(); + const fleet = result.command('fleet'); + registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome }); + return result; +} + +async function files(rootDir: string, prefix = ''): Promise { + const result: string[] = []; + for (const entry of await readdir(join(rootDir, prefix), { withFileTypes: true })) { + const path = join(prefix, entry.name); + if (entry.isDirectory()) result.push(...(await files(rootDir, path))); + else result.push(path); + } + return result.sort(); +} + +describe('mosaic fleet agent new', (): void => { + it('creates the exact authored user-data scaffold under a temp ~/.mosaic root', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + const agent = join(dataHome, 'fleet', 'agents', 'mira'); + + expect(await files(agent)).toEqual([ + '.claude/.claude.json', + '.claude/.credentials.json', + '.claude/CLAUDE.md', + 'SOUL.md', + 'overlay.json', + 'profile.json', + ]); + expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toEqual({ + schema: 1, + harness: 'claude', + bundle: 'primary', + overlay: 'overlay.json', + env: { MOSAIC_AGENT_NAME: 'mira' }, + }); + expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity'); + expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual( + `${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`, + ); + expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe( + join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'), + ); + }); + + it('creates a Pi home without Claude onboarding state', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + 'pi-seat', + '--harness', + 'pi', + ]); + expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([ + '.pi/AGENTS.md', + '.pi/auth.json', + 'SOUL.md', + 'overlay.json', + 'profile.json', + ]); + }); + + it('round-trips quotes, backticks, and shell-looking input literally', async (): Promise => { + const dataHome = await fleetDataHome(); + const name = 'seat"`$(literal)`'; + const bundle = 'bundle"`$(literal)`'; + const model = 'model"`$(literal)`'; + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + name, + '--harness', + 'pi', + '--bundle', + bundle, + '--model', + model, + ]); + const agent = join(dataHome, 'fleet', 'agents', name); + expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toMatchObject({ + harness: 'pi', + bundle, + model, + env: { MOSAIC_AGENT_NAME: name }, + }); + expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain(`You are ${name},`); + expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe( + join(dataHome, 'auth', 'pi', bundle, 'auth.json'), + ); + }); + + it.each(['', '../outside', '/absolute', 'a/b', 'a\\b'])( + 'rejects unsafe agent name %j with a non-zero outcome', + async (name: string): Promise => { + const dataHome = await fleetDataHome(); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', name]); + } catch { + // Commander rejects a missing positional before the action. That is also + // a non-zero CLI failure; all other unsafe names reach the scaffold. + process.exitCode = 1; + } + expect(process.exitCode).toBe(1); + if (name !== '') + expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request')); + }, + ); + + it.each([ + ['--harness', 'codex'], + ['--bundle', '../outside'], + ['--model', ''], + ])( + 'returns non-zero for invalid %s input', + async (option: string, value: string): Promise => { + const dataHome = await fleetDataHome(); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + 'mira', + option, + value, + ]); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request')); + }, + ); + + it('is idempotent for byte-identical content and refuses a changed user file', async (): Promise => { + const dataHome = await fleetDataHome(); + const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']; + await program(dataHome).parseAsync(command); + await program(dataHome).parseAsync(command); + expect(process.exitCode).toBeUndefined(); + + const soul = join(dataHome, 'fleet', 'agents', 'mira', 'SOUL.md'); + await writeFile(soul, '# user-owned change\n'); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await program(dataHome).parseAsync(command); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('SOUL.md')); + expect(await readFile(soul, 'utf8')).toBe('# user-owned change\n'); + }); + + 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'); + expect((await lstat(credential)).isSymbolicLink()).toBe(true); + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts new file mode 100644 index 00000000..64743237 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts @@ -0,0 +1,56 @@ +import type { Command } from 'commander'; +import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agent-scaffold.js'; + +export interface FleetAgentScaffoldCommandDeps { + /** Test seam for the user-owned ~/.mosaic root. */ + readonly fleetDataHome?: string; +} + +interface NewAgentOptions { + readonly harness?: string; + readonly bundle?: string; + readonly model?: string; +} + +/** Registers the user-data seat scaffolder, distinct from roster-v2 CRUD. */ +export function registerFleetAgentScaffoldCommand( + fleetCommand: Command, + deps: FleetAgentScaffoldCommandDeps = {}, +): void { + const agent = fleetCommand + .command('agent') + .description('Manage user-owned fleet agent harness homes'); + + agent + .command('new ') + .description('Create an additive-or-refuse fleet agent harness home') + .option('--harness ', 'Harness: claude or pi', 'claude') + .option('--bundle ', 'Auth bundle selector', 'primary') + .option('--model ', 'Optional harness-native model') + .action(async (name: string, options: NewAgentOptions): Promise => { + try { + const result = await scaffoldFleetAgent({ + name, + harness: options.harness, + bundle: options.bundle, + model: options.model, + ...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }), + }); + console.log( + result.idempotent + ? `Fleet agent "${name}" already matches the scaffold.` + : `Created fleet agent "${name}" at ${result.agentDir}.`, + ); + if (!result.credentialTargetExists) { + console.log( + `Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`, + ); + } + } catch (error: unknown) { + process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof FleetAgentScaffoldError ? error.code : 'scaffold-failed'; + process.stderr.write(`mosaic fleet agent new failed (${code}): ${message}\n`); + } + }); +} diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..36101c1e 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -38,6 +38,10 @@ import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, } from './fleet-agent-crud-command.js'; +import { + registerFleetAgentScaffoldCommand, + type FleetAgentScaffoldCommandDeps, +} from './fleet-agent-scaffold-command.js'; import { registerFleetMigrationCommand, type FleetMigrationCommandDeps, @@ -104,6 +108,8 @@ export interface FleetCommandDeps { */ isStdinTTY?: boolean; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; + /** Test-only user-data root for `fleet agent new` (production: ~/.mosaic). */ + fleetDataHome?: FleetAgentScaffoldCommandDeps['fleetDataHome']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; migrationDeps?: Omit; } @@ -2054,6 +2060,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // profile. DRY-RUN by default; --write persists under the same --mosaic-home. registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + // `fleet agent new` owns user-data harness homes under ~/.mosaic. The + // existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there + // is one `agent` namespace but deliberately separate state authorities. + registerFleetAgentScaffoldCommand(cmd, { fleetDataHome: deps.fleetDataHome }); // Roster-v2 desired-state mutations belong directly to the fleet control // plane; they do not share the root `mosaic agent` gateway-backed surface. registerFleetAgentCrudCommands(cmd, deps); diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts new file mode 100644 index 00000000..fbb4dbaa --- /dev/null +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -0,0 +1,270 @@ +import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +export type FleetAgentHarness = 'claude' | 'pi'; + +export interface FleetAgentScaffoldOptions { + readonly dataHome?: string; + readonly name: string; + readonly harness?: string; + readonly bundle?: string; + readonly model?: string; +} + +export interface FleetAgentScaffoldResult { + readonly agentDir: string; + readonly profile: Readonly>; + readonly idempotent: boolean; + readonly credentialTarget: string; + readonly credentialTargetExists: boolean; +} + +export class FleetAgentScaffoldError extends Error { + readonly code: 'invalid-request' | 'agent-exists-different'; + + constructor(code: FleetAgentScaffoldError['code'], message: string) { + super(message); + this.name = 'FleetAgentScaffoldError'; + this.code = code; + } +} + +/** User-owned data root, deliberately distinct from the update-owned mosaic home. */ +export function defaultFleetDataHome(): string { + return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic'); +} + +/** + * Materialize one fleet seat from authored, deterministic template content. + * Settings composition intentionally does not happen here: launch owns the + * three-layer settings merge and writes the generated settings.json then. + */ +export async function scaffoldFleetAgent( + options: FleetAgentScaffoldOptions, +): Promise { + const name = requireSafeName(options.name); + const harness = requireHarness(options.harness ?? 'claude'); + const bundle = requireBundle(options.bundle ?? 'primary'); + const model = optionalNonEmpty(options.model, '--model'); + const dataHome = resolve(options.dataHome ?? defaultFleetDataHome()); + const agentDir = join(dataHome, 'fleet', 'agents', name); + const homeName = harness === 'claude' ? '.claude' : '.pi'; + const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; + const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName); + const profile: Record = { + schema: 1, + harness, + bundle, + overlay: 'overlay.json', + ...(model === undefined ? {} : { model }), + env: { MOSAIC_AGENT_NAME: name }, + }; + const entries: [string, ExpectedFile][] = [ + ['profile.json', { type: 'file', content: json(profile) }], + ['SOUL.md', { type: 'file', content: soul(name) }], + ['overlay.json', { type: 'file', content: '{}\n' }], + [ + join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'), + { type: 'file', content: identityBootstrap(name) }, + ], + [join(homeName, credentialName), { type: 'symlink', target: credentialTarget }], + ]; + if (harness === 'claude') { + entries.push([ + join(homeName, '.claude.json'), + { type: 'file', content: json(onboardingState()) }, + ]); + } + const files = new Map(entries); + + const differences = await findDifferences(agentDir, files); + if (differences.length > 0) { + throw new FleetAgentScaffoldError( + 'agent-exists-different', + `Agent "${name}" already exists with different user-owned file(s): ${differences.join(', ')}. Refusing to overwrite.`, + ); + } + + const idempotent = await pathExists(agentDir); + if (!idempotent) { + for (const [file, expected] of files) { + const path = join(agentDir, file); + await mkdir(join(path, '..'), { recursive: true, mode: 0o700 }); + if (expected.type === 'file') { + await writeFile(path, expected.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + } else { + // A dangling link is intentional before enrollment. It makes absent auth + // visible at launch instead of silently selecting another account. + await symlink(expected.target, path); + } + } + } + + return { + agentDir, + profile, + idempotent, + credentialTarget, + credentialTargetExists: await pathExists(credentialTarget), + }; +} + +type ExpectedFile = + | { readonly type: 'file'; readonly content: string } + | { readonly type: 'symlink'; readonly target: string }; + +async function findDifferences( + agentDir: string, + expected: ReadonlyMap, +): Promise { + let root; + try { + root = await lstat(agentDir); + } catch (error: unknown) { + if (isMissing(error)) return []; + throw error; + } + if (!root.isDirectory() || root.isSymbolicLink()) return ['.']; + + const actual = await listRelativeEntries(agentDir); + const expectedDirs = new Set(); + for (const path of expected.keys()) { + const parent = relative('.', join(path, '..')); + if (parent !== '') expectedDirs.add(parent); + } + const paths = new Set([ + ...expected.keys(), + ...actual.filter((path: string): boolean => !expectedDirs.has(path)), + ]); + const differences: string[] = []; + for (const path of [...paths].sort()) { + const required = expected.get(path); + if (!required) { + differences.push(path); + continue; + } + try { + const info = await lstat(join(agentDir, path)); + if (required.type === 'file') { + if ( + !info.isFile() || + info.isSymbolicLink() || + (await readFile(join(agentDir, path), 'utf8')) !== required.content + ) { + differences.push(path); + } + } else if ( + !info.isSymbolicLink() || + (await readlink(join(agentDir, path))) !== required.target + ) { + differences.push(path); + } + } catch (error: unknown) { + if (isMissing(error)) differences.push(path); + else throw error; + } + } + return differences; +} + +async function listRelativeEntries(root: string, prefix = ''): Promise { + const result: string[] = []; + for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { + const path = join(prefix, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + result.push(path, ...(await listRelativeEntries(root, path))); + } else { + result.push(path); + } + } + return result; +} + +function requireSafeName(value: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value === '.' || + value === '..' || + value.includes('/') || + value.includes('\\') || + value.includes('\0') || + isAbsolute(value) + ) { + throw new FleetAgentScaffoldError( + 'invalid-request', + 'Agent name must be one non-empty path component (not absolute or traversal).', + ); + } + return value; +} + +function requireHarness(value: string): FleetAgentHarness { + if (value === 'claude' || value === 'pi') return value; + throw new FleetAgentScaffoldError('invalid-request', '--harness must be claude or pi.'); +} + +function requireBundle(value: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value === '.' || + value === '..' || + value.includes('/') || + value.includes('\\') || + value.includes('\0') || + isAbsolute(value) + ) { + throw new FleetAgentScaffoldError( + 'invalid-request', + '--bundle must be one non-empty auth-bundle path component.', + ); + } + return value; +} + +function optionalNonEmpty(value: string | undefined, option: string): string | undefined { + if (value === undefined) return undefined; + if (value.length === 0 || value.includes('\0')) { + throw new FleetAgentScaffoldError('invalid-request', `${option} must be a non-empty string.`); + } + return value; +} + +function onboardingState(): Record { + return { hasCompletedOnboarding: true, theme: 'dark' }; +} + +function soul(name: string): string { + return `# SOUL\n\n## Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nRole: _Describe this seat's role._\n`; +} + +/** Identity is materialized by value so restricted harness modes never need to read SOUL.md. */ +function identityBootstrap(name: string): string { + return `# Mosaic Fleet Agent Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nYour mechanical identity is ${name} (MOSAIC_AGENT_NAME). Keep this identity when working in repositories with other personas.\n`; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error: unknown) { + if (isMissing(error)) return false; + throw error; + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** Guardrail kept explicit for callers that construct paths from untrusted text. */ +export function isContainedInFleetDataHome(dataHome: string, path: string): boolean { + const rel = relative(resolve(dataHome), resolve(path)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +}