Merge branch 'feat/wf-fleet-t3-scaffold' into feat/wf-fleet-mvp
This commit is contained in:
@@ -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/<name>` (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 <name> [--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 <name>` 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.
|
||||
@@ -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<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
async function fleetDataHome(): Promise<string> {
|
||||
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<string[]> {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<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');
|
||||
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
||||
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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 <name>')
|
||||
.description('Create an additive-or-refuse fleet agent harness home')
|
||||
.option('--harness <harness>', 'Harness: claude or pi', 'claude')
|
||||
.option('--bundle <bundle>', 'Auth bundle selector', 'primary')
|
||||
.option('--model <model>', 'Optional harness-native model')
|
||||
.action(async (name: string, options: NewAgentOptions): Promise<void> => {
|
||||
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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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<FleetAgentScaffoldResult> {
|
||||
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<string, unknown> = {
|
||||
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<string, ExpectedFile>(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<string, ExpectedFile>,
|
||||
): Promise<string[]> {
|
||||
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<string>();
|
||||
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<string[]> {
|
||||
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<string, unknown> {
|
||||
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<boolean> {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user