feat(bundle): opt in to service archives with durable targets (#1474)

An opted-in v2 service bundle can now carry its declared Compose file, show the archived per-service declarations before anything runs, and install into a lasting `--target` through the existing service boot, readiness and seat-launch machinery.

It is off by default. `bundle.services_enabled` (or `OPENRIG_BUNDLE_SERVICES_ENABLED`) gates compose-file packing, the per-service view, target admission and prospective target plans; the daemon reads it on each request. With it off, archive contents, the inspect view and the services refusal are as before, except that the new typed key appears, default `false`, in the config list and help.

With it on:
- Create copies only the declared Compose file, at its relative path, through the existing containment rules; no `.env`, no recursive copy. A missing declared file still creates the archive and shows as unresolved.
- Inspect shows each service's image, declared ports and privileges, environment names (never values), mounts, health and wait targets and down policy, with YAML merge keys resolved, host values left unresolved, and every other declared key listed as "Also declared, not summarized". It describes the archive, not an effective deployment.
- Apply with a target uses the existing materialisation, Compose boot and readiness, and content routing before the first seat launches. The archive path keeps the full service result and the replaced generations, so rollback, recovery retention and inherited project protection behave as on the folder path.
- Plan with a target runs the existing conflict and live-team checks and shows the prospective services without writing target files or starting services; a plan over a running team of the same name is refused, as apply is. Without a target, plan and apply keep the services refusal.

Native archive-to-Vault proof on a real Docker host is not run; coverage uses real archives, routes and SQLite with inert service and seat adapters.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-18 14:06:51 -07:00
1 parent 042e81358b
commit 421b90180e
17 files changed
+692 -25

No files matched your search

@@ -212,7 +212,11 @@
"description": "Host settings (for example OPENRIG_YOLO, persisted or native settings) decide the effective permission at launch."
},
"permissionPrompts": {
"enum": ["off", "on", "default"],
"enum": [
"off",
"on",
"default"
],
"description": "Archive declaration per seat: off for full_bypass (including builtin:yolo) on any runtime; on for a Claude/Codex flag-surface floor policy, or Codex under auto; default for Claude/Codex with no declared policy. Omitted for Claude under auto, config-surface policies, host-resolved Codex profiles, and Pi except declared full bypass. Absent means not stated, never on. Host settings may differ; nativeEffect remains unknown."
},
"sourceRefs": {
@@ -417,7 +421,10 @@
"commands": {
"type": "array",
"description": "Unexecuted author setup lines, in order in one shell; the install command follows in the resulting working directory.",
"items": { "type": "string", "minLength": 1 }
"items": {
"type": "string",
"minLength": 1
}
},
"status": {
"const": "not_checked"
@@ -459,6 +466,102 @@
}
}
}
},
"services": {
"type": "array",
"description": "Optional opt-in service declarations from archived Compose YAML. Host-resolved values remain unresolved; no Compose or health probe ran.",
"items": {
"type": "object",
"required": [
"name",
"composeFile",
"image",
"ports",
"privileged",
"capAdd",
"securityOpt",
"devices",
"environment",
"mounts",
"healthcheck",
"waitFor",
"downPolicy",
"unresolved",
"notes"
],
"properties": {
"name": {
"type": "string"
},
"composeFile": {
"type": "string"
},
"image": {
"type": "string"
},
"ports": {
"type": "array",
"items": {
"type": "string"
}
},
"privileged": {
"type": [
"boolean",
"string"
]
},
"capAdd": {
"type": "array",
"items": {
"type": "string"
}
},
"securityOpt": {
"type": "array",
"items": {
"type": "string"
}
},
"devices": {
"type": "array",
"items": {
"type": "string"
}
},
"environment": {
"type": "array",
"items": {
"type": "string"
}
},
"mounts": {
"type": "array",
"items": {
"type": "string"
}
},
"healthcheck": {},
"waitFor": {
"type": "array"
},
"downPolicy": {
"type": "string"
},
"unresolved": {
"type": "array",
"items": {
"type": "string"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"allOf": [
+17
View File
@@ -29,6 +29,23 @@ export function formatBundleBehaviour(view: BundleBehaviour): string[] {
if (view.posture.some(item => item.nonInterruptive === "available" && item.nonInterruptiveDefault !== true)) lines.push("Non-interruptive mode is available using rig bundle install <archive-or-link> --non-interruptive for the declared full-bypass Claude/Codex seats. This view does not select the mode or check whether you've already accepted the warning on this machine; login and other harness preconditions still apply.");
lines.push("Told files:", ...view.toldFiles.map(f => ` ${f.seat ? plain(f.seat) + ": " : ""}${plain(f.pathOrRef)} [${f.resolution}${f.delivery ? `; ${plain(f.delivery)}` : ""}]`));
lines.push("Also declared to run:", ...view.alsoRuns.map(f => ` ${f.seat ? plain(f.seat) + ": " : ""}${plain(f.kind)}: ${plain(f.pathOrRef)} [${f.resolution}${f.trigger ? `; ${plain(f.trigger)}` : ""}]`));
if (view.services) {
lines.push("Service declarations from the archive (not effective host configuration):");
for (const service of view.services) {
lines.push(` ${plain(service.name)} (${plain(service.composeFile)}): image ${plain(service.image)}`);
lines.push(` Published ports: ${service.ports.map(plain).join(", ") || "none declared"}`);
lines.push(` Added privileges: privileged=${plain(service.privileged)}, cap_add=${service.capAdd.map(plain).join(", ") || "none"}, security_opt=${service.securityOpt.map(plain).join(", ") || "none"}, devices=${service.devices.map(plain).join(", ") || "none"}`);
lines.push(` Environment names: ${service.environment.map(plain).join(", ") || "none declared"}`);
lines.push(` Mounts: ${service.mounts.map(plain).join(", ") || "none declared"}`);
lines.push(` Healthcheck: ${plain(JSON.stringify(service.healthcheck))}; rig wait_for: ${plain(JSON.stringify(service.waitFor))}`);
const downEffect = service.downPolicy === "leave_running" ? "leaves the Compose project running"
: service.downPolicy === "down_and_volumes" ? "stops the Compose project and removes its volumes"
: service.downPolicy === "down" ? "stops the Compose project" : "effect unresolved";
lines.push(` rig down policy: ${plain(service.downPolicy)} (${downEffect}; a live successor can retain the project)`);
if (service.unresolved.length) lines.push(` Unresolved on the installing host: ${service.unresolved.map(plain).join(", ")}`);
for (const note of service.notes) lines.push(` ${plain(note)}`);
}
}
lines.push("Writes and library additions (destinations, runtime support and conflicts resolved at launch):", ...view.writes.map(w => ` ${w.seat ? plain(w.seat) + ": " : ""}${plain(w.destinationBase)}/${plain(w.path)} — ${plain(w.operation)}, ${plain(w.phase)}`));
lines.push(`Outside domains found in files (not predicted traffic): ${view.outsideAddresses.map(a => plain(a.domain)).join(", ") || "none found"}`);
lines.push("Needs (not checked):");
+1
View File
@@ -103,6 +103,7 @@ Keys:
terminal.status_bar show the inner tmux status bar on launch (default off)
output.compact short default output for queue writes, queue show, queue transitions and whoami peers; --full restores today's output (default off)
output.whoami_peers with output.compact on, how many peers rig whoami --json lists, edge peers first; --all-peers lists all (default 8)
bundle.services_enabled Service-backed bundle packing, preview and target installs (default off)
provider.usage_windows Codex and Claude usage windows in rig provider status (Codex read on request; Claude from its seats' status lines) (default off)
provider.subscription_moves the subscription move record: rig provider moves, moves show and moves record, and their routes (default off)
usage.pi_cost_facts Pi seats report model-priced cost from their persisted session entries to rig usage cost (default off)
+7
View File
@@ -179,6 +179,7 @@ export interface RiggedConfig {
// the CLI key is the lockstep twin.
subscriptionMoves: boolean;
};
bundle: { servicesEnabled: boolean };
// Slice 02 (OPR.0.7.0.2) — Pi seats report model-priced cost from persisted session entries
// (default off). Read by the daemon at Pi launch; the CLI key is the lockstep twin.
usage: {
@@ -350,6 +351,7 @@ const DEFAULTS = {
// Slice 18 — the subscription move record OFF until the switch is on.
subscriptionMoves: false,
},
bundle: { servicesEnabled: false },
// Slice 02 — Pi cost facts OFF until the switch is on.
usage: {
piCostFacts: false,
@@ -477,6 +479,8 @@ export const VALID_KEYS = [
"output.whoami_peers",
// Slice 01 — Codex and Claude usage windows in the provider view; lockstep with the daemon twin.
"provider.usage_windows",
// Service-backed archives, including packing, preview and durable-target admission; default off.
"bundle.services_enabled",
// Slice 18 — the subscription move record and its reads; lockstep with the daemon twin.
"provider.subscription_moves",
"usage.pi_cost_facts",
@@ -581,6 +585,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
"provider.usage_windows": { primary: "OPENRIG_PROVIDER_USAGE_WINDOWS" },
"bundle.services_enabled": { primary: "OPENRIG_BUNDLE_SERVICES_ENABLED" },
"provider.subscription_moves": { primary: "OPENRIG_PROVIDER_SUBSCRIPTION_MOVES" },
"usage.pi_cost_facts": { primary: "OPENRIG_USAGE_PI_COST_FACTS" },
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
@@ -667,6 +672,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"output.compact": ["output", "compact"],
"output.whoami_peers": ["output", "whoamiPeers"],
"provider.usage_windows": ["provider", "usageWindows"],
"bundle.services_enabled": ["bundle", "servicesEnabled"],
"provider.subscription_moves": ["provider", "subscriptionMoves"],
"usage.pi_cost_facts": ["usage", "piCostFacts"],
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
@@ -1214,6 +1220,7 @@ export class ConfigStore {
usageWindows: v("provider.usage_windows") as boolean,
subscriptionMoves: v("provider.subscription_moves") as boolean,
},
bundle: { servicesEnabled: v("bundle.services_enabled") as boolean },
usage: {
piCostFacts: v("usage.pi_cost_facts") as boolean,
piOpenRouterCharge: v("usage.pi_openrouter_charge") as boolean,
@@ -15,6 +15,29 @@ const view = {
};
describe("before-action bundle view", () => {
it("renders the opt-in service declarations and host-dependent fields before an action", () => {
const files = new Map([
["rig.yaml", "name: demo\nversion: '0.2'\nservices: {kind: compose, compose_file: compose.yaml, down_policy: down}\npods:\n- id: demo\n members:\n - {id: shell, agent_ref: 'builtin:terminal', profile: none, runtime: terminal, cwd: .}\n"],
["compose.yaml", "services:\n vault:\n image: hashicorp/vault:1.15\n cap_add: [IPC_LOCK]\n ports: ['8200:8200']\n environment: {VAULT_DEV_ROOT_TOKEN_ID: openrig-dev-token, VAULT_DEV_LISTEN_ADDRESS: '0.0.0.0:8200'}\n worker:\n network_mode: host\n image: '${IMAGE}'\n volumes: ['${DATA_DIR}:/data']\n"],
]);
const described = describeBundleBehaviour({ files, manifest: { schema_version: 2, rig_spec: "rig.yaml" }, generator: { openrigVersion: "test" }, digestValid: true, filesVerified: true, servicesEnabled: true });
const output = formatBundleBehaviour(described).join("\n");
expect(output).toContain("image hashicorp/vault:1.15");
expect(output).toContain("Published ports: 8200:8200");
expect(output).toContain("cap_add=IPC_LOCK");
expect(output).toContain("public dev token openrig-dev-token");
expect(output).toContain("all interfaces at port 8200");
expect(output).toContain("rig down policy: down");
expect(output).toContain("Unresolved on the installing host: image, mounts");
expect(output).toContain("not effective host configuration");
expect(output).toContain("Also declared, not summarized: network_mode");
const ajv = new Ajv2020({ strict: false, allErrors: true });
for (const name of ["bundle-common.v1.schema.json", "bundle-behaviour.v1.schema.json"]) {
ajv.addSchema(JSON.parse(fs.readFileSync(new URL(`../../../docs/reference/schemas/${name}`, import.meta.url), "utf8")));
}
const validate = ajv.getSchema("https://openrig.dev/schemas/bundle-behaviour.v1.json")!;
expect(validate(described), JSON.stringify(validate.errors)).toBe(true);
});
it("prints before the caller's action without writing stdout or a new exit code", async () => {
const events: string[] = [];
const before = process.exitCode;
@@ -177,6 +177,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"output.whoami_peers",
// Slice 01 (OPR.0.7.0.1) — Codex usage windows switch.
"provider.usage_windows",
"bundle.services_enabled",
// Slice 18 (OPR.0.7.0.18) — the subscription move record switch.
"provider.subscription_moves",
"usage.pi_cost_facts",
@@ -29,6 +29,8 @@ import { runtimeVersionProbeCwd } from "../adapters/preflight-exec.js";
import { routeBundleContents, routingFailureWarnings, thrownMessage, type BundleContentRouting } from "./bundle-content-routing.js";
import { bundleInstallContext, bundleInstallContextLines, isExistingBundleTarget, type BundleInstallContext } from "./bundle-install-context.js";
import { confirmStoppedGenerations } from "./running-name-guard.js";
import { inspectBundleBehaviour } from "./bundle-behaviour-inspect.js";
import { getDaemonVersion } from "./daemon-version.js";
/** Bootstrap mode */
export type BootstrapMode = "plan" | "apply";
@@ -185,12 +187,15 @@ export class BootstrapOrchestrator {
try {
const parsedSpec = parsePodBundleManifest(rawYaml) as Record<string, unknown>;
const serviceBundleTarget = Boolean(opts.targetRoot && parsedSpec?.services && typeof parsedSpec.services === "object"
&& new SettingsStore().resolveOne("bundle.services_enabled").value === true);
const planningServices = mode === "plan" && serviceBundleTarget;
const context = bundleInstallContext(this.deps.db, typeof parsedSpec?.name === "string" ? parsedSpec.name : "", {
name: podSource.manifest.name, version: podSource.manifest.version, source: sourceRef,
});
if (context.existing.length > 0) {
const lines = bundleInstallContextLines(context);
if (mode === "apply" && context.existing.some(rig => rig.state === "running")) {
if ((mode === "apply" || planningServices) && context.existing.some(rig => rig.state === "running")) {
const message = [...lines, "No bundle files were written and no team was created or launched."].join("\n");
stages.push({ stage: "import_rig", status: "failed", detail: { code: "rig_name_running", message } });
this.deps.bootstrapRepo.updateRunStatus(run.id, "failed");
@@ -198,10 +203,10 @@ export class BootstrapOrchestrator {
}
warnings.push(...bundleInstallContextLines(context, mode === "plan"));
}
// Reject service-backed bundles — services require a stable source directory
// Service-backed archives still require an opted-in durable target.
try {
const parsed = parsePodBundleManifest(rawYaml) as Record<string, unknown>;
if (parsed && typeof parsed === "object" && parsed["services"] && typeof parsed["services"] === "object") {
if (!serviceBundleTarget && parsed && typeof parsed === "object" && parsed["services"] && typeof parsed["services"] === "object") {
const msg = "Service-backed rigs cannot be launched from .rigbundle archives. The services block requires a stable source directory. Use the source directory path instead: rig up <path/to/rig.yaml>";
stages.push({ stage: "resolve_spec", status: "failed", detail: { code: "services_unsupported", error: msg } });
errors.push(msg);
@@ -213,7 +218,8 @@ export class BootstrapOrchestrator {
// Apply with an explicit target: launch from a copy of the bundle in the
// target, not from the temp extraction removed in the finally below —
// otherwise `cwd: "."`, the spec dir and agent refs point at a deleted dir.
if (opts.mode === "apply" && opts.targetRoot) {
// An opted-in service plan runs the same target checks without copying.
if ((opts.mode === "apply" || planningServices) && opts.targetRoot) {
const targetRoot = nodePath.resolve(opts.targetRoot);
const replacingOwnTarget = isExistingBundleTarget(context, targetRoot, this.deps.db);
const tmux = this.deps.podInstantiator?.["deps"]?.tmuxAdapter;
@@ -228,7 +234,7 @@ export class BootstrapOrchestrator {
return { runId: run.id, status: "failed", stages, errors: [message], warnings: [...warnings, ...context.resolutions], bundleInstall: context };
}
}
const materialized = materializePodBundle(podSource.tempDir, targetRoot, replacingOwnTarget);
const materialized = materializePodBundle(podSource.tempDir, targetRoot, replacingOwnTarget, planningServices);
if (!materialized.ok) {
const shown = materialized.conflicts.slice(0, 10).join(", ");
const more = materialized.conflicts.length > 10 ? ` (and ${materialized.conflicts.length - 10} more)` : "";
@@ -238,10 +244,23 @@ export class BootstrapOrchestrator {
this.deps.bootstrapRepo.updateRunStatus(run.id, "failed");
return { runId: run.id, status: "failed" as BootstrapStatus, stages, errors, warnings };
}
if (materialized.backupPath) warnings.push(`Existing target files were preserved at ${materialized.backupPath} before installing the replacement. Unrelated target files were kept.`);
installRoot = fs.realpathSync.native(targetRoot);
specDir = nodePath.dirname(nodePath.join(targetRoot, podSource.manifest.rigSpec));
warnings.push(`Bundle files are installed in ${targetRoot}; a later launch failure does not remove them.`);
if (planningServices) {
const behaviour = inspectBundleBehaviour(podSource.tempDir, {
manifest: { schema_version: 2, rig_spec: podSource.manifest.rigSpec },
generator: { openrigVersion: getDaemonVersion() }, digestValid: true, filesVerified: true, servicesEnabled: true,
});
stages.push({ stage: "services_plan", status: "ok", detail: {
targetRoot, materialised: false,
start: "Services start only after the target is materialised, through the existing boot and health gate.",
...(behaviour.state === "generated" ? { services: behaviour.services, unknownBeforeLaunch: behaviour.unknownBeforeLaunch } : { viewUnavailable: behaviour.reason }),
} });
warnings.push(`Plan only: no bundle files were written to ${targetRoot} and no services were started.`);
} else {
if (materialized.backupPath) warnings.push(`Existing target files were preserved at ${materialized.backupPath} before installing the replacement. Unrelated target files were kept.`);
installRoot = fs.realpathSync.native(targetRoot);
specDir = nodePath.dirname(nodePath.join(targetRoot, podSource.manifest.rigSpec));
warnings.push(`Bundle files are installed in ${targetRoot}; a later launch failure does not remove them.`);
}
}
const result = await this.handlePodAwareSpec(opts, run, rawYaml, specDir, stages, errors, warnings, installRoot);
@@ -736,9 +755,10 @@ export class BootstrapOrchestrator {
}
: undefined;
const prelaunchHook = serviceHook && bundleHook
? async (rigId: string) => {
const serviceResult = await serviceHook(rigId);
return serviceResult.ok ? bundleHook() : serviceResult;
? async (...args: Parameters<typeof serviceHook>) => {
const serviceResult = await serviceHook(...args);
if (serviceResult.ok) await bundleHook();
return serviceResult;
}
: serviceHook ?? bundleHook;
let declaredNonInterruptive: boolean | undefined;
+67 -1
View File
@@ -40,6 +40,23 @@ interface WriteFact {
condition?: string;
sourceRefs: SourceRef[];
}
export interface BundleServiceBehaviour {
name: string;
composeFile: string;
image: string;
ports: string[];
privileged: boolean | string;
capAdd: string[];
securityOpt: string[];
devices: string[];
environment: string[];
mounts: string[];
healthcheck: unknown;
waitFor: unknown[];
downPolicy: string;
unresolved: string[];
notes: string[];
}
interface GeneratedBehaviour {
schema: "openrig.bundle-behaviour/v1";
state: "generated";
@@ -52,6 +69,8 @@ interface GeneratedBehaviour {
outsideAddresses: Array<{ domain: string; sourceRefs: SourceRef[] }>;
needs: Array<{ seat?: string; kind: string; name: string; commands?: string[]; versionConstraint?: string; status: "not_checked"; sourceRefs: SourceRef[] }>;
unknownBeforeLaunch: Array<{ seat?: string; subject: string; reason: string; sourceRefs: SourceRef[] }>;
/** Present only when bundle.services_enabled is selected. Declarations, not effective deployment. */
services?: BundleServiceBehaviour[];
}
export type BundleBehaviour = GeneratedBehaviour | {
schema: "openrig.bundle-behaviour/v1";
@@ -72,6 +91,7 @@ export interface DescribeBundleInput {
source?: ObjectValue | null;
configurationId?: string | null;
assembler?: Assembler | null;
servicesEnabled?: boolean;
}
const object = (v: unknown): ObjectValue => v !== null && typeof v === "object" && !Array.isArray(v) ? v as ObjectValue : {};
@@ -310,8 +330,54 @@ export function describeBundleBehaviour(input: DescribeBundleInput): BundleBehav
if (rig.services) {
const services = object(rig.services);
const refs = [{ path: rigPath, field: "services" }];
view.alsoRuns.push({ ...fileFact(undefined, "services", text(services.compose_file) ?? "compose.yaml", rigRoot, refs), trigger: "before agent launch; existing health checks still apply" });
const composeFact = fileFact(undefined, "services", text(services.compose_file) ?? "compose.yaml", rigRoot, refs);
view.alsoRuns.push({ ...composeFact, trigger: "before agent launch; existing health checks still apply" });
view.needs.push({ kind: "tool", name: "Docker Compose and the declared services", status: "not_checked", sourceRefs: refs });
if (input.servicesEnabled) {
const content = composeFact.resolution === "archive" ? files.get(composeFact.pathOrRef) : undefined;
if (content !== undefined) {
try {
const compose = object(parseYaml(content, { merge: true }));
if (!compose.services || Array.isArray(compose.services) || typeof compose.services !== "object") throw new Error("Missing service declarations");
view.services = Object.entries(object(compose.services)).map(([name, value]) => {
const service = object(value);
const declared = (v: unknown): string => typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
const hostDependent = (v: unknown) => /\$(?:[A-Za-z_]|\{)/.test(JSON.stringify(v ?? "").replace(/\$\$/g, ""));
const strings = (v: unknown) => list(v).map(declared);
const env = Array.isArray(service.environment)
? Object.fromEntries(service.environment.filter((v): v is string => typeof v === "string").map(v => {
const equal = v.indexOf("="); return equal < 0 ? [v, null] : [v.slice(0, equal), v.slice(equal + 1)];
}))
: object(service.environment);
const unresolved = new Set<string>();
for (const [field, val] of Object.entries({ image: service.image, ports: service.ports, mounts: service.volumes, healthcheck: service.healthcheck, waitFor: services.wait_for, privileges: [service.privileged, service.cap_add, service.security_opt, service.devices], downPolicy: services.down_policy })) {
if (hostDependent(val)) unresolved.add(field);
}
if (Object.values(env).some(v => v == null || hostDependent(v)) || service.env_file) unresolved.add("environment");
const notes: string[] = [];
const summarized = new Set(["image", "ports", "privileged", "cap_add", "security_opt", "devices", "environment", "volumes", "healthcheck"]);
const otherKeys = Object.keys(service).filter(key => !summarized.has(key)).sort();
if (otherKeys.length) notes.push(`Also declared, not summarized: ${otherKeys.join(", ")}`);
if (service.build) notes.push("Build context is declared; its contents and build effects were not evaluated.");
if (service.env_file) notes.push("Environment files are declared; their contents and effective values are not resolved by this summary.");
if (service.extends || compose.include) notes.push("Additional Compose sources are not resolved; this summary is incomplete.");
if (text(service.image)?.startsWith("hashicorp/vault:") && env.VAULT_DEV_ROOT_TOKEN_ID === "openrig-dev-token") {
notes.push("Vault development demonstration with the public dev token openrig-dev-token; not for real secrets.");
if (env.VAULT_DEV_LISTEN_ADDRESS === "0.0.0.0:8200") notes.push("The dev listener is declared on all interfaces at port 8200; this does not certify the effective host deployment.");
}
return {
name, composeFile: composeFact.pathOrRef, image: text(service.image) ?? "not declared",
ports: strings(service.ports), privileged: typeof service.privileged === "boolean" ? service.privileged : text(service.privileged) ?? (service.privileged == null ? "not declared" : "unresolved"),
capAdd: strings(service.cap_add), securityOpt: strings(service.security_opt), devices: strings(service.devices),
environment: Object.keys(env), mounts: strings(service.volumes), healthcheck: service.healthcheck ?? null,
waitFor: list(services.wait_for), downPolicy: text(services.down_policy) ?? "down", unresolved: [...unresolved], notes,
};
});
} catch {
unknown(composeFact.pathOrRef, "The archived Compose declarations could not be described; no service or host configuration was evaluated.", refs);
}
}
}
for (const hook of list(services.checkpoints)) {
for (const field of ["export_command", "import_command"]) {
const command = text(object(hook)[field]);
@@ -212,6 +212,8 @@ export function materializePodBundle(
extractedDir: string,
targetRoot: string,
preserveConflicts = false,
/** Run the same target checks for a plan, without copying or backing up anything. */
dryRun = false,
): { ok: true; backupPath?: string } | { ok: false; conflicts: string[] } {
const files: string[] = [];
(function walk(dir: string, prefix: string) {
@@ -256,6 +258,7 @@ export function materializePodBundle(
}
}
if (conflicts.size > 0 && !preserveConflicts) return { ok: false, conflicts: [...conflicts] };
if (dryRun) return { ok: true };
let backupPath: string | undefined;
let roots: string[] = [];
@@ -24,6 +24,8 @@ export interface PodAssembleOptions {
outputDir: string;
bundleName: string;
bundleVersion: string;
/** Opt-in from bundle.services_enabled; omitted preserves the existing archive. */
servicesEnabled?: boolean;
/**
* Optional Item-1 provenance input. Caller fills the fields it knows
* (sourceHost, authorSession, daemonVersion, cliVersion, sourceRigId,
@@ -106,6 +108,11 @@ export class PodBundleAssembler {
}
}
if (opts.servicesEnabled && rigSpec.services) {
const composeFile = rigSpec.services.composeFile;
this.collectRigFile(composeFile, opts.rigRoot, opts.outputDir, collectedFiles);
}
// 2c. Rig startup files
this.collectStartupFiles(rigSpec.startup, opts.rigRoot, opts.outputDir, collectedFiles);
@@ -187,6 +187,8 @@ export const SETTINGS_VALID_KEYS = [
"output.whoami_peers",
// Slice 01 (OPR.0.7.0.1) — Codex and Claude usage windows in the provider view; daemon-read, twin of the CLI key.
"provider.usage_windows",
// Service-backed archives, including packing, preview and durable-target admission; default off.
"bundle.services_enabled",
// Slice 18 (OPR.0.7.0.18) — the subscription move record and its reads; default off, twin of the CLI key.
"provider.subscription_moves",
"usage.pi_cost_facts",
@@ -299,6 +301,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
"output.whoami_peers": { primary: "OPENRIG_OUTPUT_WHOAMI_PEERS" },
"provider.usage_windows": { primary: "OPENRIG_PROVIDER_USAGE_WINDOWS" },
"bundle.services_enabled": { primary: "OPENRIG_BUNDLE_SERVICES_ENABLED" },
"provider.subscription_moves": { primary: "OPENRIG_PROVIDER_SUBSCRIPTION_MOVES" },
"usage.pi_cost_facts": { primary: "OPENRIG_USAGE_PI_COST_FACTS" },
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
@@ -384,6 +387,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"output.compact": ["output", "compact"],
"output.whoami_peers": ["output", "whoamiPeers"],
"provider.usage_windows": ["provider", "usageWindows"],
"bundle.services_enabled": ["bundle", "servicesEnabled"],
"provider.subscription_moves": ["provider", "subscriptionMoves"],
"usage.pi_cost_facts": ["usage", "piCostFacts"],
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
@@ -664,6 +668,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "output.compact": return false;
case "output.whoami_peers": return 8;
case "provider.usage_windows": return false;
case "bundle.services_enabled": return false;
// Slice 18 — the subscription move record is off until the switch is on.
case "provider.subscription_moves": return false;
// Slice 02 — Pi cost facts from persisted entries; off until the switch is on.
+3 -2
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { SettingsStore } from "../domain/user-settings/settings-store.js";
import { readSeatEnvOverride } from "../domain/seat-env.js";
import os from "node:os";
import nodePath from "node:path";
@@ -814,7 +815,7 @@ bundleRoutes.post("/create", async (c) => {
const tmpStaging = fs.mkdtempSync(nodePath.join(os.tmpdir(), "pod-bundle-create-"));
try {
const assembler = new PodBundleAssembler({ fsOps: podAssemblerFsOps() });
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance, compatibility });
const result = assembler.assemble({ servicesEnabled: new SettingsStore().resolveOne("bundle.services_enabled").value === true, rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance, compatibility });
// Item 6 / Checkpoint 7.5 (QA-20260601 A2 repair): auto-detect
// author bundle.yaml at the rig source root; vendor declared
@@ -991,7 +992,7 @@ bundleRoutes.post("/inspect", async (c) => {
const generator = { openrigVersion: getDaemonVersion() };
const source = identity.source ? { ...identity.source } : null;
try {
return inspectBundleBehaviour(tmpDir, { manifest: rawParsed, ...identity, source, generator, digestValid, filesVerified });
return inspectBundleBehaviour(tmpDir, { manifest: rawParsed, ...identity, source, generator, digestValid, filesVerified, servicesEnabled: new SettingsStore().resolveOne("bundle.services_enabled").value === true });
} catch {
return {
schema: "openrig.bundle-behaviour/v1", state: "not_generated",
@@ -31,6 +31,7 @@ import { routeBundleContents, routingFailureWarnings, type BundleContentRouting
import { ContextPackLibraryService } from "../src/domain/context-packs/context-pack-library-service.js";
import type { ExecFn } from "../src/adapters/tmux.js";
import type { FsOps } from "../src/domain/package-resolver.js";
import { RigRepository } from "../src/domain/rig-repository.js";
const RIG_YAML = `
version: "0.2"
@@ -77,11 +78,12 @@ function realFsOps(): FsOps {
} as FsOps;
}
async function buildBundleWithPack(workDir: string, packManifest = PACK_MANIFEST): Promise<string> {
async function buildBundleWithPack(workDir: string, packManifest = PACK_MANIFEST, services = false): Promise<string> {
const staging = path.join(workDir, "staging");
fs.mkdirSync(path.join(staging, "agents", "impl"), { recursive: true });
fs.mkdirSync(path.join(staging, "context-packs", "demo"), { recursive: true });
fs.writeFileSync(path.join(staging, "rig.yaml"), RIG_YAML);
fs.writeFileSync(path.join(staging, "rig.yaml"), RIG_YAML + (services ? "\nservices:\n kind: compose\n compose_file: compose.yaml\n" : ""));
if (services) fs.writeFileSync(path.join(staging, "compose.yaml"), "services:\n demo:\n image: example/demo:1\n");
fs.writeFileSync(path.join(staging, "agents", "impl", "agent.yaml"), `name: impl\nversion: "1.0"\n`);
fs.writeFileSync(path.join(staging, "context-packs", "demo", "manifest.yaml"), packManifest);
fs.writeFileSync(path.join(staging, "context-packs", "demo", "intro.md"), "# Demo world\n");
@@ -127,6 +129,7 @@ describe("bundle contents are routed before any seat launches", () => {
let seenAtLaunch: { packOnDisk: boolean; rescans: number } | null;
let rescans: number;
let podInstantiator: { db: Database.Database; instantiate: ReturnType<typeof vi.fn> };
let serviceRepo: RigRepository | undefined;
beforeEach(async () => {
db = createDb();
@@ -138,11 +141,13 @@ describe("bundle contents are routed before any seat launches", () => {
bundlePath = await buildBundleWithPack(workDir);
seenAtLaunch = null;
rescans = 0;
serviceRepo = undefined;
podInstantiator = {
db,
instantiate: vi.fn(async (_yaml: string, _rigRoot: string, opts?: { prelaunchHook?: Hook }) => {
const rigId = serviceRepo?.createRig("routed-rig").id ?? "rig-1";
if (opts?.prelaunchHook) {
const hook = await opts.prelaunchHook("rig-1");
const hook = await opts.prelaunchHook(rigId);
if (!hook.ok) return { ok: false as const, code: hook.code, message: hook.message };
}
seenAtLaunch = { packOnDisk: fs.existsSync(path.join(contextRoot, "demo", "manifest.yaml")), rescans };
@@ -152,13 +157,14 @@ describe("bundle contents are routed before any seat launches", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
db.close();
if (savedContextRoot === undefined) delete process.env["OPENRIG_CONTEXT_ROOT"];
else process.env["OPENRIG_CONTEXT_ROOT"] = savedContextRoot;
fs.rmSync(workDir, { recursive: true, force: true });
});
function orchestrator(route?: (bundlePath: string) => Promise<BundleContentRouting>): BootstrapOrchestrator {
function orchestrator(route?: (bundlePath: string) => Promise<BundleContentRouting>, extra: Partial<ConstructorParameters<typeof BootstrapOrchestrator>[0]> = {}): BootstrapOrchestrator {
return new BootstrapOrchestrator({
db,
bootstrapRepo: new BootstrapRepository(db),
@@ -187,6 +193,7 @@ describe("bundle contents are routed before any seat launches", () => {
podBundleSourceResolver: new PodBundleSourceResolver(),
podInstantiator: podInstantiator as never,
routeBundleContents: route ?? ((p) => routeBundleContents(p, { onContextPacksRouted: () => { rescans += 1; } })),
...extra,
});
}
@@ -202,6 +209,24 @@ describe("bundle contents are routed before any seat launches", () => {
expect(result.stages.find((s) => s.stage === "route_bundle_contents")?.status).toBe("ok");
});
it("routes a service bundle's context pack after boot and before its first seat", async () => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
serviceRepo = new RigRepository(db);
const boot = vi.fn(async (rigId: string) => {
expect(fs.existsSync(serviceRepo!.getServicesRecord(rigId)!.composeFile)).toBe(true);
expect(seenAtLaunch).toBeNull();
expect(rescans).toBe(0);
return { ok: true, health: "healthy", receipt: {} };
});
const result = await orchestrator(undefined, { rigRepo: serviceRepo, serviceOrchestrator: { boot } as never }).bootstrap({
mode: "apply", sourceRef: await buildBundleWithPack(workDir, PACK_MANIFEST, true), sourceKind: "rig_bundle", targetRoot: path.join(workDir, "target"),
});
expect(result.status, JSON.stringify(result)).toBe("completed");
expect(boot).toHaveBeenCalledOnce();
expect(seenAtLaunch).toEqual({ packOnDisk: true, rescans: 1 });
expect(result.bundleRouting?.contextPacksRouting?.routedCount).toBe(1);
});
it("reports a routing failure as a warning and still launches the rig", async () => {
const route = vi.fn(async (): Promise<BundleContentRouting> => ({
routingFailures: [{ kind: "contextPacks", error: "no space left on device" }],
@@ -0,0 +1,203 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PodBundleAssembler, type PodAssemblerFsOps } from "../src/domain/pod-bundle-assembler.js";
import { pack, unpack } from "../src/domain/bundle-archive.js";
import { computeIntegrity } from "../src/domain/bundle-integrity.js";
import { serializePodBundleManifest } from "../src/domain/bundle-types.js";
import { describeBundleBehaviour } from "../src/domain/bundle-behaviour.js";
import { parse } from "yaml";
import { fileURLToPath } from "node:url";
import { resolveAgentRef } from "../src/domain/agent-resolver.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
const rig = `version: "0.2"
name: service-demo
services:
kind: compose
compose_file: services/compose.yaml
down_policy: down
wait_for:
- url: http://127.0.0.1:8200/v1/sys/health
pods:
- id: demo
label: Demo
members:
- id: shell
agent_ref: builtin:terminal
runtime: terminal
profile: none
cwd: .
edges: []
edges: []
`;
const compose = `services:
vault:
image: hashicorp/vault:1.15
ports: ["8200:8200"]
cap_add: [IPC_LOCK]
environment:
VAULT_DEV_ROOT_TOKEN_ID: openrig-dev-token
PRIVATE_VALUE: do-not-display
healthcheck:
test: [CMD, vault, status]
worker:
image: "\${WORKER_IMAGE}"
ports:
- target: 8080
published: "\${HOST_PORT}"
host_ip: 127.0.0.1
privileged: true
environment: [API_KEY, OTHER=not-a-public-default]
volumes: ["\${DATA_DIR}:/data"]
`;
const roots: string[] = [];
afterEach(() => { vi.unstubAllEnvs(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
function filesUnder(root: string): string[] {
return fs.readdirSync(root, { withFileTypes: true }).flatMap(entry => entry.isDirectory()
? filesUnder(path.join(root, entry.name)).map(file => `${entry.name}/${file}`) : [entry.name]);
}
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-services-")); roots.push(root);
const source = path.join(root, "source"); fs.mkdirSync(path.join(source, "services"), { recursive: true });
fs.writeFileSync(path.join(source, "rig.yaml"), rig);
fs.writeFileSync(path.join(source, "services/compose.yaml"), compose);
fs.writeFileSync(path.join(source, ".env"), "PRIVATE_SECRET=must-not-travel");
const fsOps: PodAssemblerFsOps = {
readFile: p => fs.readFileSync(p, "utf8"), readFileBuffer: fs.readFileSync,
exists: fs.existsSync, realpath: fs.realpathSync,
mkdirp: p => { fs.mkdirSync(p, { recursive: true }); },
writeFile: (p, content, mode) => { fs.writeFileSync(p, content, { mode }); },
copyDir: (s, d) => { fs.cpSync(s, d, { recursive: true }); }, listFiles: filesUnder,
};
const assemble = (servicesEnabled = false) => new PodBundleAssembler({ fsOps }).assemble({
rigRoot: source, rigSpecPath: path.join(source, "rig.yaml"), outputDir: path.join(root, "staging"),
bundleName: "demo", bundleVersion: "1.0.0", servicesEnabled,
});
return { root, source, assemble, fsOps };
}
describe("opt-in service bundles", () => {
it.each([false, true])("uses the typed switch at the actual create/inspect routes (enabled=%s)", async enabled => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", String(enabled));
const { root, source } = fixture(), db = createFullTestDb();
try {
const { app } = createTestApp(db);
const bundlePath = path.join(root, "created.rigbundle");
const post = (route: string, body: unknown) => app.request(route, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const created = await post("/api/bundles/create", { specPath: path.join(source, "rig.yaml"), rigRoot: source, bundleName: "demo", bundleVersion: "1.0.0", outputPath: bundlePath });
expect(created.status, await created.clone().text()).toBe(201);
const extracted = path.join(root, "api-extracted"); fs.mkdirSync(extracted); await unpack(bundlePath, extracted);
expect(filesUnder(extracted).sort()).toEqual(enabled ? ["bundle.yaml", "rig.yaml", "services/compose.yaml"] : ["bundle.yaml", "rig.yaml"]);
const inspected = await post("/api/bundles/inspect", { bundlePath });
expect(inspected.status).toBe(200);
const result = await inspected.json();
if (enabled) expect(result.behaviour.services).toHaveLength(2);
else expect(result.behaviour).not.toHaveProperty("services");
} finally { db.close(); }
});
it("keeps the selected specimen's agent and import refs inside its repository", () => {
const repository = fileURLToPath(new URL("../../../", import.meta.url));
const source = path.join(repository, "packages/daemon/specs/rigs/launch/secrets-manager");
const spec = parse(fs.readFileSync(path.join(source, "rig.yaml"), "utf8"));
const { fsOps } = fixture();
const resolved = resolveAgentRef(spec.pods[0].members[0].agent_ref, source, fsOps);
expect(resolved.ok).toBe(true);
if (!resolved.ok) throw new Error(JSON.stringify(resolved));
for (const agent of [resolved.resolved, ...resolved.imports]) {
const relative = path.relative(fs.realpathSync(repository), fs.realpathSync(agent.sourcePath));
expect(relative.startsWith("..") || path.isAbsolute(relative)).toBe(false);
expect(fs.existsSync(path.join(agent.sourcePath, "agent.yaml"))).toBe(true);
}
});
it("packs only the declared compose file at its relative path in a real archive", async () => {
const { root, assemble } = fixture(); const { manifest } = assemble(true);
const staging = path.join(root, "staging");
const integrityFs = {
readFile: (p: string) => fs.readFileSync(p, "utf8"), readFileBuffer: fs.readFileSync,
writeFile: fs.writeFileSync, exists: fs.existsSync, walkFiles: filesUnder,
};
manifest.integrity = computeIntegrity(staging, integrityFs);
fs.writeFileSync(path.join(staging, "bundle.yaml"), serializePodBundleManifest(manifest));
const archive = path.join(root, "demo.rigbundle"); await pack(staging, archive);
const extracted = path.join(root, "extracted"); fs.mkdirSync(extracted); await unpack(archive, extracted);
expect(filesUnder(extracted).sort()).toEqual(["bundle.yaml", "rig.yaml", "services/compose.yaml"]);
expect(fs.readFileSync(path.join(extracted, "services/compose.yaml"), "utf8")).toBe(compose);
});
it("keeps the old archive file list while disabled", () => {
const { root, assemble } = fixture(); assemble();
expect(filesUnder(path.join(root, "staging")).sort()).toEqual(["bundle.yaml", "rig.yaml"]);
});
it("preserves creation with a missing compose declaration and shows it as unresolved", () => {
const { source, assemble } = fixture(); fs.unlinkSync(path.join(source, "services/compose.yaml"));
expect(assemble(true).collectedFiles).not.toContain("services/compose.yaml");
const view = describeBundleBehaviour({ files: new Map([["rig.yaml", rig]]), manifest: { schema_version: 2, rig_spec: "rig.yaml" }, generator: { openrigVersion: "test" }, digestValid: true, filesVerified: true, servicesEnabled: true });
expect(view.state === "generated" && view.alsoRuns).toEqual(expect.arrayContaining([expect.objectContaining({ pathOrRef: "services/compose.yaml", resolution: "unresolved" })]));
});
const inspect = (servicesEnabled: boolean, composeText = compose) => describeBundleBehaviour({
files: new Map([["rig.yaml", rig], ["services/compose.yaml", composeText]]),
manifest: { schema_version: 2, rig_spec: "rig.yaml" }, generator: { openrigVersion: "test" },
digestValid: true, filesVerified: true, servicesEnabled,
});
it("describes each archived service without exposing arbitrary env values or resolving host interpolation", () => {
const view = inspect(true);
expect(view.state).toBe("generated");
if (view.state !== "generated") throw new Error(view.reason);
expect(view.services).toMatchObject([
{ name: "vault", image: "hashicorp/vault:1.15", ports: ["8200:8200"], capAdd: ["IPC_LOCK"], environment: ["VAULT_DEV_ROOT_TOKEN_ID", "PRIVATE_VALUE"], downPolicy: "down" },
{ name: "worker", image: "${WORKER_IMAGE}", privileged: true, environment: ["API_KEY", "OTHER"], mounts: ["${DATA_DIR}:/data"] },
]);
expect(view.services?.[0]?.waitFor).toEqual([{ url: "http://127.0.0.1:8200/v1/sys/health" }]);
expect(view.services?.[0]?.notes.join(" ")).toMatch(/public.*dev|development/i);
expect(view.services?.[1]?.unresolved).toEqual(expect.arrayContaining(["image", "ports", "mounts"]));
expect(JSON.stringify(view)).not.toContain("do-not-display");
expect(JSON.stringify(view)).not.toContain("not-a-public-default");
});
it("summarizes fields inherited through YAML merge keys", () => {
const view = inspect(true, `x-common: &common
privileged: true
cap_add: [SYS_ADMIN]
ports: ["0.0.0.0:80:80"]
services:
app:
<<: *common
image: example/app:1
`);
expect(view.state).toBe("generated");
if (view.state !== "generated") throw new Error(view.reason);
expect(view.services).toMatchObject([{ name: "app", privileged: true, capAdd: ["SYS_ADMIN"], ports: ["0.0.0.0:80:80"] }]);
});
it("names every unsummarized service key without exposing its values", () => {
const view = inspect(true, `services:
app:
image: example/app:1
network_mode: host
pid: host
user: private-user-value
x-extra: private-extra-value
`);
expect(view.state).toBe("generated");
if (view.state !== "generated") throw new Error(view.reason);
expect(view.services?.[0]?.notes).toContain("Also declared, not summarized: network_mode, pid, user, x-extra");
expect(JSON.stringify(view)).not.toContain("private-user-value");
expect(JSON.stringify(view)).not.toContain("private-extra-value");
});
it("leaves the old view unchanged while disabled", () => {
expect(inspect(false)).not.toHaveProperty("services");
});
it("reports an unreadable compose declaration as unknown without refusing the view", () => {
const view = inspect(true, "services: [broken");
expect(view.state).toBe("generated");
expect(view.state === "generated" && view.unknownBeforeLaunch.some(item => item.subject === "services/compose.yaml")).toBe(true);
});
});
@@ -27,6 +27,14 @@ import { PodBundleSourceResolver, LegacyBundleSourceResolver } from "../src/doma
import { pack } from "../src/domain/bundle-archive.js";
import { computeIntegrity } from "../src/domain/bundle-integrity.js";
import { resolveLaunchCwd } from "../src/domain/cwd-resolution.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { ServiceOrchestrator } from "../src/domain/service-orchestrator.js";
import { ComposeServicesAdapter } from "../src/adapters/compose-services-adapter.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import * as preflight from "../src/domain/rigspec-preflight.js";
import type { ExecFn } from "../src/adapters/tmux.js";
import type { FsOps } from "../src/domain/package-resolver.js";
@@ -69,10 +77,12 @@ function realFsOps(): FsOps {
} as FsOps;
}
async function buildV2Bundle(workDir: string): Promise<string> {
async function buildV2Bundle(workDir: string, services = false, options: { terminal?: boolean; projectName?: string } = {}): Promise<string> {
const staging = path.join(workDir, "staging");
fs.mkdirSync(path.join(staging, "agents", "impl"), { recursive: true });
fs.writeFileSync(path.join(staging, "rig.yaml"), RIG_YAML);
const rigYaml = options.terminal ? RIG_YAML.replace('"local:agents/impl"', 'builtin:terminal').replace('profile: default', 'profile: none').replace('runtime: claude-code', 'runtime: terminal') : RIG_YAML;
fs.writeFileSync(path.join(staging, "rig.yaml"), rigYaml + (services ? "\nservices:\n kind: compose\n compose_file: compose.yaml\n down_policy: down\n" + (options.projectName ? ` project_name: ${options.projectName}\n` : "") : ""));
if (services) fs.writeFileSync(path.join(staging, "compose.yaml"), "services:\n demo:\n image: example/demo:1\n");
fs.writeFileSync(path.join(staging, "agents", "impl", "agent.yaml"), AGENT_YAML);
const integrity = computeIntegrity(staging, {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
@@ -134,11 +144,13 @@ describe("v2 bundle apply: launch root lifetime and --target", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
db.close();
fs.rmSync(workDir, { recursive: true, force: true });
});
function orchestrator(): BootstrapOrchestrator {
function orchestrator(extra: Partial<ConstructorParameters<typeof BootstrapOrchestrator>[0]> = {}): BootstrapOrchestrator {
return new BootstrapOrchestrator({
db,
bootstrapRepo: new BootstrapRepository(db),
@@ -166,6 +178,7 @@ describe("v2 bundle apply: launch root lifetime and --target", () => {
bundleSourceResolver: new LegacyBundleSourceResolver({ fsOps: realFsOps() }),
podBundleSourceResolver: new PodBundleSourceResolver(),
podInstantiator: podInstantiator as never,
...extra,
});
}
@@ -228,4 +241,158 @@ describe("v2 bundle apply: launch root lifetime and --target", () => {
expect(launches[0]!.cwd).toBe(explicit);
expect(fs.existsSync(launches[0]!.cwd)).toBe(true);
});
it.each(["plan", "apply"] as const)("keeps services_unsupported with the switch off in %s", async mode => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "false");
const result = await orchestrator().bootstrap({ mode, sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true), targetRoot: path.join(workDir, "target") });
expect(result.stages).toContainEqual(expect.objectContaining({ detail: expect.objectContaining({ code: "services_unsupported" }) }));
expect(podInstantiator.instantiate).not.toHaveBeenCalled();
});
it.each(["plan", "apply"] as const)("keeps services_unsupported without a lasting target in %s", async mode => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
const result = await orchestrator().bootstrap({ mode, sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true) });
expect(result.stages).toContainEqual(expect.objectContaining({ detail: expect.objectContaining({ code: "services_unsupported" }) }));
expect(podInstantiator.instantiate).not.toHaveBeenCalled();
});
it("boots the services using the materialised target before the first seat", async () => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
const target = path.join(workDir, "target"), repo = new RigRepository(db);
const order: string[] = [];
const boot = vi.fn(async (rigId: string) => {
const record = repo.getServicesRecord(rigId)!;
expect(fs.realpathSync(record.rigRoot)).toBe(fs.realpathSync(target));
expect(record.composeFile).toBe(path.join(target, "compose.yaml"));
expect(fs.existsSync(record.composeFile)).toBe(true);
order.push("boot");
return { ok: true, health: "healthy", receipt: {} };
});
const route = vi.fn(async () => { order.push("route"); return {}; });
podInstantiator.instantiate.mockImplementation(async (_yaml: string, rigRoot: string, opts: { prelaunchHook: (rigId: string) => Promise<{ ok: boolean }> }) => {
const rig = repo.createRig("lifetime-rig");
expect((await opts.prelaunchHook(rig.id)).ok).toBe(true);
order.push("seat");
return { ok: true, result: { rigId: rig.id, specName: "lifetime-rig", nodes: [{ logicalId: "dev.impl", status: "launched" }] } };
});
const result = await orchestrator({ rigRepo: repo, serviceOrchestrator: { boot } as never, routeBundleContents: route }).bootstrap({
mode: "apply", sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true), targetRoot: target,
});
expect(result.status, JSON.stringify(result)).toBe("completed");
expect(order).toEqual(["boot", "route", "seat"]);
expect(fs.existsSync(path.join(target, "compose.yaml"))).toBe(true);
});
it.each(["rollback", "cleanup-failure", "inherited", "inherited-boot-failure", "ambiguous", "explicit"])(
"preserves the composed archive service lifecycle (%s)", async scenario => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
vi.spyOn(preflight, "rigPreflight").mockResolvedValue({ ready: true, errors: [], warnings: [] } as never);
const repo = new RigRepository(db), order: string[] = [], commands: string[] = [];
const inherited = ["inherited", "inherited-boot-failure", "ambiguous", "explicit"].includes(scenario);
const store = (id: string, projectName: string) => repo.setServicesRecord(id, {
kind: "compose", specJson: "{}", rigRoot: workDir, composeFile: "compose.yaml", projectName,
});
const historical = repo.createRig("lifetime-rig"); store(historical.id, "unrelated-history"); repo.archiveRig(historical.id);
const predecessors: string[] = [];
if (inherited) {
const previous = repo.createRig("lifetime-rig"); store(previous.id, "exact-predecessor"); predecessors.push(previous.id);
if (scenario === "ambiguous") {
const other = repo.createRig("lifetime-rig"); store(other.id, "second-project"); predecessors.push(other.id);
}
}
let currentId: string | undefined;
const composeAdapter = new ComposeServicesAdapter(async command => {
commands.push(command);
if (command.includes("up -d")) order.push("up");
if (command.includes("ps --format json") && scenario === "inherited-boot-failure") throw new Error("test status failure after up");
if (command.includes("down")) {
order.push("down");
expect(repo.getServicesRecord(currentId!)).not.toBeNull();
if (scenario === "cleanup-failure") throw new Error("test cleanup unavailable");
}
return "";
});
const serviceOrchestrator = new ServiceOrchestrator({ rigRepo: repo, composeAdapter });
const boot = serviceOrchestrator.boot.bind(serviceOrchestrator);
vi.spyOn(serviceOrchestrator, "boot").mockImplementation(async id => { currentId = id; return boot(id); });
const remove = repo.deleteRig.bind(repo);
vi.spyOn(repo, "deleteRig").mockImplementation(id => { order.push("delete"); return remove(id); });
const nodeLauncher = { db, launchNode: vi.fn(async () => {
order.push("seat"); return { ok: false, code: "launch_failed", message: "test terminal launch failure" };
}) };
const actualInstantiator = new PodRigInstantiator({
db, rigRepo: repo, podRepo: new PodRepository(db), sessionRegistry: new SessionRegistry(db), eventBus: new EventBus(db),
nodeLauncher, startupOrchestrator: { db }, adapters: {}, fsOps: realFsOps(),
tmuxAdapter: { probeSession: vi.fn(async () => ({ state: "absent" })) },
} as never);
const route = vi.fn(async () => { order.push("route"); throw new Error("test routing warning"); });
const target = path.join(workDir, "lifecycle-target");
const result = await orchestrator({ rigRepo: repo, podInstantiator: actualInstantiator, serviceOrchestrator, routeBundleContents: route }).bootstrap({
mode: "apply", sourceKind: "rig_bundle", targetRoot: target,
sourceRef: await buildV2Bundle(workDir, true, { terminal: true, projectName: scenario === "explicit" ? "requested-project" : undefined }),
});
expect(result.status, JSON.stringify(result)).toBe("failed");
if (scenario === "ambiguous") {
expect(commands).toEqual([]);
expect(result.errors.join(" ")).toContain("multiple predecessor Compose projects");
for (const id of predecessors) expect(result.errors.join(" ")).toContain(id);
expect(route).not.toHaveBeenCalled();
} else {
const expectedProject = scenario === "explicit" ? "requested-project" : inherited ? "exact-predecessor" : repo.getServicesRecord(currentId!)?.projectName;
if (inherited) expect(commands.some(command => command.includes(`-p '${expectedProject}'`) && command.includes("up -d"))).toBe(true);
const shouldRollback = !inherited || scenario === "explicit";
expect(commands.filter(command => command.includes("down"))).toHaveLength(shouldRollback ? 1 : 0);
expect(commands.filter(command => command.includes("down")).every(command => !command.includes("--volumes"))).toBe(true);
if (scenario === "cleanup-failure") {
expect(repo.getRig(currentId!)).not.toBeNull();
expect(repo.getServicesRecord(currentId!)).not.toBeNull();
expect(result.errors.join(" ")).toContain("retained for recovery");
expect(order).not.toContain("delete");
} else {
expect(repo.getRig(currentId!)).toBeNull();
if (shouldRollback) expect(order.indexOf("down")).toBeLessThan(order.indexOf("delete"));
}
if (scenario !== "inherited-boot-failure") {
expect(order.indexOf("route")).toBeLessThan(order.indexOf("seat"));
expect(result.warnings.join(" ")).toContain("test routing warning");
}
}
const remaining = repo.findUnarchivedRigsByName("lifetime-rig").map(record => record.id);
expect(remaining).not.toContain(historical.id);
if (scenario !== "cleanup-failure") expect(remaining.sort()).toEqual(predecessors.sort());
},
);
it("plans the target services and view without materialising or launching", async () => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
const target = path.join(workDir, "planned-target");
Object.assign(podInstantiator, { deps: { fsOps: realFsOps() } });
vi.spyOn(preflight, "rigPreflight").mockResolvedValue({ ready: true, errors: [], warnings: [] } as never);
const result = await orchestrator().bootstrap({ mode: "plan", sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true), targetRoot: target });
expect(result.status).toBe("planned");
expect(result.stages).toContainEqual(expect.objectContaining({ stage: "services_plan", detail: expect.objectContaining({ targetRoot: target, materialised: false, services: [expect.objectContaining({ name: "demo", image: "example/demo:1" })] }) }));
expect(fs.existsSync(target)).toBe(false);
expect(podInstantiator.instantiate).not.toHaveBeenCalled();
});
it("checks target conflicts in a service plan without writing", async () => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
const target = path.join(workDir, "target"); fs.mkdirSync(target); fs.writeFileSync(path.join(target, "rig.yaml"), "my content");
const result = await orchestrator().bootstrap({ mode: "plan", sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true), targetRoot: target });
expect(result.stages).toContainEqual(expect.objectContaining({ detail: expect.objectContaining({ code: "target_conflict" }) }));
expect(fs.readdirSync(target)).toEqual(["rig.yaml"]);
expect(fs.readFileSync(path.join(target, "rig.yaml"), "utf8")).toBe("my content");
});
it("does not plan a service install over a running team", async () => {
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "true");
const repo = new RigRepository(db), rig = repo.createRig("lifetime-rig");
const node = repo.addNode(rig.id, "dev.old", { runtime: "terminal", cwd: workDir });
const registry = new SessionRegistry(db), session = registry.registerSession(node.id, "dev-old@lifetime-rig");
registry.updateStatus(session.id, "running");
const target = path.join(workDir, "target");
const result = await orchestrator().bootstrap({ mode: "plan", sourceKind: "rig_bundle", sourceRef: await buildV2Bundle(workDir, true), targetRoot: target });
expect(result.stages).toContainEqual(expect.objectContaining({ detail: expect.objectContaining({ code: "rig_name_running" }) }));
expect(fs.existsSync(target)).toBe(false);
});
});
@@ -24,6 +24,7 @@ function clearEnv(): () => void {
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_LAUNCH_NON_INTERRUPTIVE",
"OPENRIG_BUNDLE_SERVICES_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
@@ -115,8 +116,11 @@ describe("config routes (User Settings v0)", () => {
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
// + 1 bundle.services_enabled (service archive packing, preview and target installs; default off).
expect(body.settings["bundle.services_enabled"]).toMatchObject({ value: false, source: "default", defaultValue: false });
// + 1 provider.subscription_moves (slice 18 subscription move record; default off).
expect(Object.keys(body.settings).length).toBe(80);
expect(Object.keys(body.settings).length).toBe(81);
expect(body.settings["provider.subscription_moves"]).toMatchObject({ value: false, source: "default", defaultValue: false });
expect(body.settings["launch.non_interruptive"]).toMatchObject({ value: false, source: "default" });
expect(body.settings["runtime.readiness_timeout_seconds"]).toMatchObject({ value: 30, source: "default" });
expect(body.settings["ui.enabled"]).toMatchObject({ value: false, source: "default" });
@@ -159,6 +159,7 @@ describe("SettingsStore (User Settings v0)", () => {
"output.whoami_peers",
// Slice 01 (OPR.0.7.0.1) — Codex usage windows switch.
"provider.usage_windows",
"bundle.services_enabled",
// Slice 18 (OPR.0.7.0.18) — the subscription move record switch.
"provider.subscription_moves",
"usage.pi_cost_facts",
@@ -363,6 +364,19 @@ describe("SettingsStore (User Settings v0)", () => {
.toMatchObject({ value: true, source: "env", defaultValue: true });
});
it("bundle.services_enabled is typed, default-off and read again after a file or env change", () => {
const store = new SettingsStore(configPath);
expect(store.resolveOne("bundle.services_enabled")).toMatchObject({ value: false, source: "default" });
store.set("bundle.services_enabled", "true");
expect(store.resolveOne("bundle.services_enabled")).toMatchObject({ value: true, source: "file" });
expect(JSON.parse(readFileSync(configPath, "utf8")).bundle).toEqual({ servicesEnabled: true });
vi.stubEnv("OPENRIG_BUNDLE_SERVICES_ENABLED", "false");
expect(store.resolveOne("bundle.services_enabled")).toMatchObject({ value: false, source: "env" });
vi.unstubAllEnvs();
store.reset("bundle.services_enabled");
expect(store.resolveOne("bundle.services_enabled")).toMatchObject({ value: false, source: "default" });
});
it("transport.verify_input_box is off by default and resolves file, then env", () => {
const store = new SettingsStore(configPath);
expect(store.resolveOne("transport.verify_input_box"))