feat(launch): isolate harness homes and record immutable launch provenance
Mosaic wrote into the operator's harness base installs — ~/.claude, ~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a 102-symlink skill farm per harness. Any experiment with hooks or gating therefore mutated the operator's own tooling, and a broken framework change could take out the very harness needed to repair it. Harness home isolation ---------------------- Each runtime now reads config from a dedicated mosaic-owned home via the harness's own config-dir variable: claude CLAUDE_CONFIG_DIR ~/.config/mosaic/.claude pi PI_CODING_AGENT_DIR ~/.config/mosaic/.pi (replaces ~/.pi/agent) codex CODEX_HOME ~/.config/mosaic/.codex opencode XDG_CONFIG_HOME ~/.config/mosaic/.opencode These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator ownership and a keep-mode upgrade can neither overwrite nor prune them. A bare `claude` / `pi` keeps its own config AND auth, making it a structural break-glass rather than one depending on restoring a file under pressure. opencode is blunter than the rest: it has no dedicated variable and follows XDG, so isolation also relocates XDG lookups for anything it spawns. Documented in place. mosaic-sync-skills now links into those homes and cleans the legacy farms it previously planted in base installs. Ownership is proven by RESOLUTION, not by name — only symlinks resolving inside the canonical/local skills dirs are removed, mirroring the refusal already in commands/skill.js. Verified against a real install: codex's own .system directory survived while its 102 mosaic links were removed. Both resolution prefixes are length-checked first; an empty prefix would make "$resolved" == "$prefix/"* match every absolute path and delete foreign symlinks. Immutable launch record ----------------------- Every launch now appends one record to fleet/run/sessions/events.ndjson before exec. Mandatory, mechanical, no model involvement. pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the launch evidence — that has already produced a confident wrong diagnosis ("this agent bypassed the launcher"), disproved only by the parent's argv and only because the parent had not yet exited. A record written before exec is the only place this survives. The path is the #797 Runtime Session Ledger, already operator-classified and already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a populated ledger survives keep-mode upgrades — but nothing shipped ever wrote it. This implements it in the shape that guard already asserts (0600 files under a 0700 dir). `mosaic` writes session.launch; launch-runtime.py appends lease.register with the broker session id and activation capability. They correlate by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a child with a different pid. Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/ TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is mechanically detectable rather than a matter of judgement. Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over 256 bytes become a sha256 + length rather than being inlined. Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED. resolveTool() uses that same failing specifier, which is why its documented preference for bundled tools over the deployed ~/.config/mosaic copy has never once applied — noted in place, not fixed here. Verified on sb-it-1-dt: isolated homes written and base installs byte-identical for all four harnesses; 408 legacy symlinks removed with 1 foreign entry preserved; launch records paired across the spawn boundary. typecheck shows zero errors in launch.ts (the @mosaicstack/types failures are pre-existing and reproduce on a pristine origin/main worktree).
This commit is contained in:
@@ -153,7 +153,24 @@ if [[ $link_only -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
|
||||||
|
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
|
||||||
|
# commands/launch.js):
|
||||||
|
# claude CLAUDE_CONFIG_DIR -> <home>/skills
|
||||||
|
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
||||||
|
# codex CODEX_HOME -> <home>/skills
|
||||||
|
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
|
||||||
link_targets=(
|
link_targets=(
|
||||||
|
"$MOSAIC_HOME/.claude/skills"
|
||||||
|
"$MOSAIC_HOME/.codex/skills"
|
||||||
|
"$MOSAIC_HOME/.opencode/opencode/skills"
|
||||||
|
"$MOSAIC_HOME/.pi/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pre-isolation installs planted the same symlink farm directly in the operator's
|
||||||
|
# base installs. Those are now orphaned: the launcher no longer reads them, but
|
||||||
|
# they persist and make a "clean" base install look mosaic-managed.
|
||||||
|
legacy_link_targets=(
|
||||||
"$HOME/.claude/skills"
|
"$HOME/.claude/skills"
|
||||||
"$HOME/.codex/skills"
|
"$HOME/.codex/skills"
|
||||||
"$HOME/.config/opencode/skills"
|
"$HOME/.config/opencode/skills"
|
||||||
@@ -252,6 +269,60 @@ prune_stale_links_in_target() {
|
|||||||
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
|
||||||
|
#
|
||||||
|
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
|
||||||
|
# canonical or local skills dirs are removed. Anything else — a real directory, a
|
||||||
|
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
|
||||||
|
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
|
||||||
|
# directory are managed") and preserves e.g. codex's own `.system` dir.
|
||||||
|
#
|
||||||
|
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
|
||||||
|
# missing, and an empty dir is the correct end state, not an absent one.
|
||||||
|
cleanup_legacy_target() {
|
||||||
|
local target_dir="$1"
|
||||||
|
local removed=0 kept=0
|
||||||
|
|
||||||
|
[[ -d "$target_dir" ]] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' link_path; do
|
||||||
|
local resolved owned=0
|
||||||
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
|
||||||
|
# match every absolute path and delete foreign links.
|
||||||
|
if [[ -n "$resolved" ]]; then
|
||||||
|
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
|
owned=1
|
||||||
|
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
|
||||||
|
owned=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $owned -eq 1 ]]; then
|
||||||
|
rm -f "$link_path"
|
||||||
|
removed=$((removed + 1))
|
||||||
|
else
|
||||||
|
kept=$((kept + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
|
||||||
|
|
||||||
|
if [[ $removed -gt 0 ]]; then
|
||||||
|
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for legacy in "${legacy_link_targets[@]}"; do
|
||||||
|
# Skip anything that is also a current target, so isolation can never
|
||||||
|
# self-destruct if the two lists ever overlap.
|
||||||
|
skip=0
|
||||||
|
for target in "${link_targets[@]}"; do
|
||||||
|
[[ "$legacy" == "$target" ]] && skip=1
|
||||||
|
done
|
||||||
|
[[ $skip -eq 1 ]] && continue
|
||||||
|
cleanup_legacy_target "$legacy"
|
||||||
|
done
|
||||||
|
|
||||||
for target in "${link_targets[@]}"; do
|
for target in "${link_targets[@]}"; do
|
||||||
mkdir -p "$target"
|
mkdir -p "$target"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -53,6 +55,48 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _self_starttime() -> str | None:
|
||||||
|
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
|
||||||
|
|
||||||
|
Read past the comm field's parens, since a process name may contain them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
|
||||||
|
return raw.rsplit(")", 1)[1].split()[19]
|
||||||
|
except (OSError, IndexError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
|
||||||
|
"""Append one NDJSON event to the #797 Runtime Session Ledger.
|
||||||
|
|
||||||
|
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
|
||||||
|
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
|
||||||
|
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
|
||||||
|
asserts.
|
||||||
|
|
||||||
|
Never raises: a launch must not be denied over bookkeeping. But it also never
|
||||||
|
fails silently — a missing record is exactly the kind of gap that made the
|
||||||
|
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
|
||||||
|
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(directory, 0o700)
|
||||||
|
framed = {
|
||||||
|
"seq": time.time_ns() // 1_000_000,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
**record,
|
||||||
|
}
|
||||||
|
path = directory / "events.ndjson"
|
||||||
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w") as handle:
|
||||||
|
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
|
||||||
|
except (OSError, ValueError, TypeError) as error:
|
||||||
|
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(
|
||||||
argv: Sequence[str] | None = None,
|
argv: Sequence[str] | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -94,8 +138,9 @@ def main(
|
|||||||
# silent pass and never folded into the generic registration-failure
|
# silent pass and never folded into the generic registration-failure
|
||||||
# branch.
|
# branch.
|
||||||
try:
|
try:
|
||||||
|
activation_capability = probe_activation_capability(source_environment)
|
||||||
assert_activation_capability_matches(
|
assert_activation_capability_matches(
|
||||||
probe_activation_capability(source_environment),
|
activation_capability,
|
||||||
expected_activation_capability,
|
expected_activation_capability,
|
||||||
)
|
)
|
||||||
except VersionCouplingError as version_error:
|
except VersionCouplingError as version_error:
|
||||||
@@ -128,6 +173,32 @@ def main(
|
|||||||
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
|
||||||
|
# config/provenance it knows; only this process knows the broker session id
|
||||||
|
# and the activation capability it just asserted. os.execvpe preserves the
|
||||||
|
# PID, so this PID is BOTH the anchor pid and the join key back to that
|
||||||
|
# record. Never fatal — bookkeeping must not deny a launch — but never
|
||||||
|
# silent either.
|
||||||
|
_append_launch_record(
|
||||||
|
source_environment,
|
||||||
|
{
|
||||||
|
"kind": "lease.register",
|
||||||
|
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
|
||||||
|
# spawns rather than execs, so this process is a CHILD of mosaic with a
|
||||||
|
# different pid. This pid IS the broker anchor pid (os.execvpe below
|
||||||
|
# preserves it), which is a separate and still-useful fact.
|
||||||
|
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"runtime": arguments.runtime,
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"generation_file": str(generation_file),
|
||||||
|
"anchor_starttime": _self_starttime(),
|
||||||
|
"activation_capability": activation_capability,
|
||||||
|
"command": Path(command[0]).name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
environment = dict(source_environment)
|
environment = dict(source_environment)
|
||||||
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
|
||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
|
appendFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +44,163 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
|||||||
pi: 'Pi',
|
pi: 'Pi',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Harness home isolation ──────────────────────────────────────────────────
|
||||||
|
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
|
||||||
|
// tree — never the operator's base install. A bare `claude` / `pi` therefore
|
||||||
|
// keeps its own config AND its own auth, and stays a working break-glass no
|
||||||
|
// matter what mosaic does to its own tree.
|
||||||
|
//
|
||||||
|
// These paths are manifest-UNKNOWN, which resolves to operator ownership
|
||||||
|
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
|
||||||
|
// neither overwrite nor prune them. Overwrite-mode install still would.
|
||||||
|
//
|
||||||
|
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
|
||||||
|
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
|
||||||
|
// three: it also relocates XDG lookups for anything opencode spawns.
|
||||||
|
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
||||||
|
claude: 'CLAUDE_CONFIG_DIR',
|
||||||
|
pi: 'PI_CODING_AGENT_DIR',
|
||||||
|
codex: 'CODEX_HOME',
|
||||||
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||||
|
function harnessHome(runtime: RuntimeName): string {
|
||||||
|
return join(MOSAIC_HOME, `.${runtime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
||||||
|
* created on demand so a first launch does not fail on a missing path.
|
||||||
|
*/
|
||||||
|
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
||||||
|
const key = HARNESS_HOME_ENV[runtime];
|
||||||
|
if (!key) return {};
|
||||||
|
const home = harnessHome(runtime);
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
return { [key]: home };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launch record (immutable provenance) ────────────────────────────────────
|
||||||
|
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
|
||||||
|
// actually launched with, written before exec. No model involvement, no opt-out.
|
||||||
|
//
|
||||||
|
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
|
||||||
|
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
|
||||||
|
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
|
||||||
|
// disproved only by the parent process's argv and only because the parent had
|
||||||
|
// not yet exited. A record written before exec is the only place this survives.
|
||||||
|
//
|
||||||
|
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
|
||||||
|
// operator-classified in framework-manifest.txt and already covered by
|
||||||
|
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
|
||||||
|
// it.
|
||||||
|
//
|
||||||
|
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
|
||||||
|
// uses spawnSync, so the runtime is a CHILD with a different pid.
|
||||||
|
// launch-runtime.py appends the matching `lease.register` event.
|
||||||
|
//
|
||||||
|
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
|
||||||
|
// oversized argv values (the composed system prompt) become a digest + length.
|
||||||
|
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
|
||||||
|
|
||||||
|
const CLI_VERSION: string | null = (() => {
|
||||||
|
try {
|
||||||
|
// Resolved RELATIVELY: the package `exports` map does not expose
|
||||||
|
// package.json, so '@mosaicstack/mosaic/package.json' throws
|
||||||
|
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
|
||||||
|
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
interface NormativeFragmentDigest {
|
||||||
|
source_id: string;
|
||||||
|
sha256: string | null;
|
||||||
|
bytes: number | null;
|
||||||
|
missing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(value: string | Buffer): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the normative sources injected into the agent. This is "what the agent
|
||||||
|
* IS" — and it is the same fragment set the lease broker hashes for promotion,
|
||||||
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||||
|
* a matter of judgement.
|
||||||
|
*/
|
||||||
|
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
||||||
|
const candidates: Array<[string, string]> = [
|
||||||
|
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
||||||
|
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
||||||
|
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
||||||
|
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
||||||
|
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
||||||
|
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
||||||
|
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
||||||
|
];
|
||||||
|
return candidates.map(([sourceId, path]) => {
|
||||||
|
try {
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
|
||||||
|
} catch {
|
||||||
|
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** argv with oversized values replaced by a digest, so the record stays small
|
||||||
|
* and never inlines injected content verbatim. */
|
||||||
|
function redactArgv(argv: string[]): string[] {
|
||||||
|
return argv.map((a) =>
|
||||||
|
typeof a === 'string' && a.length > 256
|
||||||
|
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
|
||||||
|
: a,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
|
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
||||||
|
const record = {
|
||||||
|
seq: Date.now(),
|
||||||
|
kind: 'session.launch',
|
||||||
|
launch_id: launchId,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
host: hostname(),
|
||||||
|
pid: process.pid,
|
||||||
|
runtime,
|
||||||
|
mode: yolo ? 'yolo' : 'normal',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
cli_version: CLI_VERSION,
|
||||||
|
config_home: harnessHome(runtime),
|
||||||
|
config_home_isolated: true,
|
||||||
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
|
argv: redactArgv(cliArgs),
|
||||||
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
|
// names only — values are never recorded
|
||||||
|
mosaic_env_present: Object.keys(process.env)
|
||||||
|
.filter((k) => k.startsWith('MOSAIC_'))
|
||||||
|
.sort(),
|
||||||
|
};
|
||||||
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Never block a launch on bookkeeping — but never fail silently either.
|
||||||
|
console.error(
|
||||||
|
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function checkMosaicHome(): void {
|
function checkMosaicHome(): void {
|
||||||
@@ -105,11 +264,11 @@ interface SettingsAudit {
|
|||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
|
||||||
return { warnings };
|
return { warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +720,9 @@ function skillRealPath(dir: string): string {
|
|||||||
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
|
||||||
* skills dir and the project-local one relative to the launch cwd. */
|
* skills dir and the project-local one relative to the launch cwd. */
|
||||||
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
|
||||||
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
|
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
|
||||||
|
// <home>/skills — there is no extra 'agent' segment under the isolated home.
|
||||||
|
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
|
||||||
@@ -764,12 +925,13 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
|
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
||||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -777,14 +939,17 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('codex', cliArgs);
|
recordLaunch('codex', cliArgs, yolo);
|
||||||
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
execRuntime('opencode', args);
|
recordLaunch('opencode', args, yolo);
|
||||||
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,6 +964,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
|
recordLaunch('pi', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -835,6 +1001,7 @@ function execLeaseGatedRuntime(
|
|||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
|
...harnessEnv(runtime),
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user