Compare commits

...
Author SHA1 Message Date
terra bf6b245f3c fleet: move directories off managed paths instead of refusing forever
Launch will not delete a real directory sitting where it expects a managed
link -- an auth/<harness>/primary that someone logged into by hand, or a
plugin directory a seat acquired before the central store existed. That
refusal is right and it is also a dead end: the operator gets a composition
error and no way forward.

`mosaic fleet adopt` is the way forward. Bare, it lists every such directory
and the command that resolves it. With a verb, it moves one where it belongs.

Nothing here deletes. A promotion is a rename; an occupied destination is a
refusal, not a merge; a cross-device rename is reported rather than retried as
copy-then-delete, because a copy-then-delete is a delete.

Store adoption stops at the move and does not install the link. The seat's
.mosaic-managed-links.json belongs to launch, and a link written behind it
fails the next composition as an unrecorded symlink -- one refusal traded for
another. The next launch installs and records it when the profile lists the
entry; whether a seat gets a plugin stays `mosaic fleet plugin`'s decision.

W-F3 of docs/plans/2026-08-14_fleet-seats-on-web1.md.
2026-08-14 19:38:41 -05:00
terraandClaude Opus 5 478e925041 fleet: give one host several accounts per harness, and peg each seat to one
`mosaic auth enroll | assign | list | default` (W-F5). Until now a host had one
account per harness, so an author seat and a reviewer seat were the same
principal wearing two names, and a review carried out under that arrangement is
self-review. Bundles under ~/.mosaic/auth/<harness>/<bundle>/ are what a seat's
profile.json points at, so two seats on one host can hold genuinely different
accounts.

Enroll does not reimplement any harness's login. It creates the bundle
directory owner-only, points the harness's own home at it by environment, runs
the harness, and then checks what landed: credential present, permissions
tightened, and the account recorded. Claude is reached through
CLAUDE_SECURESTORAGE_CONFIG_DIR rather than a symlink because it writes by
rename(2), which replaces a symlink instead of following it. --no-login prints
the environment for an operator who would rather run the login themselves.

The check worth naming is identity: enroll reads the account back out of what
the harness wrote and refuses quietly to accept a bundle named for one account
that holds another. That mistake is otherwise silent -- an operator enrolling
the reviewer bundle logs in out of habit as the author, both seats collapse to
one principal, and nothing else in the system notices.

Assign re-parses a seat's profile before rewriting its bundle, so an already
broken profile is reported here rather than re-serialized into something that
looks repaired and still fails at launch. An unenrolled bundle is assigned but
said out loud, because the seat will refuse to launch until the account exists.

registerAuthCommand now returns its Command so these local verbs can hang off
it. They never talk to the gateway and work on a host where it is down.

41 tests. Each of the load-bearing checks was mutation-tested: nine mutations,
each killing exactly the one test that covers it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-14 19:23:19 -05:00
terraandClaude Opus 5 309a99a600 fleet: fix four defects that made no seat launchable on a clean install
Found by rehearsing the full install on a greenfield Debian 13 VM
(mosaic-sbx-dev) rather than on a host that already had a working Mosaic
tree. Each one is invisible on a developer machine and fatal on a new host.

1. Required system settings layer. The framework ships runtime/<harness>/
   for claude, codex, opencode and pi but a settings.json only for claude,
   so requiring the file made every pi, codex and opencode seat refuse to
   compose. The system layer is now optional; what must exist is the
   harness runtime directory, which is the thing that actually proves the
   framework is installed and carries that harness.

2. Required mcpServers in canonical Claude settings. The shipped
   settings.json has no such key, so `fleet agent new` refused to scaffold
   any Claude seat. Absent now means the same as empty. A present but
   wrong-typed value is still an error.

3. Never-enrolled hosts were told their auth directory "must be a real,
   non-symlink directory", which reads as a tampering report when the real
   situation is that nobody has logged in yet. Absent and wrong-shaped are
   now separate messages, and the absent one names `mosaic auth enroll`.

4. A fleet seat whose host had no system SOUL.md reached checkSoul(),
   which spawns the interactive `mosaic wizard` with inherited stdio. On a
   detached tmux seat that parks the pane on a menu with nobody at it: the
   session is live, the systemd unit reports fine, and no agent ever
   starts. A seat's identity is its own SOUL.md, written by `fleet agent
   new`, so the fleet path checks that and fails loudly instead.

Each fix has a regression test verified red against the unfixed source.
The launch.spec.ts seat fixtures gained a SOUL.md they always should have
had -- without it those tests were satisfied by whatever SOUL.md the
developer's real ~/.config/mosaic happened to contain.

Full suite before and after: the same 5 pre-existing failures in
mutator-gate.acceptance.spec.ts and install-ordering-guard.spec.ts,
1585 -> 1591 passing. typecheck and eslint clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-14 19:03:30 -05:00
terra c1a42cdb81 fleet: start a roster pane through its seat when one is scaffolded
The roster lane and the harness-homes lane did not touch. start-agent-session.sh
ran `mosaic yolo "$RUNTIME"` with HOME set to the operator's home, so every fleet
seat on a host shared the operator's harness home and, for Claude, the operator's
own ~/.claude credentials. Nothing in framework/ called `mosaic fleet launch` at
all, which meant ~/.mosaic was a directory nothing read.

The pane now runs `mosaic fleet launch "$AGENT_NAME"` when a scaffolded seat
exists at $PANE_HOME/.mosaic/fleet/agents/<name>/profile.json, and the historical
command otherwise. Detection uses $PANE_HOME/.mosaic rather than MOSAIC_DATA_HOME
because the pane environment is cleared with env -i; the composition resolves the
same root from HOME, so the two cannot disagree.

Additive by construction: a host with no scaffolded seats launches exactly as
before, so this can land ahead of any seat being enrolled.

- fleet launch gains --dangerous, threaded to launchFleetRuntime. Without it a
  seat launched from the roster would drop the permissions footing `mosaic yolo`
  gave it and prompt at a pane with nobody at it. The roster launcher asks for it
  explicitly so it stays visible in the process table instead of becoming a
  profile default.
- A caller's --model replaces the profile's instead of being appended after it.
  The roster carries a model per seat and is the surface operators edit; emitting
  both flags would leave the choice to each harness's argument parser.
- Claude workdir trust is written into the seat's .claude.json when the pane will
  run in a seat home. It previously always went to the operator's ~/.claude.json,
  which would leave the seat prompting on its first turn.

Covers Jason's scope amendment for web1: without this seam, "multiple
authentication accounts and agent pegging to auth" cannot be demonstrated on a
roster-managed seat.
2026-08-14 18:35:11 -05:00
terra a12eeb4786 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.
2026-08-14 18:20:54 -05:00
22 changed files with 3029 additions and 99 deletions
@@ -286,12 +286,24 @@ _build_runtime_bin_prefix() {
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings
# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes
# ~/.mosaic real for a roster-started pane instead of a directory nothing reads.
# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below,
# so `mosaic fleet launch` resolves the same root from HOME and the two agree.
FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME"
FLEET_SEAT=0
[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1
_ensure_claude_workdir_trusted() {
local workdir="$1"
local claude_json="$2"
local resolved
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
claude_json="${claude_json:-$HOME/.claude.json}"
if [ -z "$claude_json" ]; then
claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
claude_json="${claude_json:-$HOME/.claude.json}"
fi
command -v python3 >/dev/null 2>&1 || return 1
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
import json, os, sys, tempfile
@@ -325,11 +337,23 @@ PY
}
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
# Trust belongs to the home the seat will actually run in. Writing it to the
# operator's ~/.claude.json would leave the seat prompting on its first turn.
SEAT_CLAUDE_JSON=""
if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then
SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json"
fi
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
fi
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
if [ "$FLEET_SEAT" = 1 ]; then
# --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it;
# the composition, not the roster, decides harness home, bundle and settings.
LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous)
else
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
fi
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
@@ -409,4 +409,23 @@ if echo "$stop_args" | grep -qF 'ambient-socket'; then
fail "exact stop trusted an ambient socket"
fi
# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches
# through the composition instead of the operator's own home. --dangerous keeps the
# seat on the permissions footing `mosaic yolo` gave it.
: > "$TMUX_CALLS"
HOME_SEAT="$ROOT/seat"
write_generated "$HOME_SEAT" "coder-seat"
mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat"
printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \
> "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json"
run_start "$HOME_SEAT" "coder-seat"
seat_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch"
echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch"
echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat"
echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions"
if echo "$seat_args" | grep -qxF 'yolo'; then
fail "scaffolded seat still launched through mosaic yolo"
fi
echo 'ok - start-agent-session generated environment boundary'
+2 -1
View File
@@ -25,6 +25,7 @@ import { registerLaunchCommands } from './commands/launch.js';
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
import { registerAuthCommand } from './commands/auth.js';
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
import { registerFederationCommand } from './commands/federation.js';
import { registerGatewayCommand } from './commands/gateway.js';
import {
@@ -350,7 +351,7 @@ sessionsCmd
// ─── auth ────────────────────────────────────────────────────────────────
registerAuthCommand(program);
registerFleetAuthCommands(registerAuthCommand(program));
// ─── gateway ──────────────────────────────────────────────────────────
+5 -2
View File
@@ -139,10 +139,11 @@ function printUser(u: UserDto): void {
* Keeping packages/auth as a pure server-side library avoids adding commander
* and CLI tooling as dependencies there.
*/
export function registerAuthCommand(parent: Command): void {
/** Returns the `auth` command so local (non-gateway) verbs can be attached to it. */
export function registerAuthCommand(parent: Command): Command {
const auth = parent
.command('auth')
.description('Manage gateway authentication, users, SSO providers, and sessions')
.description('Manage authentication: local credential bundles, and gateway users and sessions')
.configureHelp({ sortSubcommands: true })
.action(() => {
auth.outputHelp();
@@ -328,4 +329,6 @@ export function registerAuthCommand(parent: Command): void {
);
void opts;
});
return auth;
}
@@ -0,0 +1,196 @@
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
let root: string | undefined;
interface Harness {
readonly home: string;
readonly out: string[];
readonly err: string[];
readonly run: (argv: string[]) => Promise<void>;
}
beforeEach((): void => {
process.exitCode = undefined;
});
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function harness(): Promise<Harness> {
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-cmd-'));
const home = join(root, '.mosaic');
const out: string[] = [];
const err: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
err.push(String(chunk));
return true;
});
const program = new Command();
program.exitOverride();
const fleet = program.command('fleet');
registerFleetAdoptCommand(fleet, { fleetDataHome: home });
return {
home,
out,
err,
run: async (argv: string[]): Promise<void> => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'adopt', ...argv]);
},
};
}
function realAliasDirectory(home: string, harnessName: string): string {
const path = join(home, 'auth', harnessName, 'primary');
mkdirSync(path, { recursive: true });
writeFileSync(join(path, '.credentials.json'), '{"token":"kept"}');
return path;
}
function seat(home: string, name: string, profile: Record<string, unknown>): void {
const dir = join(home, 'fleet', 'agents', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
}
function seatDirectory(home: string, agent: string, plural: string, name: string): string {
const path = join(home, 'fleet', 'agents', agent, '.claude', plural, name);
mkdirSync(path, { recursive: true });
writeFileSync(join(path, 'marker.txt'), 'kept');
return path;
}
describe('mosaic fleet adopt', () => {
it('says there is nothing to adopt on a clean host', async () => {
const h = await harness();
await h.run([]);
expect(h.out.join('\n')).toContain('Nothing to adopt');
expect(process.exitCode).toBeUndefined();
});
// A read-only listing that exits non-zero is one people stop running, so the scan reports
// and stays out of the way.
it('lists each finding with the command that resolves it, and exits zero', async () => {
const h = await harness();
const path = realAliasDirectory(h.home, 'claude');
await h.run([]);
const printed = h.out.join('\n');
expect(printed).toContain(path);
expect(printed).toContain('resolve: mosaic fleet adopt bundle --harness claude --as <account>');
expect(printed).toContain('1 found, 0 needing a decision before adoption. Nothing was moved.');
expect(process.exitCode).toBeUndefined();
});
it('separates findings it can resolve from findings that need a decision first', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
await h.run([]);
const printed = h.out.join('\n');
expect(printed).toContain('blocked (destination occupied)');
expect(printed).toContain('1 found, 1 needing a decision before adoption.');
});
it('adopts a bundle and reports where the credentials went and what the alias points at', async () => {
const h = await harness();
realAliasDirectory(h.home, 'claude');
await h.run(['bundle', '--harness', 'claude', '--as', 'jason_woltje.com']);
const target = join(h.home, 'auth', 'claude', 'jason_woltje.com');
expect(readFileSync(join(target, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
const printed = h.out.join('\n');
expect(printed).toContain(`bundle: ${target}`);
expect(printed).toContain('-> jason_woltje.com');
// The name is the operator's claim about the account; only a listing shows what is in it.
expect(printed).toContain('mosaic auth list --harness claude');
expect(process.exitCode).toBeUndefined();
});
it('rejects an unknown harness instead of building a path out of it', async () => {
const h = await harness();
await h.run(['bundle', '--harness', 'nonsense', '--as', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('--harness must be one of: claude, codex, opencode, pi');
});
it('exits non-zero and names the failure when there is nothing to adopt', async () => {
const h = await harness();
await h.run(['bundle', '--harness', 'pi', '--as', 'jason_woltje.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('mosaic fleet adopt bundle failed (nothing-to-adopt)');
});
it('adopts a plugin into the store and says the next launch links it back', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(readFileSync(join(h.home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe('kept');
expect(h.out.join('\n')).toContain('next launch links it back from the store');
});
it('says plainly when no seat uses the adopted entry yet', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(h.out.join('\n')).toContain("is not listed in uc-e6-coder's profile");
});
it('adopts a skill into the skill store, not the plugin store', async () => {
const h = await harness();
seat(h.home, 'uc-e6-rev', { schema: 1, harness: 'claude', bundle: 'primary' });
seatDirectory(h.home, 'uc-e6-rev', 'skills', 'spec-audit');
await h.run(['skill', 'spec-audit', '--seat', 'uc-e6-rev']);
expect(readFileSync(join(h.home, 'skills', 'spec-audit', 'marker.txt'), 'utf8')).toBe('kept');
});
it('leaves an already-linked entry alone and exits non-zero', async () => {
const h = await harness();
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(h.home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(h.home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('already a link into the store');
});
});
@@ -0,0 +1,137 @@
/**
* `mosaic fleet adopt` -- resolve the real directories that sit where a managed link belongs.
*
* Launch refuses to delete anything an operator put on a managed path, which is right, but on
* its own it leaves the operator holding a composition error and no way forward. This command
* is the way forward: bare, it lists every such directory and the command that resolves it;
* with a verb, it moves one of them where it belongs.
*
* The bare scan reads only, and exits zero whatever it finds. It is meant to be safe to run
* out of curiosity, and a non-zero exit from a read-only listing would make it something
* people avoid running.
*/
import type { Command } from 'commander';
import {
AdoptionError,
type StoreKind,
promoteBundleAlias,
promoteStoreEntry,
scanAdoptions,
} from '../fleet/adoption.js';
import type { CredentialHarness } from '../fleet/credential-sharing.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
export interface FleetAdoptCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
}
function requireHarness(value: string | undefined): CredentialHarness {
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
throw new AdoptionError('invalid-request', `--harness must be one of: ${HARNESSES.join(', ')}`);
}
return value as CredentialHarness;
}
function requireSeat(value: string | undefined): string {
if (value === undefined || value.trim() === '') {
throw new AdoptionError(
'invalid-request',
'give the seat this directory belongs to: --seat <agent>',
);
}
return value;
}
function fail(error: unknown, verb: string): void {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof AdoptionError ? error.code : 'failed';
process.stderr.write(
`mosaic fleet adopt${verb === '' ? '' : ` ${verb}`} failed (${code}): ${message}\n`,
);
}
/** Registers the adoption scan and its three promotion verbs. */
export function registerFleetAdoptCommand(
fleetCommand: Command,
deps: FleetAdoptCommandDeps = {},
): void {
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
const adopt = fleetCommand
.command('adopt')
.description('Find and resolve real directories occupying paths the fleet manages with links')
.action((): void => {
try {
const findings = scanAdoptions(dataHome());
if (findings.length === 0) {
console.log('Nothing to adopt: no real directory occupies a managed path.');
return;
}
for (const finding of findings) {
console.log(finding.path);
console.log(` ${finding.reason}`);
console.log(
finding.blocked === undefined
? ` resolve: ${finding.remedy}`
: ` blocked (${finding.blocked}): ${finding.remedy}`,
);
}
const blocked = findings.filter((finding) => finding.blocked !== undefined).length;
console.log(
`\n${String(findings.length)} found, ${String(blocked)} needing a decision before adoption. Nothing was moved.`,
);
} catch (error: unknown) {
fail(error, '');
}
});
adopt
.command('bundle')
.description(`Adopt a real directory on the "primary" alias path as a named bundle`)
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
.requiredOption('--as <bundle>', 'Account this directory holds, e.g. jason_woltje.com')
.action((options: { harness?: string; as: string }): void => {
try {
const result = promoteBundleAlias(dataHome(), requireHarness(options.harness), options.as);
console.log(`Adopted ${result.from}`);
console.log(` bundle: ${result.to}`);
console.log(` alias: ${result.alias} -> ${result.bundle}`);
console.log(
`\nCheck the account it actually holds before trusting the name:\n mosaic auth list --harness ${result.harness}`,
);
} catch (error: unknown) {
fail(error, 'bundle');
}
});
for (const store of ['plugin', 'skill'] as const) {
adopt
.command(`${store} <name>`)
.description(`Move a real ${store} directory out of a seat and into the central store`)
.requiredOption('--seat <agent>', 'Seat the directory currently sits in')
.action((name: string, options: { seat?: string }): void => {
try {
const result = promoteStoreEntry(
dataHome(),
requireSeat(options.seat),
store as StoreKind,
name,
);
console.log(`Adopted ${result.from}`);
console.log(` store: ${result.to}`);
console.log(
result.listedInProfile
? `\n"${result.name}" is listed in ${result.agent}'s profile, so its next launch links it back from the store.`
: `\n"${result.name}" is not listed in ${result.agent}'s profile, so no seat uses it yet. It is now vetted store content any seat can be given.`,
);
} catch (error: unknown) {
fail(error, store);
}
});
}
}
@@ -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,61 @@ 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);
});
it('scaffolds against canonical settings that declare no mcpServers', async (): Promise<void> => {
// The framework's shipped runtime/claude/settings.json has no mcpServers key, so
// requiring one refused to scaffold any Claude seat on a clean install. Measured on a
// greenfield Debian 13 VM against framework main.
const dataHome = await fleetDataHome();
const command = program(dataHome);
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
await writeFile(settings, JSON.stringify({ model: 'opus', hooks: {} }));
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
expect(process.exitCode).toBeUndefined();
const claudeJson = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.claude.json');
expect(JSON.parse(await readFile(claudeJson, 'utf8'))).toEqual({
hasCompletedOnboarding: true,
theme: 'dark',
mcpServers: {},
});
});
it('still refuses canonical settings whose mcpServers is the wrong shape', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const command = program(dataHome);
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
await writeFile(settings, JSON.stringify({ mcpServers: ['sequential-thinking'] }));
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
expect(process.exitCode).toBe(1);
});
});
@@ -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) {
@@ -0,0 +1,365 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdtemp, readFile, rm } 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 { registerFleetAuthCommands, type FleetAuthCommandDeps } from './fleet-auth-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;
});
interface Harness {
readonly home: string;
readonly out: string[];
readonly err: string[];
readonly logins: Array<{ command: string; args: readonly string[]; env: Record<string, string> }>;
run: (argv: string[]) => Promise<void>;
}
async function harness(
overrides: Omit<FleetAuthCommandDeps, 'fleetDataHome'> = {},
): Promise<Harness> {
root = await mkdtemp(join(tmpdir(), 'mosaic-auth-cmd-'));
const home = join(root, '.mosaic');
const out: string[] = [];
const err: string[] = [];
const logins: Harness['logins'] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
err.push(String(chunk));
return true;
});
// Every login is recorded regardless of which behaviour the test supplied, so a test can
// assert on what the harness was actually handed as well as on what it wrote.
const inner = overrides.runLogin ?? ((): number => 0);
const program = new Command();
program.exitOverride();
const auth = program.command('auth');
registerFleetAuthCommands(auth, {
...overrides,
fleetDataHome: home,
runLogin: (command, args, env): number | null => {
logins.push({ command, args, env: { ...env } });
return inner(command, args, env);
},
});
return {
home,
out,
err,
logins,
run: async (argv: string[]): Promise<void> => {
await program.parseAsync(['node', 'mosaic', 'auth', ...argv]);
},
};
}
/**
* A login that behaves: writes the credential where the harness would write it, using only the
* environment it was handed — the same way a real harness finds its home.
*/
function goodLogin(email?: string, status = 0): NonNullable<FleetAuthCommandDeps['runLogin']> {
return (command, _args, env): number => {
const dir =
command === 'claude'
? (env['CLAUDE_SECURESTORAGE_CONFIG_DIR'] ?? '')
: (env['PI_CODING_AGENT_DIR'] ?? env['CODEX_HOME'] ?? env['XDG_CONFIG_HOME'] ?? '');
writeFileSync(join(dir, command === 'claude' ? '.credentials.json' : 'auth.json'), '{}', {
mode: 0o600,
});
if (email !== undefined) {
writeFileSync(
join(dir, command === 'claude' ? '.claude.json' : 'auth.json'),
JSON.stringify(
command === 'claude' ? { oauthAccount: { emailAddress: email } } : { account: { email } },
),
);
}
return status;
};
}
function scaffoldSeat(
home: string,
name: string,
profile: Record<string, unknown> = { schema: 1, harness: 'claude', bundle: 'primary' },
): string {
const dir = join(home, 'fleet', 'agents', name);
mkdirSync(dir, { recursive: true });
const path = join(dir, 'profile.json');
writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`);
return path;
}
describe('mosaic auth enroll', () => {
it('runs the harness login against the bundle directory and reports what landed', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
expect(process.exitCode).toBeUndefined();
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
expect(h.out.join('\n')).toContain(bundleDir);
expect(h.out.join('\n')).toContain('account: [email protected]');
const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record<
string,
unknown
>;
expect(recorded['emailAddress']).toBe('[email protected]');
});
it('hands the harness its own home and credential directory, never an empty value', async () => {
const h = await harness({ runLogin: goodLogin() });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
expect(h.logins).toEqual([
{
command: 'claude',
args: [],
// An empty CLAUDE_SECURESTORAGE_CONFIG_DIR is not "unset" -- Claude resolves it to
// ~/.claude, the operator's own account -- so exporting one would quietly log the
// operator in over their own credentials instead of enrolling the seat's.
env: { CLAUDE_CONFIG_DIR: bundleDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: bundleDir },
},
]);
});
it('forwards login arguments to the harness', async () => {
const h = await harness({ runLogin: goodLogin() });
await h.run([
'enroll',
'--harness',
'pi',
'--bundle',
'jason_woltje.com',
'--login-arg',
'/login',
]);
expect(h.logins[0]?.args).toEqual(['/login']);
expect(h.logins[0]?.env).toEqual({
PI_CODING_AGENT_DIR: join(h.home, 'auth', 'pi', 'jason_woltje.com'),
});
});
it('exits non-zero when the account that logged in is not the account the bundle claims', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('[email protected]');
expect(h.err.join('')).toContain('one principal wearing two names');
});
it('fails clearly when the harness is not installed', async () => {
const h = await harness({ runLogin: (): null => null });
await h.run(['enroll', '--harness', 'pi', '--bundle', 'someone_example.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('could not start "pi"');
});
it('reports a login that wrote nothing rather than calling the bundle enrolled', async () => {
const h = await harness({ runLogin: (): number => 0 });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('login left no credential');
expect(h.err.join('')).toContain('nothing was assigned');
});
it('still checks the bundle when the harness exits non-zero on quit', async () => {
// Several harnesses exit non-zero on a normal quit after a successful login. The
// credential on disk is the fact that matters, not the exit status.
const h = await harness({ runLogin: goodLogin('[email protected]', 130) });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
expect(process.exitCode).toBeUndefined();
expect(h.out.join('\n')).toContain('account: [email protected]');
});
it('creates the directory and stops when the operator will run the login themselves', async () => {
const h = await harness();
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com', '--no-login']);
expect(h.logins).toHaveLength(0);
expect(process.exitCode).toBeUndefined();
expect(h.out.join('\n')).toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR=');
});
it('rejects a harness it does not know', async () => {
const h = await harness();
await h.run(['enroll', '--harness', 'emacs', '--bundle', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('--harness must be one of');
});
});
describe('mosaic auth assign', () => {
it('pegs a seat to a bundle and leaves every other profile field alone', async () => {
const h = await harness();
const path = scaffoldSeat(h.home, 'uc-e6-rev', {
schema: 1,
harness: 'claude',
bundle: 'primary',
model: 'opus',
overlay: 'overlay.json',
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
});
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
expect(written).toEqual({
schema: 1,
harness: 'claude',
bundle: 'reviewer_example.com',
model: 'opus',
overlay: 'overlay.json',
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
});
expect(h.out.join('\n')).toContain('uc-e6-rev: primary -> reviewer_example.com');
});
it('says the bundle is not enrolled, because the seat will refuse to launch until it is', async () => {
const h = await harness();
scaffoldSeat(h.home, 'uc-e6-rev');
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
expect(h.out.join('\n')).toContain('is not enrolled for claude');
});
it('is quiet about enrolment when the bundle really is enrolled', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
scaffoldSeat(h.home, 'uc-e6-rev');
h.out.length = 0;
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
expect(h.out.join('\n')).not.toContain('is not enrolled');
});
it('reports an unchanged seat instead of rewriting it', async () => {
const h = await harness();
scaffoldSeat(h.home, 'seat', { schema: 1, harness: 'pi', bundle: 'held_example.com' });
await h.run(['assign', 'seat', '--bundle', 'held_example.com']);
expect(h.out.join('\n')).toContain('seat: already held_example.com (pi)');
});
it('assigns every scaffolded seat with --all', async () => {
const h = await harness();
scaffoldSeat(h.home, 'a');
scaffoldSeat(h.home, 'b', { schema: 1, harness: 'pi', bundle: 'primary' });
await h.run(['assign', '--all', '--bundle', 'shared_example.com']);
for (const name of ['a', 'b']) {
const written = JSON.parse(
await readFile(join(h.home, 'fleet', 'agents', name, 'profile.json'), 'utf8'),
) as Record<string, unknown>;
expect(written['bundle']).toBe('shared_example.com');
}
});
it('refuses an ambiguous target rather than guessing', async () => {
const h = await harness();
scaffoldSeat(h.home, 'a');
await h.run(['assign', 'a', '--all', '--bundle', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('exactly one of');
process.exitCode = undefined;
h.err.length = 0;
await h.run(['assign', '--bundle', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('exactly one of');
});
it('names the seat that does not exist', async () => {
const h = await harness();
await h.run(['assign', 'ghost', '--bundle', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('no such fleet agent');
expect(h.err.join('')).toContain('mosaic fleet agent new ghost');
});
it('refuses to rewrite a profile that is already invalid', async () => {
const h = await harness();
// Re-serializing a broken profile would produce a file that looks repaired and still
// fails at launch, with the original damage no longer visible.
scaffoldSeat(h.home, 'broken', { schema: 1, harness: 'claude', nonsense: true });
await h.run(['assign', 'broken', '--bundle', 'x']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('unknown profile key "nonsense"');
});
});
describe('mosaic auth list', () => {
it('says where bundles would live on a host that has none', async () => {
const h = await harness();
await h.run(['list']);
expect(h.out.join('\n')).toContain(join(h.home, 'auth'));
expect(h.out.join('\n')).toContain('mosaic auth enroll');
});
it('shows each bundle with its enrolment state and account', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
h.out.length = 0;
await h.run(['list', '--harness', 'claude']);
const text = h.out.join('\n');
expect(text).toContain('jason_woltje.com');
expect(text).toContain('enrolled');
expect(text).toContain('[email protected]');
});
});
describe('mosaic auth default', () => {
it('moves the primary alias to a bundle', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
h.out.length = 0;
await h.run(['default', 'jason_woltje.com', '--harness', 'claude']);
expect(process.exitCode).toBeUndefined();
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
h.out.length = 0;
await h.run(['list', '--harness', 'claude']);
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
});
it('refuses a bundle that was never enrolled', async () => {
const h = await harness();
mkdirSync(join(h.home, 'auth', 'claude'), { recursive: true });
await h.run(['default', 'missing_example.com', '--harness', 'claude']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('no such bundle');
});
});
@@ -0,0 +1,326 @@
/**
* `mosaic auth enroll | assign | list | default` -- the operator surface for credential bundles.
*
* These are local commands. They never talk to the gateway, unlike the rest of `mosaic auth`,
* and they work on a host where the gateway is down. What they do is give one host more than
* one account per harness and let each seat be pegged to one of them.
*
* Enroll does not reimplement any harness's login. It creates a private bundle directory,
* points the harness's own home at it by environment, and runs the harness. Whatever the
* harness writes is then checked: credential present, owner-only, and the account it belongs
* to recorded. Logging into the wrong account is the failure this catches -- it is otherwise
* silent, and it collapses two principals back into one.
*/
import { spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
AuthBundleError,
PRIMARY_ALIAS,
completeEnrollment,
listBundles,
prepareEnrollment,
setDefaultBundle,
} from '../fleet/auth-bundles.js';
import type { CredentialHarness } from '../fleet/credential-sharing.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
import { FleetLaunchError, parseFleetAgentProfile } from './fleet-launch-command.js';
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
export interface FleetAuthCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
/**
* Test seam for running the harness login. Returns the harness's exit status; `null` means
* the harness could not be started at all.
*/
readonly runLogin?: (
command: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
) => number | null;
}
function requireHarness(value: string | undefined): CredentialHarness {
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
throw new AuthBundleError(
'invalid-request',
`--harness must be one of: ${HARNESSES.join(', ')}`,
);
}
return value as CredentialHarness;
}
function defaultRunLogin(
command: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
): number | null {
const result = spawnSync(command, [...args], {
stdio: 'inherit',
env: { ...process.env, ...env },
});
if (result.error !== undefined) return null;
return result.status;
}
function fail(error: unknown, verb: string): void {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code =
error instanceof AuthBundleError
? error.code
: error instanceof FleetLaunchError
? error.code
: 'failed';
process.stderr.write(`mosaic auth ${verb} failed (${code}): ${message}\n`);
}
// ─── assign ──────────────────────────────────────────────────────────────────
interface AssignOutcome {
readonly agent: string;
readonly harness: CredentialHarness;
readonly from: string;
readonly to: string;
readonly changed: boolean;
}
function agentsRoot(dataHome: string): string {
return join(dataHome, 'fleet', 'agents');
}
function listAgents(dataHome: string): string[] {
try {
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
/**
* Rewrite one seat's `bundle`, leaving every other field byte-identical where possible.
*
* The profile is re-parsed before writing rather than patched blind: an already-invalid
* profile should be reported as invalid here, not silently re-serialized into something that
* looks fine and still fails at launch.
*/
function assignOne(dataHome: string, agent: string, bundle: string): AssignOutcome {
const path = join(agentsRoot(dataHome), agent, 'profile.json');
let source: string;
try {
source = readFileSync(path, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new AuthBundleError(
'invalid-request',
`no such fleet agent: ${path} — scaffold it first: mosaic fleet agent new ${agent}`,
);
}
throw error;
}
const profile = parseFleetAgentProfile(source);
const harness = profile.harness as CredentialHarness;
const raw = JSON.parse(source) as Record<string, unknown>;
const from = profile.bundle;
if (from === bundle) return { agent, harness, from, to: bundle, changed: false };
raw['bundle'] = bundle;
writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`);
return { agent, harness, from, to: bundle, changed: true };
}
// ─── registration ────────────────────────────────────────────────────────────
/** Adds the local bundle verbs onto the existing `mosaic auth` command. */
export function registerFleetAuthCommands(
authCommand: Command,
deps: FleetAuthCommandDeps = {},
): void {
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
const runLogin = deps.runLogin ?? defaultRunLogin;
authCommand
.command('enroll')
.description('Enrol a credential bundle by running a harness login into a private directory')
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
.requiredOption('--bundle <bundle>', 'Bundle name, normally the account email with @ as _')
.option('--login-arg <arg...>', 'Arguments to pass to the harness login invocation')
.option('--no-login', 'Only create the bundle directory; run the login yourself')
.action(
(options: {
harness?: string;
bundle: string;
loginArg?: string[];
login?: boolean;
}): void => {
try {
const harness = requireHarness(options.harness);
const plan = prepareEnrollment(dataHome(), harness, options.bundle);
console.log(`Bundle directory: ${plan.bundleDir}`);
if (plan.hadCredential) {
console.log('A credential is already present. Logging in again replaces it.');
}
for (const [key, value] of Object.entries(plan.env)) {
console.log(` ${key}=${value}`);
}
if (options.login === false) {
console.log(
`\nRun the ${harness} login with the environment above, then verify with:\n mosaic auth list --harness ${harness}`,
);
return;
}
console.log(
`\nStarting ${harness} against that directory. Complete the login inside it, then exit.`,
);
const status = runLogin(harness, options.loginArg ?? [], plan.env);
if (status === null) {
throw new AuthBundleError(
'invalid-request',
`could not start "${harness}" — is it installed and on PATH?`,
);
}
// A non-zero login is reported but still checked: some harnesses exit non-zero on
// a normal quit after a successful login, and the credential on disk is the fact
// that matters, not the exit status.
if (status !== 0) {
console.log(`\nNote: ${harness} exited ${String(status)}. Checking the bundle anyway.`);
}
const result = completeEnrollment(plan);
console.log(`\nEnrolled ${harness} bundle "${result.bundle}".`);
console.log(` credential: ${result.credentialPath}`);
if (result.tightened) {
console.log(' permissions: tightened to owner-only');
}
if (result.email !== undefined) {
console.log(` account: ${result.email}`);
} else {
console.log(
' account: could not be determined from what the harness wrote; the bundle name is not verified against the logged-in account',
);
}
if (result.identityMismatch !== undefined) {
process.exitCode = 1;
process.stderr.write(
`\nWARNING: this bundle is named "${result.bundle}" but the account that logged in is "${result.email ?? 'unknown'}", which implies "${result.identityMismatch}".\n` +
'Two seats pointed at bundles that hold the same account are one principal wearing two names. Re-enrol under the right name, or delete this bundle.\n',
);
return;
}
console.log(
`\nAssign it to a seat with:\n mosaic auth assign <agent> --bundle ${result.bundle}`,
);
} catch (error: unknown) {
fail(error, 'enroll');
}
},
);
authCommand
.command('assign [agent]')
.description('Peg a fleet seat to a credential bundle')
.requiredOption('--bundle <bundle>', 'Bundle name to assign')
.option('--all', 'Assign every scaffolded seat')
.action((agent: string | undefined, options: { bundle: string; all?: boolean }): void => {
try {
const home = dataHome();
if ((agent === undefined) === (options.all !== true)) {
throw new AuthBundleError(
'invalid-request',
'give exactly one of: an agent name, or --all',
);
}
const targets = options.all === true ? listAgents(home) : [agent as string];
if (targets.length === 0) {
console.log('No scaffolded fleet agents found; nothing to assign.');
return;
}
// Assignment does not require the bundle to be enrolled -- scaffolding a seat before
// its account exists is a normal order of operations -- but an unenrolled bundle is
// worth saying out loud, because the seat will refuse to launch until it is. The
// check is per harness: the same bundle name under a different harness is a
// different bundle.
const unenrolled = new Set<CredentialHarness>();
for (const target of targets) {
const outcome = assignOne(home, target, options.bundle);
console.log(
outcome.changed
? `${outcome.agent}: ${outcome.from} -> ${outcome.to} (${outcome.harness})`
: `${outcome.agent}: already ${outcome.to} (${outcome.harness})`,
);
const enrolled = listBundles(home, outcome.harness).some(
(entry) => entry.name === options.bundle && entry.enrolled,
);
if (!enrolled) unenrolled.add(outcome.harness);
}
for (const harness of unenrolled) {
console.log(
`\nNotice: "${options.bundle}" is not enrolled for ${harness}, so those seats will refuse to launch until it is.\n mosaic auth enroll --harness ${harness} --bundle ${options.bundle}`,
);
}
} catch (error: unknown) {
fail(error, 'assign');
}
});
authCommand
.command('list')
.description('List local credential bundles and which accounts they hold')
.option('--harness <harness>', `Limit to one harness: ${HARNESSES.join(', ')}`)
.action((options: { harness?: string }): void => {
try {
const home = dataHome();
const harnesses =
options.harness === undefined ? HARNESSES : [requireHarness(options.harness)];
let found = 0;
for (const harness of harnesses) {
const bundles = listBundles(home, harness);
if (bundles.length === 0) continue;
found += bundles.length;
console.log(`${harness}:`);
for (const bundle of bundles) {
const parts = [
bundle.alias ? `${bundle.name} -> ${bundle.target ?? '(dangling)'}` : bundle.name,
bundle.enrolled ? 'enrolled' : 'NOT ENROLLED',
];
if (bundle.email !== undefined) parts.push(bundle.email);
console.log(` ${parts.join(' ')}`);
}
}
if (found === 0) {
console.log(
`No credential bundles under ${join(home, 'auth')}.\nEnrol one with: mosaic auth enroll --harness <harness> --bundle <account>`,
);
}
} catch (error: unknown) {
fail(error, 'list');
}
});
authCommand
.command('default <bundle>')
.description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`)
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
.action((bundle: string, options: { harness?: string }): void => {
try {
const harness = requireHarness(options.harness);
const alias = setDefaultBundle(dataHome(), harness, bundle);
console.log(`${alias} -> ${bundle}`);
console.log(
`Seats with "bundle": "${PRIMARY_ALIAS}" now use ${bundle} at their next launch. Seats pinned to a named bundle are unaffected.`,
);
} catch (error: unknown) {
fail(error, 'default');
}
});
}
@@ -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', () => {
@@ -288,6 +291,65 @@ describe('profile-selected overlay', () => {
});
});
describe('system settings layer on a real install', () => {
it('composes a harness whose runtime ships no settings.json', () => {
// Measured on a greenfield Debian 13 VM against framework main: the install ships
// runtime/<harness>/ for claude, codex, opencode and pi but a settings.json only for
// claude. Requiring the file made every pi seat unlaunchable.
const fx = fixture({ schema: 1, harness: 'pi' });
rmSync(join(fx.systemHome, 'runtime', 'pi', 'settings.json'));
writeFileSync(join(fx.systemHome, 'runtime', 'pi', 'RUNTIME.md'), '# pi\n');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.settings.layers[0]?.present).toBe(false);
expect(plan.settings.merged).toEqual({});
});
it('still refuses a harness the framework does not carry', () => {
const fx = fixture({ schema: 1, harness: 'pi' });
rmSync(join(fx.systemHome, 'runtime', 'pi'), { recursive: true });
try {
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
throw new Error('expected resolution to fail');
} catch (error: unknown) {
const launchError = error as FleetLaunchError;
expect(launchError.code).toBe('COMPOSITION_FAILED');
expect(launchError.message).toMatch(/harness runtime is not installed/);
}
});
});
describe('never-enrolled hosts', () => {
it('names the enroll command instead of reporting a shape violation', () => {
// A host that has simply never logged in has no ~/.mosaic/auth at all. Reusing the
// wrong-shape wording there told the operator their auth directory "must be a real,
// non-symlink directory", which reads as tampering rather than "enroll a bundle".
const fx = fixture();
rmSync(join(fx.userHome, 'auth'), { recursive: true });
try {
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
throw new Error('expected resolution to fail');
} catch (error: unknown) {
const launchError = error as FleetLaunchError;
expect(launchError.code).toBe('COMPOSITION_FAILED');
expect(launchError.message).toMatch(/does not exist/);
expect(launchError.message).toMatch(/mosaic auth enroll/);
expect(launchError.message).not.toMatch(/non-symlink/);
}
});
});
describe('unscaffolded agent names', () => {
it('points an unscaffolded name at mosaic fleet agent new', () => {
const fx = fixture();
@@ -381,6 +443,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 +602,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 +618,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 +644,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 +658,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,13 +745,44 @@ 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 },
false,
);
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('asks for dangerous permissions only when the caller does', () => {
const fx = fixture({ schema: 1, harness: 'claude' });
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher });
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dangerous']);
expect(launcher).toHaveBeenCalledWith('claude', [], expect.anything(), expect.anything(), true);
});
it('lets a caller-supplied --model replace the profile model instead of duplicating it', () => {
const fx = fixture({ schema: 1, harness: 'claude', model: 'opus' });
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher });
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--model', 'sonnet']);
expect(launcher).toHaveBeenCalledWith(
'claude',
['--model', 'sonnet'],
expect.anything(),
expect.anything(),
false,
);
});
@@ -743,12 +865,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 +886,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[];
@@ -142,6 +149,7 @@ export interface FleetLaunchCommandDeps {
args: string[],
declaredEnv: Readonly<Record<string, string>>,
context: FleetHarnessContext,
dangerous: boolean,
) => void;
}
@@ -310,9 +318,20 @@ function lstatIfPresent(path: string): Stats | undefined {
}
}
function assertRealDirectory(path: string, label: string): void {
function assertRealDirectory(path: string, label: string, absentHint?: string): void {
const info = lstatIfPresent(path);
if (!info?.isDirectory() || info.isSymbolicLink()) {
// Absent and wrong-shaped are different problems and want different words. A host that has
// simply never enrolled a bundle was being told its auth directory "must be a real,
// non-symlink directory", which reads as a tampering report rather than "log in first".
if (!info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
absentHint
? `${label} does not exist: ${path}${absentHint}`
: `${label} does not exist: ${path}`,
);
}
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} must be a real, non-symlink directory: ${path}`,
@@ -330,6 +349,22 @@ function assertContained(root: string, candidate: string, label: string): void {
}
}
/**
* Proves the framework is installed and knows this harness. This is the check the required
* system settings layer used to stand in for, moved to the thing that is actually always
* present: the runtime directory. A missing one means an uninstalled framework or a harness
* the install does not carry, and both are worth failing on before a seat is composed.
*/
function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void {
const runtimeDir = join(systemHome, 'runtime', harness);
if (!lstatIfPresent(runtimeDir)?.isDirectory()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`,
);
}
}
function readSettingsLayer(
name: SettingsLayer['name'],
path: string,
@@ -395,9 +430,10 @@ function resolveCredential(
assertRealDirectory(userHome, 'user Mosaic root');
const realUserHome = realpathSync(userHome);
const authDirectory = join(userHome, 'auth');
assertRealDirectory(authDirectory, 'auth directory');
const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`;
assertRealDirectory(authDirectory, 'auth directory', enrollHint);
const authRoot = join(authDirectory, profile.harness);
assertRealDirectory(authRoot, `${profile.harness} auth root`);
assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint);
const resolvedAuthRoot = realpathSync(authRoot);
assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`);
@@ -448,6 +484,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 +502,11 @@ function resolveCredential(
...(email === undefined ? {} : { email }),
display,
},
credential: { link: credentialLink, target: resolvedCredential },
credential: {
...(sharesByEnv ? {} : { link: credentialLink }),
target: resolvedCredential,
dir: resolvedBundleDir,
},
};
}
@@ -657,7 +701,10 @@ function buildArgv(
passthrough: string[],
): string[] {
const argv: string[] = [profile.harness];
if (profile.model) argv.push('--model', profile.model);
// A caller-supplied --model replaces the profile's rather than being appended after
// it. The fleet roster carries a model per seat and is the surface operators edit, so
// it has to win; emitting both flags would leave that to each harness's arg parser.
if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model);
if (profile.harness === 'pi') {
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
}
@@ -716,11 +763,16 @@ export function resolveFleetLaunchComposition(
}
const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json');
assertContained(agentDir, overlayPath, 'agent overlay');
// The framework ships a runtime directory per harness but a settings.json only where it
// has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made
// every pi, codex and opencode seat unlaunchable on a clean install. The install is what
// has to be present; an absent base layer just means the harness has no system settings.
assertHarnessRuntimeInstalled(roots.systemHome, profile.harness);
const layers: SettingsLayer[] = [
readSettingsLayer(
'system',
join(roots.systemHome, 'runtime', profile.harness, 'settings.json'),
true,
false,
),
readSettingsLayer(
'user',
@@ -754,9 +806,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 +897,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 +918,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 +973,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}`);
}
@@ -929,33 +1000,43 @@ export function registerFleetLaunchCommand(
.command('launch <name>')
.description('Compose and launch one per-agent harness home')
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
.option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
.action(
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
// Dangerous mode is the caller's to ask for, not the seat's to assume. An
// unattended tmux seat needs it -- a permission prompt with nobody at the pane
// is a hung agent -- so the roster launcher passes the flag explicitly and it
// stays visible in the process table rather than hiding in a profile default.
launcher(
plan.profile.harness,
plan.argv.slice(1),
plan.env,
{ agentDir: plan.agentDir, mosaicHome: plan.systemHome },
opts.dangerous === true,
);
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, {
agentDir: plan.agentDir,
mosaicHome: plan.systemHome,
});
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
});
},
);
}
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'adopt',
'agent',
'apply',
'backlog',
+6
View File
@@ -42,6 +42,7 @@ import {
registerFleetAgentScaffoldCommand,
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
@@ -2080,6 +2081,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome,
});
// The counterpart to launch's refusals: launch will not delete a real directory sitting on
// a managed path, and this is how one gets moved out of the way instead.
registerFleetAdoptCommand(cmd, {
...(deps.fleetDataHome === undefined ? {} : { 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);
@@ -122,6 +122,7 @@ describe('checkSequentialThinking', () => {
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
@@ -174,6 +175,9 @@ describe('checkSequentialThinking', () => {
},
}),
);
// Scaffolded seat, unconfigured harness: the seat's own identity is present so this
// still fails on the missing MCP configuration rather than on a missing SOUL.md.
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
@@ -200,6 +204,7 @@ describe('checkSequentialThinking', () => {
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
checker,
);
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
writeFileSync(
join(agentDir, '.claude', '.claude.json'),
@@ -234,6 +239,7 @@ describe('checkSequentialThinking', () => {
const bin = join(installed, 'bin');
try {
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
mkdirSync(join(agentDir, '.claude'), { recursive: true });
mkdirSync(bin, { recursive: true });
copyFileSync(
@@ -281,6 +287,37 @@ describe('checkSequentialThinking', () => {
}
});
it('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => {
// Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md
// reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at
// the pane the seat parked on the wizard's menu -- tmux session live, unit reporting
// fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an
// unattended launch must fail loudly rather than wait for a keystroke.
const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-'));
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-'));
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md')));
expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new'));
} finally {
error.mockRestore();
exit.mockRestore();
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
rmSync(installed, { recursive: true, force: true });
}
});
it('rejects a group-writable installed helper root', () => {
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
+19 -3
View File
@@ -244,7 +244,22 @@ function checkRuntime(cmd: string): void {
}
}
function checkSoul(): void {
function checkSoul(fleet?: FleetHarnessContext): void {
// A fleet seat carries its own identity -- `mosaic fleet agent new` writes SOUL.md into the
// seat home -- so the operator's system-wide SOUL.md is not the file to check, and the
// interactive wizard is never the right answer for an unattended seat. Measured on a
// greenfield VM: a seat launched into tmux parked on the wizard's menu with nobody at the
// pane. The session was live, the unit reported fine, and no agent ever started.
if (fleet) {
const seatSoul = join(fleet.agentDir, 'SOUL.md');
if (!existsSync(seatSoul)) {
console.error(`[mosaic] ERROR: seat identity not found: ${seatSoul}`);
console.error('[mosaic] Scaffold the seat first: mosaic fleet agent new <name>');
process.exit(1);
}
return;
}
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -1035,7 +1050,7 @@ function launchRuntime(
): never {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkSoul(context.fleet);
(context.runtimeCheck ?? checkRuntime)(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
@@ -1207,8 +1222,9 @@ export function launchFleetRuntime(
args: string[],
declaredEnv: Readonly<Record<string, string>>,
fleet: FleetHarnessContext,
dangerous = false,
): never {
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
return launchRuntime(runtime, args, dangerous, { fleet, declaredEnv });
}
/** Bounded production-path test seam; all preflight and composition remain real. */
+341
View File
@@ -0,0 +1,341 @@
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { AdoptionError, promoteBundleAlias, promoteStoreEntry, scanAdoptions } from './adoption.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function userHome(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-'));
return join(root, '.mosaic');
}
/** A real directory where the primary alias belongs, with something inside worth not losing. */
function realAliasDirectory(
home: string,
harness: string,
credential = '.credentials.json',
): string {
const path = join(home, 'auth', harness, 'primary');
mkdirSync(path, { recursive: true });
writeFileSync(join(path, credential), '{"token":"kept"}');
return path;
}
function seat(
home: string,
name: string,
profile: Record<string, unknown> = { schema: 1, harness: 'claude', bundle: 'primary' },
): string {
const dir = join(home, 'fleet', 'agents', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
return dir;
}
/** A real plugin/skill directory inside a seat, where a link into the store belongs. */
function seatDirectory(
home: string,
agent: string,
harness: string,
plural: string,
name: string,
): string {
const path = join(home, 'fleet', 'agents', agent, `.${harness}`, plural, name);
mkdirSync(path, { recursive: true });
writeFileSync(join(path, 'marker.txt'), 'kept');
return path;
}
describe('scanAdoptions', () => {
it('finds nothing on a host that has no ~/.mosaic at all', async () => {
expect(scanAdoptions(await userHome())).toEqual([]);
});
it('finds a real directory on the primary alias path and names the command that resolves it', async () => {
const home = await userHome();
const path = realAliasDirectory(home, 'claude');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]?.kind).toBe('bundle-alias');
expect(findings[0]?.path).toBe(path);
expect(findings[0]?.harness).toBe('claude');
expect(findings[0]?.blocked).toBeUndefined();
expect(findings[0]?.remedy).toBe('mosaic fleet adopt bundle --harness claude --as <account>');
});
it('ignores a primary alias that is already a symlink', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'claude', 'jason_woltje.com'), { recursive: true });
symlinkSync('jason_woltje.com', join(home, 'auth', 'claude', 'primary'));
expect(scanAdoptions(home)).toEqual([]);
});
it('finds a real plugin directory inside a seat', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
const path = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
kind: 'store-entry',
path,
agent: 'uc-e6-coder',
store: 'plugin',
name: 'reviewer',
});
expect(findings[0]?.remedy).toBe('mosaic fleet adopt plugin reviewer --seat uc-e6-coder');
});
it('finds skills the same way it finds plugins', async () => {
const home = await userHome();
seat(home, 'uc-e6-rev');
seatDirectory(home, 'uc-e6-rev', 'claude', 'skills', 'spec-audit');
const findings = scanAdoptions(home);
expect(findings).toHaveLength(1);
expect(findings[0]?.store).toBe('skill');
expect(findings[0]?.remedy).toBe('mosaic fleet adopt skill spec-audit --seat uc-e6-rev');
});
// Scanning the wrong directory name would report nothing on a pi seat while launch keeps
// refusing to compose it, which is worse than not having the scan.
it('looks in the seat home the launcher uses, not always the claude one', async () => {
const home = await userHome();
seat(home, 'terra', { schema: 1, harness: 'pi', bundle: 'primary' });
const path = seatDirectory(home, 'terra', 'pi', 'plugins', 'notes');
expect(scanAdoptions(home).map((finding) => finding.path)).toEqual([path]);
});
it('does not report a link that is already pointing into the store', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
expect(scanAdoptions(home)).toEqual([]);
});
it('marks the finding blocked when the store already holds that name', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const findings = scanAdoptions(home);
expect(findings[0]?.blocked).toBe('destination occupied');
expect(findings[0]?.remedy).toContain('compare the two');
});
// One malformed profile hiding every finding behind it would make the scan useless exactly
// on the hosts that need it most.
it('reports an unreadable seat as a gap and keeps scanning the others', async () => {
const home = await userHome();
mkdirSync(join(home, 'fleet', 'agents', 'broken'), { recursive: true });
writeFileSync(join(home, 'fleet', 'agents', 'broken', 'profile.json'), 'not json');
seat(home, 'working');
const path = seatDirectory(home, 'working', 'claude', 'plugins', 'reviewer');
const findings = scanAdoptions(home);
expect(findings.map((finding) => finding.kind)).toEqual(['unreadable-seat', 'store-entry']);
expect(findings[0]?.blocked).toBe('unreadable profile');
expect(findings[1]?.path).toBe(path);
});
});
describe('promoteBundleAlias', () => {
it('moves the directory to its account name and points the alias at it', async () => {
const home = await userHome();
const from = realAliasDirectory(home, 'claude');
const result = promoteBundleAlias(home, 'claude', 'jason_woltje.com');
expect(result.to).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
expect(result.from).toBe(from);
// The credential travelled with the directory; adoption is a move, never a re-creation.
expect(readFileSync(join(result.to, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
const alias = lstatSync(result.alias);
expect(alias.isSymbolicLink()).toBe(true);
expect(scanAdoptions(home)).toEqual([]);
});
it('refuses when the alias path is already a symlink', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'pi', 'jason_woltje.com'), { recursive: true });
symlinkSync('jason_woltje.com', join(home, 'auth', 'pi', 'primary'));
expect(() => promoteBundleAlias(home, 'pi', 'other')).toThrow(
/already an alias symlink.*mosaic auth default/su,
);
});
it('refuses when there is nothing on the alias path', async () => {
const home = await userHome();
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(AdoptionError);
});
it('refuses to adopt a directory as the alias name itself', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
expect(() => promoteBundleAlias(home, 'claude', 'primary')).toThrow(
/that is the alias being freed/u,
);
});
it('refuses a name that would escape the auth root', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
expect(() => promoteBundleAlias(home, 'claude', '../elsewhere')).toThrow(
/not a safe bundle name/u,
);
expect(existsSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'))).toBe(true);
});
// The failure that would cost data: an occupied destination silently merged into, or worse,
// replaced. Both directories must still be exactly where they were.
it('refuses an occupied destination and moves nothing', async () => {
const home = await userHome();
realAliasDirectory(home, 'claude');
const occupied = join(home, 'auth', 'claude', 'jason_woltje.com');
mkdirSync(occupied, { recursive: true });
writeFileSync(join(occupied, '.credentials.json'), '{"token":"other"}');
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(
/already exists and will not be overwritten/u,
);
expect(readFileSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'), 'utf8')).toBe(
'{"token":"kept"}',
);
expect(readFileSync(join(occupied, '.credentials.json'), 'utf8')).toBe('{"token":"other"}');
});
});
describe('promoteStoreEntry', () => {
it('moves the directory into the central store, creating the store root', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
const result = promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
expect(result.to).toBe(join(home, 'plugins', 'reviewer'));
expect(readFileSync(join(result.to, 'marker.txt'), 'utf8')).toBe('kept');
expect(existsSync(from)).toBe(false);
expect(result.listedInProfile).toBe(true);
});
// Installing the link here would fail the next launch as an unrecorded symlink, because the
// seat's .mosaic-managed-links.json is launch's to write. Adoption stops at the move.
it('leaves the seat path empty rather than installing the link itself', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder', {
schema: 1,
harness: 'claude',
bundle: 'primary',
plugins: ['reviewer'],
});
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
expect(existsSync(from)).toBe(false);
expect(() => lstatSync(from)).toThrow();
});
it('says when the seat does not list the entry, because then nothing links it back', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
expect(promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer').listedInProfile).toBe(
false,
);
});
it('refuses an occupied destination and moves nothing', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
writeFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'store copy');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
/already exists and will not be overwritten/u,
);
expect(readFileSync(join(from, 'marker.txt'), 'utf8')).toBe('kept');
expect(readFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe(
'store copy',
);
});
it('refuses an entry that is already a link into the store', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
mkdirSync(installRoot, { recursive: true });
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
/already a link into the store/u,
);
});
it('refuses a name that would escape the store root', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', '../escape')).toThrow(
/not a safe plugin name/u,
);
});
it('names the profile it could not read rather than guessing the seat home', async () => {
const home = await userHome();
expect(() => promoteStoreEntry(home, 'ghost', 'plugin', 'reviewer')).toThrow(
/ghost.*profile\.json.*cannot be located/su,
);
});
it('reports a missing directory as nothing to adopt', async () => {
const home = await userHome();
seat(home, 'uc-e6-coder');
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'skill', 'absent')).toThrow(
/no such skill directory/u,
);
});
});
+380
View File
@@ -0,0 +1,380 @@
/**
* Adopting real directories that sit where the fleet expects a managed link.
*
* A host used before the fleet arrived -- or an operator who ran a login by hand -- ends up
* with a real directory on a path launch reserves for a link: `auth/<harness>/primary`, or a
* plugin/skill directory inside a seat's home. Launch refuses those on purpose, because the
* only way to make a link fit there is to delete whatever is already there.
*
* This module is the other half of that refusal. It finds those directories and moves them
* where they belong. Nothing here deletes anything: a promotion is a rename, and an occupied
* destination is a refusal rather than a merge or an overwrite. Cross-device renames are
* surfaced instead of being retried as copy-then-delete, because a copy-then-delete is a
* delete and this module does not do that.
*
* Link creation is deliberately NOT done here. Seat store links are recorded in the seat's
* `.mosaic-managed-links.json`, and that manifest is owned by launch -- a link installed
* behind its back reads as "unrecorded symlink occupies managed path" on the next launch,
* which trades one refusal for another. So a promoted plugin lands in the central store and
* the next launch links it, provided the seat's profile lists it. Whether a seat gets a
* plugin is `mosaic fleet plugin`'s decision, not this one's.
*
* The auth alias is different: it lives in the auth root, no manifest covers it, and
* setDefaultBundle() already owns installing it. So a bundle promotion finishes the job.
*/
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, type Stats } from 'node:fs';
import { join } from 'node:path';
import { PRIMARY_ALIAS, assertSafeBundleName, authRoot, setDefaultBundle } from './auth-bundles.js';
import type { CredentialHarness } from './credential-sharing.js';
/** Mirrors the harness list the auth and launch surfaces accept. */
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
/** Store kinds a seat can hold, and the directory name each uses in both trees. */
const STORE_DIRECTORY: Record<StoreKind, string> = { plugin: 'plugins', skill: 'skills' };
/** Same charset as a bundle name; anything with a separator or a dot-dot never reaches a join. */
const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
export type StoreKind = 'plugin' | 'skill';
export type AdoptionErrorCode =
| 'invalid-request'
| 'nothing-to-adopt'
| 'destination-occupied'
| 'cross-device'
| 'unsafe-shape';
export class AdoptionError extends Error {
readonly code: AdoptionErrorCode;
constructor(code: AdoptionErrorCode, message: string) {
super(message);
this.name = 'AdoptionError';
this.code = code;
}
}
export interface AdoptionFinding {
/** `bundle-alias` and `store-entry` are adoptable; `unreadable-seat` is a scan gap. */
readonly kind: 'bundle-alias' | 'store-entry' | 'unreadable-seat';
/** The real directory that a launch would refuse to touch. */
readonly path: string;
/** What this is, in one line. */
readonly reason: string;
/** The exact command that resolves it, or what to look at when nothing can. */
readonly remedy: string;
readonly harness?: CredentialHarness;
readonly agent?: string;
readonly store?: StoreKind;
readonly name?: string;
/** Set when the promotion cannot run as-is; the remedy then describes the obstacle. */
readonly blocked?: string;
}
export interface BundlePromotion {
readonly harness: CredentialHarness;
/** Where the adopted directory now lives. */
readonly bundle: string;
readonly from: string;
readonly to: string;
/** The alias path now pointing at it. */
readonly alias: string;
}
export interface StorePromotion {
readonly agent: string;
readonly store: StoreKind;
readonly name: string;
readonly from: string;
readonly to: string;
/** True when the seat's profile lists this entry, so the next launch will link it back. */
readonly listedInProfile: boolean;
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function isRealDirectory(path: string): boolean {
const info = lstatIfPresent(path);
return info !== undefined && info.isDirectory() && !info.isSymbolicLink();
}
function assertSafeEntryName(name: string, store: StoreKind): void {
if (!ENTRY_NAME.test(name)) {
throw new AdoptionError(
'invalid-request',
`"${name}" is not a safe ${store} name; use letters, digits, and . _ @ -`,
);
}
}
function agentsRoot(dataHome: string): string {
return join(dataHome, 'fleet', 'agents');
}
/**
* A seat's harness home, by the same rule launch uses (`harnessHome()` in commands/launch.ts).
* Scanning by any other rule finds directories launch never looks at and misses the ones it
* refuses on.
*/
function seatHome(dataHome: string, agent: string, harness: CredentialHarness): string {
return join(agentsRoot(dataHome), agent, `.${harness}`);
}
interface SeatProfile {
readonly harness: CredentialHarness;
readonly plugins: readonly string[];
readonly skills: readonly string[];
}
/**
* Read only what adoption needs out of a seat profile, leniently.
*
* A scan that dies on one malformed profile hides every finding behind it, so an unreadable
* profile is reported as a scan gap and the walk continues. Strictness belongs at launch,
* which validates the whole profile and refuses to run the seat.
*/
function readSeatProfile(dataHome: string, agent: string): SeatProfile | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(join(agentsRoot(dataHome), agent, 'profile.json'), 'utf8'));
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
const raw = parsed as Record<string, unknown>;
const harness = raw['harness'];
if (typeof harness !== 'string' || !HARNESSES.includes(harness as CredentialHarness)) {
return undefined;
}
const names = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
return {
harness: harness as CredentialHarness,
plugins: names(raw['plugins']),
skills: names(raw['skills']),
};
}
function listDirectory(path: string): string[] {
try {
return readdirSync(path, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
function listAgents(dataHome: string): string[] {
try {
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
/**
* Everything under `~/.mosaic` that occupies a path the fleet manages with a link.
*
* Read-only. Every finding carries the command that resolves it, because the value of the
* scan is that an operator does not have to work out what a composition refusal meant.
*/
export function scanAdoptions(dataHome: string): AdoptionFinding[] {
const findings: AdoptionFinding[] = [];
for (const harness of HARNESSES) {
const alias = join(authRoot(dataHome, harness), PRIMARY_ALIAS);
if (!isRealDirectory(alias)) continue;
findings.push({
kind: 'bundle-alias',
path: alias,
harness,
reason: `a real directory occupies the ${PRIMARY_ALIAS} alias path; ${harness} seats pointed at "${PRIMARY_ALIAS}" cannot launch`,
remedy: `mosaic fleet adopt bundle --harness ${harness} --as <account>`,
});
}
for (const agent of listAgents(dataHome)) {
const profile = readSeatProfile(dataHome, agent);
if (profile === undefined) {
findings.push({
kind: 'unreadable-seat',
path: join(agentsRoot(dataHome), agent, 'profile.json'),
agent,
reason:
'profile could not be read, or names no known harness, so this seat was not scanned',
remedy: `mosaic fleet agent get ${agent}`,
blocked: 'unreadable profile',
});
continue;
}
for (const store of ['plugin', 'skill'] as const) {
const plural = STORE_DIRECTORY[store];
const installRoot = join(seatHome(dataHome, agent, profile.harness), plural);
for (const name of listDirectory(installRoot)) {
const destination = join(dataHome, plural, name);
const occupied = lstatIfPresent(destination) !== undefined;
findings.push({
kind: 'store-entry',
path: join(installRoot, name),
agent,
store,
name,
reason: `a real ${store} directory sits where the seat expects a link into the central store`,
remedy: occupied
? `${destination} already exists; compare the two and remove or rename one by hand`
: `mosaic fleet adopt ${store} ${name} --seat ${agent}`,
...(occupied ? { blocked: 'destination occupied' } : {}),
});
}
}
}
return findings;
}
/**
* Move a directory, refusing every case where the move would cost data.
*
* EXDEV is surfaced rather than handled: the fallback for a cross-device rename is copy then
* delete, and this module does not delete.
*/
function movePreservingBoth(from: string, to: string, label: string): void {
if (lstatIfPresent(to) !== undefined) {
throw new AdoptionError(
'destination-occupied',
`${label} destination already exists and will not be overwritten: ${to}`,
);
}
try {
renameSync(from, to);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
throw new AdoptionError(
'cross-device',
`${from} and ${to} are on different filesystems, so this cannot be a rename. Copy it across yourself and remove the original once you have checked the copy: ${to}`,
);
}
throw error;
}
}
/**
* Adopt a real directory sitting on the `primary` alias path as a named bundle.
*
* The directory is moved to its account name first and the alias installed second. That order
* is the one that survives a failure: if the alias cannot be created, the credentials are
* intact under their own name and the error says where they are. The reverse order would have
* a window where the alias points at nothing.
*/
export function promoteBundleAlias(
dataHome: string,
harness: CredentialHarness,
as: string,
): BundlePromotion {
assertSafeBundleName(as);
if (as === PRIMARY_ALIAS) {
throw new AdoptionError(
'invalid-request',
`--as must be the account this directory holds, not "${PRIMARY_ALIAS}" — that is the alias being freed`,
);
}
const root = authRoot(dataHome, harness);
const alias = join(root, PRIMARY_ALIAS);
const info = lstatIfPresent(alias);
if (info === undefined) {
throw new AdoptionError('nothing-to-adopt', `nothing at ${alias}; there is nothing to adopt`);
}
if (info.isSymbolicLink()) {
throw new AdoptionError(
'nothing-to-adopt',
`${alias} is already an alias symlink. Retarget it with: mosaic auth default --harness ${harness} <bundle>`,
);
}
if (!info.isDirectory()) {
throw new AdoptionError(
'unsafe-shape',
`${alias} is neither a directory nor a symlink; adoption only moves directories`,
);
}
const destination = join(root, as);
movePreservingBoth(alias, destination, 'bundle');
return {
harness,
bundle: as,
from: alias,
to: destination,
alias: setDefaultBundle(dataHome, harness, as),
};
}
/**
* Adopt a real plugin/skill directory out of a seat and into the central store.
*
* No link is installed. The seat's link manifest belongs to launch, and a link this command
* created behind it would fail the next composition as an unrecorded symlink. The next launch
* installs and records the link itself when the seat's profile lists the entry -- and when it
* does not, the entry is now vetted store content that any seat can be given deliberately,
* which is the outcome that was wanted anyway.
*/
export function promoteStoreEntry(
dataHome: string,
agent: string,
store: StoreKind,
name: string,
): StorePromotion {
assertSafeEntryName(name, store);
const profile = readSeatProfile(dataHome, agent);
if (profile === undefined) {
throw new AdoptionError(
'invalid-request',
`cannot read a harness out of ${join(agentsRoot(dataHome), agent, 'profile.json')}, so the seat's home cannot be located`,
);
}
const plural = STORE_DIRECTORY[store];
const source = join(seatHome(dataHome, agent, profile.harness), plural, name);
const info = lstatIfPresent(source);
if (info === undefined) {
throw new AdoptionError('nothing-to-adopt', `no such ${store} directory: ${source}`);
}
if (info.isSymbolicLink()) {
throw new AdoptionError(
'nothing-to-adopt',
`${source} is already a link into the store; there is nothing to adopt`,
);
}
if (!info.isDirectory()) {
throw new AdoptionError(
'unsafe-shape',
`${source} is not a directory; adoption only moves directories`,
);
}
mkdirSync(join(dataHome, plural), { recursive: true });
const destination = join(dataHome, plural, name);
movePreservingBoth(source, destination, store);
return {
agent,
store,
name,
from: source,
to: destination,
listedInProfile: (store === 'plugin' ? profile.plugins : profile.skills).includes(name),
};
}
@@ -0,0 +1,294 @@
import { chmodSync, lstatSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
AuthBundleError,
bundleNameForEmail,
completeEnrollment,
listBundles,
prepareEnrollment,
readBundleIdentity,
setDefaultBundle,
} from './auth-bundles.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function userHome(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'mosaic-auth-'));
return join(root, '.mosaic');
}
describe('prepareEnrollment', () => {
it('creates the bundle directory owner-only and names the environment the login needs', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
expect(plan.created).toBe(true);
expect(plan.hadCredential).toBe(false);
expect(plan.bundleDir).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
expect(plan.credentialPath).toBe(join(plan.bundleDir, '.credentials.json'));
// Claude reaches its bundle by CLAUDE_SECURESTORAGE_CONFIG_DIR because rename() replaces
// a symlink rather than following it; the login has to write into the bundle directly.
expect(plan.env).toEqual({
CLAUDE_CONFIG_DIR: plan.bundleDir,
CLAUDE_SECURESTORAGE_CONFIG_DIR: plan.bundleDir,
});
for (const path of [home, join(home, 'auth'), join(home, 'auth', 'claude'), plan.bundleDir]) {
expect(lstatSync(path).mode & 0o077).toBe(0);
}
});
it('gives a harness without a credential-directory variable only its home variable', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'pi', 'jason_woltje.com');
expect(plan.credentialPath).toBe(join(plan.bundleDir, 'auth.json'));
expect(plan.env).toEqual({ PI_CODING_AGENT_DIR: plan.bundleDir });
});
it('refuses to enrol into the primary alias and says what to do instead', async () => {
const home = await userHome();
// `primary` is a movable pointer, not storage. Enrolling into it would turn the alias
// into a real directory and there would no longer be a default to move.
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(
/movable alias, not a bundle/u,
);
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(AuthBundleError);
});
it('refuses a bundle name that could escape the auth root', async () => {
const home = await userHome();
expect(() => prepareEnrollment(home, 'claude', '../elsewhere')).toThrow(/not a safe bundle/u);
});
it('tightens an existing world-readable bundle directory rather than trusting it', async () => {
const home = await userHome();
const bundleDir = join(home, 'auth', 'claude', 'loose');
mkdirSync(bundleDir, { recursive: true });
chmodSync(bundleDir, 0o755);
const plan = prepareEnrollment(home, 'claude', 'loose');
expect(plan.created).toBe(false);
expect(lstatSync(plan.bundleDir).mode & 0o077).toBe(0);
});
it('reports an existing credential so a re-login is not mistaken for a first enrolment', async () => {
const home = await userHome();
const first = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(first.credentialPath, '{}', { mode: 0o600 });
expect(prepareEnrollment(home, 'claude', 'jason_woltje.com').hadCredential).toBe(true);
});
});
describe('completeEnrollment', () => {
it('fails when the login exited without writing a credential', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
// The directory exists and looks fine; only the credential proves a login happened. Without
// this check the failure surfaces much later, at composition, blaming the missing file
// rather than the login that never completed.
expect(() => completeEnrollment(plan)).toThrow(/login left no credential/u);
try {
completeEnrollment(plan);
} catch (error: unknown) {
expect((error as AuthBundleError).code).toBe('credential-missing');
}
});
it('tightens a credential the harness wrote with group or other permissions', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(plan.credentialPath, '{}');
chmodSync(plan.credentialPath, 0o644);
const result = completeEnrollment(plan);
expect(result.tightened).toBe(true);
expect(lstatSync(plan.credentialPath).mode & 0o077).toBe(0);
});
it('records the logged-in account so the bundle can say who it holds', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
writeFileSync(
join(plan.bundleDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
);
const result = completeEnrollment(plan);
expect(result.email).toBe('[email protected]');
expect(result.identityMismatch).toBeUndefined();
const recorded = JSON.parse(
await readFile(join(plan.bundleDir, 'account.json'), 'utf8'),
) as Record<string, unknown>;
expect(recorded['emailAddress']).toBe('[email protected]');
expect(lstatSync(join(plan.bundleDir, 'account.json')).mode & 0o077).toBe(0);
});
it('flags a bundle whose name does not match the account that logged into it', async () => {
const home = await userHome();
// This is the failure the whole two-principal model rests on. If an operator enrolling a
// reviewer bundle logs in as the author's account by habit, both seats end up holding one
// principal, the review is self-review, and nothing else in the system notices.
const plan = prepareEnrollment(home, 'claude', 'reviewer_example.com');
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
writeFileSync(
join(plan.bundleDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
);
const result = completeEnrollment(plan);
expect(result.email).toBe('[email protected]');
expect(result.identityMismatch).toBe('author_example.com');
});
it('enrols a harness whose files carry no identity, without inventing one', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'pi', 'someone_example.com');
writeFileSync(plan.credentialPath, JSON.stringify({ token: 'x' }), { mode: 0o600 });
const result = completeEnrollment(plan);
expect(result.email).toBeUndefined();
expect(result.identityMismatch).toBeUndefined();
});
});
describe('bundleNameForEmail', () => {
it('maps an account to its bundle name', () => {
expect(bundleNameForEmail('[email protected]')).toBe('jason.woltje_uscllc.com');
});
});
describe('readBundleIdentity', () => {
it('prefers the recorded account over whatever the harness left lying around', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(join(plan.bundleDir, 'account.json'), JSON.stringify({ emailAddress: '[email protected]' }));
writeFileSync(
join(plan.bundleDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
);
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('[email protected]');
});
it('returns nothing rather than guessing when the files are unreadable', async () => {
const home = await userHome();
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(join(plan.bundleDir, '.claude.json'), 'not json');
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBeUndefined();
});
});
describe('listBundles', () => {
it('is empty on a host that has never enrolled anything', async () => {
expect(listBundles(await userHome(), 'claude')).toEqual([]);
});
it('reports enrolment state, the alias, and which account each bundle holds', async () => {
const home = await userHome();
const enrolled = prepareEnrollment(home, 'claude', 'jason_woltje.com');
writeFileSync(enrolled.credentialPath, '{}', { mode: 0o600 });
writeFileSync(
join(enrolled.bundleDir, 'account.json'),
JSON.stringify({ emailAddress: '[email protected]' }),
);
prepareEnrollment(home, 'claude', 'empty_example.com');
setDefaultBundle(home, 'claude', 'jason_woltje.com');
const bundles = listBundles(home, 'claude');
expect(bundles.map((b) => b.name)).toEqual([
'empty_example.com',
'jason_woltje.com',
'primary',
]);
expect(bundles.find((b) => b.name === 'jason_woltje.com')).toMatchObject({
alias: false,
enrolled: true,
email: '[email protected]',
});
expect(bundles.find((b) => b.name === 'empty_example.com')).toMatchObject({
alias: false,
enrolled: false,
});
expect(bundles.find((b) => b.name === 'primary')).toMatchObject({
alias: true,
target: 'jason_woltje.com',
enrolled: true,
});
});
it('shows a dangling alias instead of failing the whole listing', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
symlinkSync('gone', join(home, 'auth', 'claude', 'primary'));
expect(listBundles(home, 'claude')).toEqual([
{
name: 'primary',
path: join(home, 'auth', 'claude', 'primary'),
resolved: join(home, 'auth', 'claude', 'primary'),
alias: true,
enrolled: false,
},
]);
});
});
describe('setDefaultBundle', () => {
it('retargets an existing alias without writing through into the old bundle', async () => {
const home = await userHome();
for (const name of ['one_example.com', 'two_example.com']) {
const plan = prepareEnrollment(home, 'claude', name);
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
}
setDefaultBundle(home, 'claude', 'one_example.com');
setDefaultBundle(home, 'claude', 'two_example.com');
expect(listBundles(home, 'claude').find((b) => b.name === 'primary')?.target).toBe(
'two_example.com',
);
// The bundle it used to point at is untouched, not emptied by the retarget.
expect(
lstatSync(join(home, 'auth', 'claude', 'one_example.com', '.credentials.json')).isFile(),
).toBe(true);
});
it('refuses to point the alias at a bundle that does not exist', async () => {
const home = await userHome();
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
expect(() => setDefaultBundle(home, 'claude', 'missing_example.com')).toThrow(
/no such bundle/u,
);
});
it('will not delete a real directory that occupies the alias path', async () => {
const home = await userHome();
prepareEnrollment(home, 'claude', 'real_example.com');
mkdirSync(join(home, 'auth', 'claude', 'primary'), { recursive: true });
// A real `primary` directory means someone enrolled into the alias by hand and their
// credentials are inside it. Deleting it to install a symlink would destroy an account.
expect(() => setDefaultBundle(home, 'claude', 'real_example.com')).toThrow(
/will not be deleted/u,
);
});
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Credential bundles under `~/.mosaic/auth/<harness>/<bundle>/`.
*
* A bundle is one account's credentials for one harness. Seats point at a bundle by name in
* their `profile.json`, so two seats can hold genuinely different principals on one host --
* which is the whole reason the fleet can run an author seat and a reviewer seat without the
* review being self-review wearing two hats.
*
* Enrolling does not reimplement any harness's login. It creates the bundle directory, points
* the harness at it by environment, and runs the harness's own login. What this module owns is
* everything around that: that the directory is a real directory nobody can read but its owner,
* that the credential actually landed, and that the account you logged in as is the account the
* bundle claims to hold.
*
* Composition-side reader: commands/fleet-launch-command.ts resolveCredential().
*/
import {
chmodSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
type Stats,
} from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import {
CREDENTIAL_DIR_ENV,
CREDENTIAL_FILE_NAMES,
type CredentialHarness,
} from './credential-sharing.js';
/** Mirrors BUNDLE_NAME in commands/fleet-launch-command.ts; drift here is a launch failure. */
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
/**
* The movable alias. `"bundle": "primary"` in a profile follows whatever this points at; a
* named bundle stays pinned. It is the only symlink launch tolerates in an auth root.
*/
export const PRIMARY_ALIAS = 'primary';
/** Where each harness expects its own home, so login writes into the bundle we just made. */
const HOME_ENV_NAME: Record<CredentialHarness, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
/**
* Files a harness writes that carry the logged-in account's identity, and the paths within
* them to try. Best effort by design: a harness we cannot read an identity from still enrolls,
* it just cannot be checked against its bundle name.
*/
const IDENTITY_SOURCES: Record<CredentialHarness, ReadonlyArray<readonly [string, string[]]>> = {
claude: [
['.claude.json', ['oauthAccount.emailAddress', 'oauthAccount.email']],
['.credentials.json', ['claudeAiOauth.emailAddress']],
],
pi: [['auth.json', ['account.email', 'email', 'user.email']]],
codex: [['auth.json', ['tokens.id_token.email', 'account.email', 'email']]],
opencode: [['auth.json', ['account.email', 'email']]],
};
export type AuthBundleErrorCode =
| 'invalid-request'
| 'bundle-not-found'
| 'bundle-exists'
| 'credential-missing'
| 'unsafe-shape';
export class AuthBundleError extends Error {
readonly code: AuthBundleErrorCode;
constructor(code: AuthBundleErrorCode, message: string) {
super(message);
this.name = 'AuthBundleError';
this.code = code;
}
}
export interface BundleInfo {
readonly name: string;
/** Absolute path of the entry as named, before alias resolution. */
readonly path: string;
/** Where it actually lives. Differs from `path` only for the primary alias. */
readonly resolved: string;
/** True when this entry is the movable primary alias rather than a real bundle. */
readonly alias: boolean;
/** Alias target's bundle name, when this is the alias. */
readonly target?: string;
/** True when the harness's credential file is present in the resolved bundle. */
readonly enrolled: boolean;
/** Account identity recorded at enrollment, when one could be determined. */
readonly email?: string;
}
export interface EnrollmentPlan {
readonly harness: CredentialHarness;
readonly bundle: string;
readonly bundleDir: string;
/** Absolute path the harness must end up writing its credential to. */
readonly credentialPath: string;
/** True when the directory did not exist before this call. */
readonly created: boolean;
/** True when a credential was already present -- a re-login, not a first enrollment. */
readonly hadCredential: boolean;
/**
* Environment the harness login must run under. Every value is an absolute path; Claude
* reads an empty credential-dir value as ~/.claude, the operator's own account, so an
* empty value is never produced here.
*/
readonly env: Readonly<Record<string, string>>;
}
export interface EnrollmentResult {
readonly harness: CredentialHarness;
readonly bundle: string;
readonly bundleDir: string;
readonly credentialPath: string;
/** Identity read back out of what the harness wrote, when it could be determined. */
readonly email?: string;
/**
* Set when an identity was found and it does not match the bundle name. Logging into the
* wrong account is silent otherwise, and it is the failure that quietly collapses two
* principals back into one.
*/
readonly identityMismatch?: string;
/** True when the credential file's permissions had to be tightened to owner-only. */
readonly tightened: boolean;
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function assertContained(root: string, candidate: string, label: string): void {
const rel = relative(resolve(root), resolve(candidate));
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new AuthBundleError('unsafe-shape', `${label} resolves outside ${root}: ${candidate}`);
}
}
/** Reject a name before it is ever joined onto a path. */
export function assertSafeBundleName(bundle: string): void {
if (!BUNDLE_NAME.test(bundle)) {
throw new AuthBundleError(
'invalid-request',
`"${bundle}" is not a safe bundle name; use letters, digits, and . _ @ -`,
);
}
}
/** `~/.mosaic/auth/<harness>`. */
export function authRoot(userHome: string, harness: CredentialHarness): string {
return join(userHome, 'auth', harness);
}
/**
* Create the auth root chain with owner-only permissions, refusing anything that is not a
* real directory. An explicit mode on mkdir is not enough on its own -- it is masked by the
* ambient umask -- so each level is chmod'ed after creation.
*/
function ensurePrivateDirectory(path: string, label: string): boolean {
const info = lstatIfPresent(path);
if (info) {
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new AuthBundleError(
'unsafe-shape',
`${label} must be a real, non-symlink directory: ${path}`,
);
}
if ((info.mode & 0o077) !== 0) chmodSync(path, 0o700);
return false;
}
mkdirSync(path, { recursive: true, mode: 0o700 });
chmodSync(path, 0o700);
return true;
}
function readJson(path: string): Record<string, unknown> | undefined {
const info = lstatIfPresent(path);
if (!info?.isFile() || info.isSymbolicLink()) return undefined;
try {
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
} catch {
return undefined;
}
}
function dig(source: Record<string, unknown>, dotted: string): string | undefined {
let cursor: unknown = source;
for (const key of dotted.split('.')) {
if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor)) return undefined;
cursor = (cursor as Record<string, unknown>)[key];
}
return typeof cursor === 'string' && cursor.trim() !== '' ? cursor.trim() : undefined;
}
/** Best-effort account identity from whatever the harness wrote into the bundle. */
export function readBundleIdentity(
bundleDir: string,
harness: CredentialHarness,
): string | undefined {
const recorded = readJson(join(bundleDir, 'account.json'));
if (recorded) {
for (const path of ['emailAddress', 'email', 'oauthAccount.emailAddress']) {
const found = dig(recorded, path);
if (found) return found;
}
}
for (const [file, paths] of IDENTITY_SOURCES[harness]) {
const source = readJson(join(bundleDir, file));
if (!source) continue;
for (const path of paths) {
const found = dig(source, path);
if (found) return found;
}
}
return undefined;
}
/**
* The bundle name an email implies. Bundles are named by account identity so that a roster
* row's `"bundle"` says who the seat is, not merely which slot it uses.
*/
export function bundleNameForEmail(email: string): string {
return email.trim().toLowerCase().replace(/@/gu, '_');
}
/**
* Create the bundle directory and describe the environment its login must run under.
*
* This deliberately stops short of running anything. The caller runs the harness's own login
* under `plan.env`, then calls completeEnrollment() to check what landed.
*/
export function prepareEnrollment(
userHome: string,
harness: CredentialHarness,
bundle: string,
): EnrollmentPlan {
assertSafeBundleName(bundle);
if (bundle === PRIMARY_ALIAS) {
throw new AuthBundleError(
'invalid-request',
`"${PRIMARY_ALIAS}" is a movable alias, not a bundle. Enroll a bundle named for the account (for example: mosaic auth enroll --harness ${harness} --bundle jason_woltje.com), then point the alias at it with: mosaic auth default --harness ${harness} <bundle>`,
);
}
ensurePrivateDirectory(userHome, 'user Mosaic root');
ensurePrivateDirectory(join(userHome, 'auth'), 'auth directory');
const root = authRoot(userHome, harness);
ensurePrivateDirectory(root, `${harness} auth root`);
const bundleDir = join(root, bundle);
assertContained(realpathSync(root), resolve(bundleDir), 'credential bundle');
const created = ensurePrivateDirectory(bundleDir, 'credential bundle');
const credentialPath = join(bundleDir, CREDENTIAL_FILE_NAMES[harness]);
const credentialDirEnvName = CREDENTIAL_DIR_ENV[harness];
return {
harness,
bundle,
bundleDir,
credentialPath,
created,
hadCredential: lstatIfPresent(credentialPath)?.isFile() === true,
env: {
[HOME_ENV_NAME[harness]]: bundleDir,
...(credentialDirEnvName === undefined ? {} : { [credentialDirEnvName]: bundleDir }),
},
};
}
/**
* Check what the harness login actually left behind, tighten it, and record the identity.
*
* A login that exits zero having written nothing is the failure worth catching here: the seat
* would then fail much later, at composition, with a message about a missing credential and no
* hint that the login was the thing that did not work.
*/
export function completeEnrollment(plan: EnrollmentPlan): EnrollmentResult {
const info = lstatIfPresent(plan.credentialPath);
if (!info?.isFile() || info.isSymbolicLink()) {
throw new AuthBundleError(
'credential-missing',
`login left no credential at ${plan.credentialPath}. The bundle directory exists but is not enrolled; nothing was assigned.`,
);
}
let tightened = false;
if ((info.mode & 0o077) !== 0) {
chmodSync(plan.credentialPath, 0o600);
tightened = true;
}
const email = readBundleIdentity(plan.bundleDir, plan.harness);
if (email !== undefined) {
writeFileSync(
join(plan.bundleDir, 'account.json'),
`${JSON.stringify({ emailAddress: email, harness: plan.harness }, null, 2)}\n`,
{ mode: 0o600 },
);
chmodSync(join(plan.bundleDir, 'account.json'), 0o600);
}
const expected = email === undefined ? undefined : bundleNameForEmail(email);
return {
harness: plan.harness,
bundle: plan.bundle,
bundleDir: plan.bundleDir,
credentialPath: plan.credentialPath,
...(email === undefined ? {} : { email }),
...(expected === undefined || expected === plan.bundle.toLowerCase()
? {}
: { identityMismatch: expected }),
tightened,
};
}
/** Every entry in a harness's auth root, alias included, with enrollment state. */
export function listBundles(userHome: string, harness: CredentialHarness): BundleInfo[] {
const root = authRoot(userHome, harness);
const info = lstatIfPresent(root);
if (!info) return [];
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new AuthBundleError(
'unsafe-shape',
`${harness} auth root must be a real, non-symlink directory: ${root}`,
);
}
const entries: BundleInfo[] = [];
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
)) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const path = join(root, entry.name);
let resolved: string;
try {
resolved = realpathSync(path);
} catch {
// A dangling alias is real state worth showing rather than a reason to fail the listing.
entries.push({ name: entry.name, path, resolved: path, alias: true, enrolled: false });
continue;
}
const alias = entry.isSymbolicLink();
const credential = join(resolved, CREDENTIAL_FILE_NAMES[harness]);
const email = readBundleIdentity(resolved, harness);
entries.push({
name: entry.name,
path,
resolved,
alias,
...(alias ? { target: resolved.slice(resolved.lastIndexOf(sep) + 1) } : {}),
enrolled: lstatIfPresent(credential)?.isFile() === true,
...(email === undefined ? {} : { email }),
});
}
return entries;
}
/**
* Point the movable `primary` alias at a real bundle.
*
* Relative so the whole `~/.mosaic` tree stays relocatable, and replaced rather than followed
* so retargeting never writes through into the old bundle.
*/
export function setDefaultBundle(
userHome: string,
harness: CredentialHarness,
bundle: string,
): string {
assertSafeBundleName(bundle);
if (bundle === PRIMARY_ALIAS) {
throw new AuthBundleError('invalid-request', `the ${PRIMARY_ALIAS} alias cannot target itself`);
}
const root = authRoot(userHome, harness);
const target = join(root, bundle);
const info = lstatIfPresent(target);
if (!info) {
throw new AuthBundleError(
'bundle-not-found',
`no such bundle: ${target} — enroll it first: mosaic auth enroll --harness ${harness} --bundle ${bundle}`,
);
}
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new AuthBundleError(
'unsafe-shape',
`the ${PRIMARY_ALIAS} alias may only target a real bundle directory: ${target}`,
);
}
const alias = join(root, PRIMARY_ALIAS);
const existing = lstatIfPresent(alias);
if (existing && !existing.isSymbolicLink()) {
throw new AuthBundleError(
'unsafe-shape',
`a real directory occupies the ${PRIMARY_ALIAS} alias path and will not be deleted: ${alias}. Move it aside, or enroll under its own name.`,
);
}
if (existing) rmSync(alias);
symlinkSync(bundle, alias);
return alias;
}
@@ -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);
}
@@ -253,21 +292,24 @@ function onboardingState(mosaicHome: string): Record<string, unknown> {
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
);
}
if (
typeof authored !== 'object' ||
authored === null ||
Array.isArray(authored) ||
!('mcpServers' in authored) ||
typeof authored.mcpServers !== 'object' ||
authored.mcpServers === null ||
Array.isArray(authored.mcpServers)
) {
if (typeof authored !== 'object' || authored === null || Array.isArray(authored)) {
throw new FleetAgentScaffoldError(
'invalid-request',
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
`canonical Claude settings must be a JSON object: ${settingsPath}`,
);
}
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
// The shipped settings.json has no mcpServers key at all, so demanding one refused to
// scaffold any Claude seat on a clean install. Absent and empty mean the same thing here:
// no MCP servers. A present-but-wrong-typed key is still an error -- that is a real
// mistake in the file rather than a section the author had nothing to put in.
const servers = 'mcpServers' in authored ? authored.mcpServers : {};
if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) {
throw new FleetAgentScaffoldError(
'invalid-request',
`canonical Claude settings have a non-object mcpServers: ${settingsPath}`,
);
}
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: servers };
}
function soul(name: string): string {