feat: initialize canonical OpenRig instance layout

This commit is contained in:
v-openrig-build committed 2026-09-03 19:49:43 +00:00
1 parent 5820f741bf
commit f9f7e0f00e
40 files changed
+880 -324

No files matched your search

@@ -67,9 +67,10 @@ in-memory cache at daemon scope.
`ContextPackLibraryService.scan()` walks roots, parses each
`manifest.yaml`, replaces the in-memory index; collisions are
**last-wins** in discovery order (workspace > user_file > builtin).
Startup wires three roots: builtin (`../context-packs`, first), user-file
(`~/.openrig/context-packs`), and workspace-local
`<workspaceRoot>/.openrig/context-packs` when present + distinct. A pack is
Startup wires the builtin root (`../context-packs`, first), the configured
user-file root (`context.root`, default `$OPENRIG_HOME/context`), its
`system/` child as the canonical System World root, and the workspace-local
`<workspaceRoot>/.openrig/context-packs` root when present + distinct. A pack is
addressed by its path-like **ref** (e.g. `packs/compaction-restore`), which is
its unique identity; the opaque entry id is `context-pack:<ref>` (a UI routing
key). Colon-id `context-pack:<name>:<version>` addressing and the `/library/:id`
@@ -79,8 +80,8 @@ ordinary files only and rejects context-pack expansion; compose a durable ref
first, then use a dedicated delivery verb when delivery is intended.
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts:481-502` (3-root
> IIFE; builtin `unshift` L497, workspace L491-493) @HEAD.
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts`
> (`contextPackLibrary` construction) @HEAD.
The parser is pure (no fs; caller passes raw YAML). It rejects malformed
YAML, missing `name`/`version`, non-array `files`, per-file path traversal
@@ -229,7 +229,9 @@ discovery and exposes empty `install.context` / `install.skills` selectors.
The ignore file excludes `exhaust/` and local `.openrig/` projection state
while keeping authored project context versionable.
The initializer is additive. It never deletes or overwrites an existing file;
The workspace owner is called by the shared instance initializer used by CLI
daemon start and direct daemon first start. The initializer is additive. It
never deletes or overwrites an existing file;
the retained `--force` spelling is a compatibility no-op for overwrite
behavior. CLI and daemon scaffolds are byte-parity pinned. Mission and slice
content is created explicitly through `rig scope`, not seeded by workspace
@@ -239,6 +241,8 @@ and the retired `artifacts/`, `evidence/`, `progress/`, `field-notes/`,
> Source: `domain/workspace/default-workspace-scaffold.ts`
> (`workspaceScaffoldDirs`, `workspaceScaffoldFiles`); CLI
> `domain/instance-initialization.ts`; daemon `index.ts`; CLI
> `packages/cli/src/daemon-lifecycle.ts` and
> `packages/cli/src/commands/config-init-workspace.ts` (`runInitWorkspace`);
> parity: `packages/daemon/test/getting-started-narrative-parity.test.ts`
> @HEAD.
+9 -1
View File
@@ -116,7 +116,11 @@ Subcommands:
- `logs [--follow]`
Notes:
- `start` launches the daemon process and accepts runtime overrides for port, host, and DB path.
- `start` additively reconciles the canonical instance layout before launching
the daemon process, then accepts runtime overrides for port, host, and DB
path. Direct daemon first start uses the same initializer. Existing files are
preserved and wrong-type managed paths are refused before any write. See
`docs/reference/instance-layout.md`.
- `logs` reads daemon log output and can follow it.
- **Deploy identity (v0.4.4, OPR.0.4.4.11 FR-6/7)**: a PACKAGED build (built via `scripts/build-package.sh`) is stamped with `{semver, commit, dirty, builtAt}`; the daemon's `/healthz` payload carries the four stamp fields additively and `rig --version` renders `<semver> (<commit8>[, dirty])`. A source/dev run has NO stamp and adds NOTHING (never an invented SHA) — `/healthz` keeps its legacy body and `--version` prints the plain semver. This is the 30-second stale-deploy diagnostic: an unstamped or old-commit `/healthz` on a long-running host means you are looking at an older deployed build, not the source tree. Source: `packages/{daemon,cli}/src/build-info.ts` (`stampFields`), `packages/daemon/src/server.ts` (`/healthz`), `packages/cli/src/version.ts`.
@@ -154,6 +158,10 @@ Supported keys:
- `transcripts.enabled`
- `transcripts.path`
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
- `context.root` (default `$OPENRIG_HOME/context`; env
`OPENRIG_CONTEXT_ROOT`) — the single writable addressable context library.
The removed `context.packs_root`, config field `context.packsRoot`, and env
`OPENRIG_CONTEXT_PACKS_ROOT` are refused with replacement guidance.
- `skills.root` (default `$OPENRIG_HOME/skills`; env `OPENRIG_SKILLS_ROOT`) — the
one authoritative Git-versioned managed skill catalog. An override replaces
the default; it does not add an overlay root.
+2 -1
View File
@@ -16,7 +16,8 @@ is no magic one-command onboarding; each step is a real, inspectable verb.
the kernel (see "Kernel framing" below).
2. **`rig up <rig-spec>`** - launch a rig. This **auto-starts the daemon** if it
is not already running (the four daemon-dependent verbs below do not auto-start
it - they tell you to run `rig up` or `rig daemon start`).
it - they tell you to run `rig up` or `rig daemon start`). Daemon start first
reconciles the additive [OpenRig instance layout](instance-layout.md).
3. **`rig status`** - see what is currently true: daemon up/down + port, kernel
readiness (distinct from daemon health), and your effective workspace root
(default vs override).
+67
View File
@@ -0,0 +1,67 @@
# OpenRig Instance Layout
An OpenRig instance keeps its managed state under one configured
`$OPENRIG_HOME`. `rig daemon start` and direct daemon first start reconcile the
same additive layout before the database is opened or the listener binds.
```text
$OPENRIG_HOME/
config.json # typed instance settings; created as an empty object
state/ # runtime-owned durable state
context/ # addressable context library (`context.root`)
system/ # canonical System World library root
skills/ # managed skill catalog (`skills.root`)
workspace/ # project work tree (`workspace.root`)
SPEC.md # project intent
project.yaml # project context and skill selection
workspace.yaml # project-location catalog
.gitignore
missions/
exhaust/
specs/ # canonical instance spec library
topology/ # instance, rig, pod, and seat continuity tree
plugins/ # installed OpenRig plugins
run/ # process coordination files
logs/ # daemon and operation logs
transcripts/ # durable per-seat terminal transcripts
backups/ # operator-created recovery artifacts
secrets/ # local connector and host secrets
```
The initializer creates only missing managed entries. It never overwrites an
existing file, and it checks every managed path before the first write. A path
with the wrong type is reported by its exact location; unrelated user-owned
content is preserved. A second run against an already-converged instance writes
nothing.
The workspace subtree is owned by the [Project Workspace
Contract](project-workspace.md). The instance initializer calls that owner
rather than carrying another copy of its file bytes. `skills/` and `topology/`
are created as empty roots; their respective catalog and topology workflows own
their contents.
## Context library setting
The addressable context library has one typed setting and one environment
override:
| Surface | Value |
| --- | --- |
| Config key | `context.root` |
| Environment | `OPENRIG_CONTEXT_ROOT` |
| Default | `$OPENRIG_HOME/context` |
| Resolved property | `contextRoot` |
The removed `context.packs_root`, `context.packsRoot`, and
`OPENRIG_CONTEXT_PACKS_ROOT` spellings are refused with guidance to use
`context.root`; they are not compatibility aliases. Bundle installation and
`rig context add` both resolve the same configured landing root.
## Existing spec libraries
Creating `$OPENRIG_HOME/specs` does not migrate existing launch-era specs.
Upgraded installations may still have a separate legacy specs library that the
runtime reads for compatibility. Treat the two-home state as an explicit
limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
+3
View File
@@ -4,6 +4,9 @@ OpenRig's Project TUI is a file-backed view over a workspace root. The folder
shape is intentionally simple so humans and agents can create or repair it
without daemon-internal knowledge.
This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md).
The instance initializer delegates these exact workspace bytes to this owner.
## Default Shape
`rig config init-workspace` creates the default workspace at
@@ -1,15 +1,18 @@
// Canonical project-workspace scaffold used by `rig config init-workspace`.
//
// Keep this byte-compatible with
// packages/daemon/src/domain/workspace/default-workspace-scaffold.ts. The two
// packages intentionally do not depend on one another, and the daemon parity
// test pins their shared output.
// Public adapter for the S01 project-workspace owner used by S05 instance
// initialization. The bytes and behavior live in @openrig/daemon.
import { Command } from "commander";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
ensureDefaultWorkspace,
workspaceScaffoldDirs,
workspaceScaffoldFiles,
type InitWorkspaceResult,
} from "@openrig/daemon/instance-initialization";
import { ConfigStore } from "../config-store.js";
export { workspaceScaffoldDirs, workspaceScaffoldFiles };
export type { InitWorkspaceResult };
export interface InitWorkspaceOpts {
root?: string;
/** Deprecated compatibility input. Existing files are always preserved. */
@@ -18,63 +21,6 @@ export interface InitWorkspaceOpts {
json?: boolean;
}
export interface InitWorkspaceResult {
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
dryRun: boolean;
}
const WORKSPACE_DIRS = ["missions", "exhaust"] as const;
const PROJECT_SPEC = `---
intent: Organize this project's durable work as missions and slices, then move it through queue-backed agent collaboration.
---
# Project
This is the project-level context for the default OpenRig workspace. It gives
agents a stable project intent before they descend into the active mission and
slice. Edit it to describe what your project is for and who benefits from it.
`;
const PROJECT_MANIFEST = `schema: openrig.project/v0alpha1
kind: project
install:
intent: SPEC.md
context: []
skills: []
missions:
root: missions
# Add ordered project Markdown addresses and stable catalog skill IDs above.
# Skill source and the System World remain outside the project workspace.
`;
const WORKSPACE_CATALOG = `schema: openrig.workspace/v0alpha1
projects:
- id: default
root: .
`;
const WORKSPACE_GITIGNORE = `# OpenRig disposable work and local runtime projection state.
/exhaust/
/.openrig/
`;
export function workspaceScaffoldDirs(): string[] {
return [...WORKSPACE_DIRS];
}
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
return [
{ relPath: "SPEC.md", content: PROJECT_SPEC },
{ relPath: "project.yaml", content: PROJECT_MANIFEST },
{ relPath: "workspace.yaml", content: WORKSPACE_CATALOG },
{ relPath: ".gitignore", content: WORKSPACE_GITIGNORE },
];
}
export function initWorkspaceCommand(configPath?: string): Command {
const cmd = new Command("init-workspace")
.description("Scaffold a repo-ready project workspace")
@@ -110,37 +56,12 @@ export function initWorkspaceCommand(configPath?: string): Command {
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
const store = new ConfigStore(opts.configPath);
const root = opts.root ?? (store.get("workspace.root") as string);
const dryRun = !!opts.dryRun;
const scaffoldFiles = workspaceScaffoldFiles();
const scaffoldDirs = workspaceScaffoldDirs();
const rootExists = existsSync(root);
const result: InitWorkspaceResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
dryRun,
};
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of scaffoldDirs) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
const result = ensureDefaultWorkspace({ root, dryRun: !!opts.dryRun });
if (!result.ok) {
const detail = result.conflicts
.map((conflict) => `${conflict.path}: expected ${conflict.expected}, found ${conflict.actual}`)
.join("; ");
throw new Error(`Workspace initialization blocked: ${detail}`);
}
for (const file of scaffoldFiles) {
const absPath = join(root, file.relPath);
const exists = existsSync(absPath);
if (exists) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
continue;
}
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
return result;
}
+1 -1
View File
@@ -69,7 +69,7 @@ Keys:
workspace.* root, slices_root, steering_path, specs_root,
projects_root, catalog_path, operator_seat_name
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.packs_root context-pack library landing zone for 'rig context add' (default $OPENRIG_HOME/context-packs)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
files.allowlist name:/abs/path,name:/abs/path
+5 -5
View File
@@ -7,7 +7,7 @@
//
// Each delegates to /api/context-packs/library/* against the daemon.
// The `add` verb installs a pack from a directory at
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
// $OPENRIG_HOME/context/<name>/ — host-symlink-free contract,
// matches `rig specs add` shape (regular files only; no symlinks).
import { Command } from "commander";
@@ -571,7 +571,7 @@ Examples:
return;
}
if (entries.length === 0) {
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context sync");
console.log("No context packs in library. Author one under `rig config get context.root`, then run: rig context sync");
return;
}
for (const e of entries) {
@@ -883,14 +883,14 @@ Examples:
cmd.command("add")
.argument("<source>", "Local directory OR http(s):// URL of a context pack (manifest.yaml + files)")
.description("Install a context pack from a local directory or a URL into the context-packs landing zone")
.description("Install a context pack from a local directory or a URL into the configured context library")
.option("--name <name>", "Override the install name (defaults to the manifest name / source basename)")
.option("--json", "JSON output")
.action(async (source: string, opts: { name?: string; json?: boolean }) => {
try {
// OPR.0.5.3.7 R4 — config-resolved landing zone (env > config > $OPENRIG_HOME/context-packs),
// OPR.0.5.9.5 Wave B — config-resolved context library,
// never a hardcoded ~/.openrig literal; the daemon resolves the same key.
const targetRoot = new ConfigStore().resolve().context.packsRoot;
const targetRoot = new ConfigStore().resolve().context.root;
let targetDir: string;
if (isHttpUrl(source)) {
// R4 — URL install: fetch → validate → atomic stage+rename (no partial pack).
+13
View File
@@ -66,6 +66,16 @@ export function realDeps(): LifecycleDeps {
removeFile: (p) => { try { fs.unlinkSync(p); } catch { /* ignore */ } },
exists: (p) => fs.existsSync(p),
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
pathKind: (p) => {
try {
const value = fs.lstatSync(p);
if (value.isDirectory()) return "directory";
if (value.isFile()) return "file";
return "other";
} catch {
return "missing";
}
},
openForAppend: (p) => fs.openSync(p, "a"),
isProcessAlive,
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
@@ -152,6 +162,9 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
transcriptsEnabled: config.transcripts.enabled,
transcriptsPath: config.transcripts.path,
workspaceRoot: config.workspace.root,
contextRoot: config.context.root,
skillsRoot: config.skills.root,
topologyRoot: config.topology.root,
// V1 pre-release CLI/daemon Item 1 — project the
// ConfigStore-resolved rotation tunables into the daemon
// process env so file-stored values
+3
View File
@@ -215,6 +215,9 @@ Examples:
transcriptsLines: resolvedConfig.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig.workspace.root,
contextRoot: resolvedConfig.context.root,
skillsRoot: resolvedConfig.skills.root,
topologyRoot: resolvedConfig.topology.root,
}, deps.lifecycleDeps);
status = await getDaemonStatus(deps.lifecycleDeps);
} catch (err) {
+6 -7
View File
@@ -4,7 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
import { parse as parseYamlDoc } from "yaml";
import { Command } from "commander";
import { DaemonClient, DaemonConnectionError } from "../client.js";
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js";
import type { RiggedConfig } from "../config-store.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -133,12 +134,7 @@ Examples:
// Run preflight before auto-start
let status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running") {
let resolvedConfig: {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
workspace: { root: string };
} | null = null;
let resolvedConfig: RiggedConfig | null = null;
// bug-fix slice auth-bearer-tailscale-trust: track whether
// daemon.host was operator-explicit (env or config file) vs
// default-fallback. The daemon's multi-bind path (loopback +
@@ -199,6 +195,9 @@ Examples:
transcriptsLines: resolvedConfig?.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig?.workspace.root,
contextRoot: resolvedConfig?.context.root,
skillsRoot: resolvedConfig?.skills.root,
topologyRoot: resolvedConfig?.topology.root,
}, deps.lifecycleDeps);
status = await getDaemonStatus(deps.lifecycleDeps);
} catch (err) {
+50 -20
View File
@@ -46,11 +46,10 @@ export interface RiggedConfig {
topology: {
root: string;
};
// OPR.0.5.3.7 R4 — the context-pack library landing zone (`rig context add`
// installs here). Config-resolved so a shipped verb runs unmodified on a
// stranger machine — never a hardcoded ~/.openrig literal. Twin of the daemon.
// OPR.0.5.9.5 Wave B — the addressable context library root (`rig context add`
// installs here). The removed packsRoot shape is refused, never bridged.
context: {
packsRoot: string;
root: string;
};
skills: {
root: string;
@@ -231,8 +230,8 @@ const DEFAULTS = {
},
// OPR.0.5.3.6 D1 — derived under the OpenRig home, never a shared-docs literal.
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.3.7 R4 — derived under the OpenRig home, never a shared-docs literal.
context: { packsRoot: getDefaultOpenRigPath("context-packs") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context") },
skills: { root: getDefaultOpenRigPath("skills") },
onboarding: { defaultPack: { enabled: true } },
files: { allowlist: "" },
@@ -362,8 +361,8 @@ export const VALID_KEYS = [
// shared-docs/rigs location stays readable via the daemon's
// resolveLegacyTopologyRigsRoot fallback (advisory-emitting).
"topology.root",
// OPR.0.5.3.7 R4 — context-pack landing zone; lockstep with the daemon twin.
"context.packs_root",
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"skills.root",
"onboarding.default_pack.enabled",
"files.allowlist",
@@ -454,8 +453,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"workspace.projects_root": { primary: "OPENRIG_WORKSPACE_PROJECTS_ROOT" },
"workspace.catalog_path": { primary: "OPENRIG_WORKSPACE_CATALOG_PATH" },
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
// OPR.0.5.3.7 R4 — new key, OPENRIG_* only (no RIGGED_* legacy).
"context.packs_root": { primary: "OPENRIG_CONTEXT_PACKS_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
@@ -534,7 +532,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"workspace.projects_root": ["workspace", "projectsRoot"],
"workspace.catalog_path": ["workspace", "catalogPath"],
"topology.root": ["topology", "root"],
"context.packs_root": ["context", "packsRoot"],
"context.root": ["context", "root"],
"skills.root": ["skills", "root"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"files.allowlist": ["files", "allowlist"],
@@ -586,6 +584,28 @@ function isValidKey(key: string): key is ValidKey {
return (VALID_KEYS as readonly string[]).includes(key);
}
const REMOVED_CONTEXT_KEY = "context.packs_root";
const REMOVED_CONTEXT_ENV = "OPENRIG_CONTEXT_PACKS_ROOT";
export function removedContextSettingMessage(key: string): string | null {
return key === REMOVED_CONTEXT_KEY
? 'Config key "context.packs_root" was removed; use "context.root".'
: null;
}
function assertNoRemovedContextSetting(fileConfig: Record<string, unknown>): void {
if (process.env[REMOVED_CONTEXT_ENV]?.trim()) {
throw new Error(
"OPENRIG_CONTEXT_PACKS_ROOT was removed; use OPENRIG_CONTEXT_ROOT (config key context.root).",
);
}
if (getNestedValue(fileConfig, ["context", "packsRoot"]) !== undefined) {
throw new Error(
'Config file contains removed key "context.packs_root" (context.packsRoot); replace it with "context.root" (context.root).',
);
}
}
// ── OPR.0.4.4.15 (G15-P1) — TWIN of the daemon settings-store's ONE
// registered dynamic key class: `feed.subscriptions.<hostId>.enabled`
// (boolean; v1 per-host key set CLOSED to {enabled}; no env mapping).
@@ -926,7 +946,7 @@ export class ConfigStore {
root: v("topology.root") as string,
},
context: {
packsRoot: v("context.packs_root") as string,
root: v("context.root") as string,
},
skills: {
root: v("skills.root") as string,
@@ -1023,6 +1043,8 @@ export class ConfigStore {
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) throw new Error(removedMessage);
// OPR.0.4.4.15: dynamic class resolves file-or-default (no env in v1).
const feedHost = parseFeedHostSubscriptionKey(key);
if (feedHost) {
@@ -1096,6 +1118,8 @@ export class ConfigStore {
}
set(key: string, value: string): void {
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) throw new Error(removedMessage);
// OPR.0.4.4.15: the registered dynamic class is accepted; every other
// unknown key keeps the reject-loud behavior below byte-for-byte.
const feedHost = parseFeedHostSubscriptionKey(key);
@@ -1132,6 +1156,8 @@ export class ConfigStore {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) throw new Error(removedMessage);
// OPR.0.4.4.15: dynamic-class reset removes the whole host node.
const feedHost = parseFeedHostSubscriptionKey(key);
if (feedHost) {
@@ -1158,15 +1184,19 @@ export class ConfigStore {
}
private readConfigFile(): Record<string, unknown> {
if (!existsSync(this.configPath)) return {};
const raw = readFileSync(this.configPath, "utf-8");
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`
);
let parsed: Record<string, unknown> = {};
if (existsSync(this.configPath)) {
const raw = readFileSync(this.configPath, "utf-8");
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`
);
}
}
assertNoRemovedContextSetting(parsed);
return parsed;
}
}
+38 -38
View File
@@ -3,7 +3,14 @@ import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
import { workspaceScaffoldDirs, workspaceScaffoldFiles } from "./commands/config-init-workspace.js";
import {
ensureDefaultWorkspace,
ensureOpenRigInstance,
formatInstanceInitializationConflicts,
type InitializationFsOps,
type InitWorkspaceResult,
type ManagedPathKind,
} from "@openrig/daemon/instance-initialization";
export interface DaemonState {
pid: number;
@@ -139,6 +146,9 @@ export interface StartOptions {
transcriptsPath?: string;
/** When set, daemon startup idempotently ensures the default workspace exists. */
workspaceRoot?: string;
contextRoot?: string;
skillsRoot?: string;
topologyRoot?: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// Threaded through to the daemon process env so the rotation hook
// picks up file-stored ConfigStore values, not just shell env.
@@ -167,6 +177,9 @@ export interface LifecycleDeps {
removeFile: (path: string) => void;
exists: (path: string) => boolean;
mkdirp: (path: string) => void;
/** Exact production path typing for additive initialization. Tests that do
* not exercise collisions may omit it and use the legacy exists seam. */
pathKind?: (path: string) => ManagedPathKind;
openForAppend: (path: string) => number;
isProcessAlive: (pid: number) => boolean;
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
@@ -215,43 +228,23 @@ const STATUS_PROBE_RETRY_DELAY_MS = 200;
// restores the timeout the removed HEALTHZ_PROBE_TIMEOUT_MS supplied to them.
const HEALTHZ_PROBE_TIMEOUT_MS = 250;
export interface WorkspaceScaffoldResult {
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
export type WorkspaceScaffoldResult = InitWorkspaceResult;
const INITIALIZATION_FILE_NAMES = new Set(["config.json", "SPEC.md", "project.yaml", "workspace.yaml", ".gitignore"]);
function lifecycleInitializationFs(deps: LifecycleDeps): InitializationFsOps {
return {
pathKind: deps.pathKind ?? ((candidate) => {
if (!deps.exists(candidate)) return "missing";
return INITIALIZATION_FILE_NAMES.has(path.basename(candidate)) ? "file" : "directory";
}),
mkdirp: deps.mkdirp,
writeFile: deps.writeFile,
};
}
export function ensureWorkspaceScaffold(root: string, deps: LifecycleDeps): WorkspaceScaffoldResult {
const rootExists = deps.exists(root);
if (!rootExists) deps.mkdirp(root);
const result: WorkspaceScaffoldResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
};
for (const sub of workspaceScaffoldDirs()) {
const subPath = path.join(root, sub);
const existed = deps.exists(subPath);
if (!existed) deps.mkdirp(subPath);
result.subdirs.push({ name: sub, path: subPath, created: !existed });
}
for (const file of workspaceScaffoldFiles()) {
const absPath = path.join(root, file.relPath);
const existed = deps.exists(absPath);
if (existed) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
continue;
}
deps.writeFile(absPath, file.content);
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
return result;
return ensureDefaultWorkspace({ root, fs: lifecycleInitializationFs(deps) });
}
class HealthProbeTimeoutError extends Error {
@@ -611,9 +604,16 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
}
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
deps.mkdirp(OPENRIG_DIR);
if (opts.workspaceRoot) {
ensureWorkspaceScaffold(opts.workspaceRoot, deps);
const initialization = ensureOpenRigInstance({
home: OPENRIG_DIR,
workspaceRoot: opts.workspaceRoot,
contextRoot: opts.contextRoot,
skillsRoot: opts.skillsRoot,
topologyRoot: opts.topologyRoot,
fs: lifecycleInitializationFs(deps),
});
if (!initialization.ok) {
throw new Error(`OpenRig instance initialization blocked: ${formatInstanceInitializationConflicts(initialization)}`);
}
const logFd = deps.openForAppend(LOG_FILE);
@@ -51,6 +51,7 @@ function clearEnv(): () => void {
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS",
"OPENRIG_UI_PREVIEW_MAX_PINS", "OPENRIG_UI_PREVIEW_DEFAULT_LINES",
@@ -121,7 +122,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"workspace.catalog_path",
// OPR.0.5.3.6 D1 — the topology tree root (instance at its top).
"topology.root",
"context.packs_root",
"context.root",
"skills.root",
"onboarding.default_pack.enabled",
"files.allowlist", "progress.scan_roots",
@@ -180,6 +181,29 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect([...VALID_KEYS]).toEqual(expected);
});
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("context.root")).toMatchObject({
value: join(HOISTED_HOME, "context"),
source: "default",
});
process.env["OPENRIG_CONTEXT_ROOT"] = join(tmpDir, "context-library");
expect(store.resolve().context.root).toBe(join(tmpDir, "context-library"));
});
it("refuses every removed context.packs_root input with context.root guidance", () => {
const store = new ConfigStore(configPath);
expect(() => store.get("context.packs_root")).toThrow(/removed.*context\.root/i);
writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } }));
expect(() => store.resolve()).toThrow(/context\.packs_root.*context\.root/i);
writeFileSync(configPath, "{}\n");
process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = "/legacy-env";
expect(() => store.resolve()).toThrow(/OPENRIG_CONTEXT_PACKS_ROOT.*OPENRIG_CONTEXT_ROOT/i);
});
it("W2c idle-gate-qitem cadence defaults to scan=60 and active-wake=900; B6 gate defaults off/empty", () => {
const config = new ConfigStore(configPath).resolve();
const idleGate = (config.policies as typeof config.policies & {
@@ -58,7 +58,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
const tmpHomes: string[] = [];
const tmpSrcs: string[] = [];
const origHome = process.env["OPENRIG_HOME"];
const origPacksRoot = process.env["OPENRIG_CONTEXT_PACKS_ROOT"];
const origContextRoot = process.env["OPENRIG_CONTEXT_ROOT"];
afterEach(() => {
for (const d of [...tmpHomes, ...tmpSrcs]) rmSync(d, { recursive: true, force: true });
@@ -66,15 +66,15 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
tmpSrcs.length = 0;
if (origHome === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = origHome;
if (origPacksRoot === undefined) delete process.env["OPENRIG_CONTEXT_PACKS_ROOT"];
else process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = origPacksRoot;
if (origContextRoot === undefined) delete process.env["OPENRIG_CONTEXT_ROOT"];
else process.env["OPENRIG_CONTEXT_ROOT"] = origContextRoot;
});
function isolatedHome(): string {
const home = mkdtempSync(join(tmpdir(), "ctx-add-home-"));
tmpHomes.push(home);
process.env["OPENRIG_HOME"] = home;
process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = join(home, "context-packs");
process.env["OPENRIG_CONTEXT_ROOT"] = join(home, "context");
return home;
}
@@ -104,7 +104,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
expect(errLogs.join("\n")).toMatch(/unsafe/);
// the REAL pin: rejection happened BEFORE any fs mutation — the target
// store root was never created, nothing was copied anywhere under HOME
expect(existsSync(join(home, "context-packs")), "no store root created").toBe(false);
expect(existsSync(join(home, "context")), "no store root created").toBe(false);
expect(readdirSync(home), "isolated HOME untouched").toEqual([]);
});
@@ -121,7 +121,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
} catch { /* daemon sync may fail against the mock-less port — the WRITE has already happened */ }
console.error = origErr;
console.log = origLog;
expect(existsSync(join(home, "context-packs", "packs", "compaction-restore", "manifest.yaml"))).toBe(true);
expect(existsSync(join(home, "context", "packs", "compaction-restore", "manifest.yaml"))).toBe(true);
});
// Slice-03 lineage repair (R2 terminal HIGH-1): a lexically-safe path-like ref
@@ -134,7 +134,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
tmpSrcs.push(src);
const outside = mkdtempSync(join(tmpdir(), "ctx-add-outside-"));
tmpSrcs.push(outside);
const storeRoot = join(home, "context-packs");
const storeRoot = join(home, "context");
mkdirSync(storeRoot, { recursive: true });
symlinkSync(outside, join(storeRoot, "linked")); // attacker-planted namespace segment
let failed = false;
@@ -159,7 +159,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
const home = isolatedHome();
const src = validSourcePack();
tmpSrcs.push(src);
const storeRoot = join(home, "context-packs");
const storeRoot = join(home, "context");
const outside = join(home, "outside-missing");
const leaf = join(storeRoot, "dangle");
mkdirSync(storeRoot, { recursive: true });
@@ -201,6 +201,6 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE
process.exitCode = origExit;
expect(failed, "add must fail on an unsafe version").toBe(true);
expect(errLogs.join("\n")).toMatch(/version/i);
expect(existsSync(join(home, "context-packs")), "no store root created — rejected before write").toBe(false);
expect(existsSync(join(home, "context")), "no store root created — rejected before write").toBe(false);
});
});
+14 -14
View File
@@ -72,7 +72,7 @@ const FIXTURE_PACK = {
purpose: "Smoke test pack",
taxonomy: "mission",
sourceType: "user_file" as const,
sourcePath: "/home/op/.openrig/context-packs/smoke",
sourcePath: "/home/op/.openrig/context/smoke",
relativePath: "packs/smoke",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
@@ -506,25 +506,25 @@ files:
return { baseUrl: `http://127.0.0.1:${port2}/`, close: () => srv.close() };
}
async function withPacksRoot(fn: (root: string) => Promise<void>): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "openrig-r4-packsroot-"));
const saved = process.env["OPENRIG_CONTEXT_PACKS_ROOT"];
process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = root;
async function withContextRoot(fn: (root: string) => Promise<void>): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "openrig-r4-context-root-"));
const saved = process.env["OPENRIG_CONTEXT_ROOT"];
process.env["OPENRIG_CONTEXT_ROOT"] = root;
try {
await fn(root);
} finally {
if (saved === undefined) delete process.env["OPENRIG_CONTEXT_PACKS_ROOT"];
else process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = saved;
if (saved === undefined) delete process.env["OPENRIG_CONTEXT_ROOT"];
else process.env["OPENRIG_CONTEXT_ROOT"] = saved;
rmSync(root, { recursive: true, force: true });
}
}
const R4_MANIFEST = "name: url-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: SKILL.md\n role: instruction\n";
it("add <url>: installs into the OPENRIG_CONTEXT_PACKS_ROOT landing zone (config-resolved, no hardcoded path)", async () => {
it("add <url>: installs into the OPENRIG_CONTEXT_ROOT landing zone (config-resolved, no hardcoded path)", async () => {
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# hello\nbody\n" });
try {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]);
});
@@ -545,7 +545,7 @@ files:
{ "redirect-manifest": "/pack/manifest.yaml" },
);
try {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}redirect-manifest`, "--name", "redirected-pack"]);
});
@@ -563,7 +563,7 @@ files:
const absManifest = "name: evil-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: http://127.0.0.1:1/secret.md\n role: instruction\n";
const pack = await startPackServer({ "manifest.yaml": absManifest });
try {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]);
});
@@ -577,7 +577,7 @@ files:
});
it("add <url>: unreachable URL fails loud with the reason and leaves NO partial pack", async () => {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", "http://127.0.0.1:1/manifest.yaml"]);
});
@@ -590,7 +590,7 @@ files:
it("add <url>: malformed manifest fails loud with the reason and leaves NO partial pack", async () => {
const pack = await startPackServer({ "manifest.yaml": "name: bad\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: ../escape.md\n role: x\n" });
try {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]);
});
@@ -607,7 +607,7 @@ files:
// manifest declares SKILL.md but the server does not serve it
const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST });
try {
await withPacksRoot(async (root) => {
await withContextRoot(async (root) => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]);
});
+40 -2
View File
@@ -91,7 +91,7 @@ describe("Daemon Lifecycle", () => {
expect(opts.detached).toBe(true);
});
it("start: idempotently scaffolds configured workspace before spawning", async () => {
it("start: initializes the canonical instance and configured workspace before spawning", async () => {
const created = new Set<string>();
const written = new Map<string, string>();
const deps = startableDeps({
@@ -99,8 +99,32 @@ describe("Daemon Lifecycle", () => {
mkdirp: vi.fn((p: string) => { created.add(p); }),
writeFile: vi.fn((p: string, content: string) => { written.set(p, content); }),
});
await startDaemon({ port: 7433, db: "openrig.sqlite", workspaceRoot: "/tmp/openrig-workspace" }, deps);
await startDaemon({
port: 7433,
db: "openrig.sqlite",
workspaceRoot: "/tmp/openrig-workspace",
contextRoot: "/tmp/openrig-context",
skillsRoot: "/tmp/openrig-skills",
topologyRoot: "/tmp/openrig-topology",
}, deps);
for (const root of [
path.join(OPENRIG_DIR, "state"),
"/tmp/openrig-context",
path.join("/tmp/openrig-context", "system"),
"/tmp/openrig-skills",
path.join(OPENRIG_DIR, "specs"),
"/tmp/openrig-topology",
path.join(OPENRIG_DIR, "plugins"),
path.join(OPENRIG_DIR, "run"),
path.join(OPENRIG_DIR, "logs"),
path.join(OPENRIG_DIR, "transcripts"),
path.join(OPENRIG_DIR, "backups"),
path.join(OPENRIG_DIR, "secrets"),
]) {
expect(created.has(root), root).toBe(true);
}
expect(written.get(path.join(OPENRIG_DIR, "config.json"))).toBe("{}\n");
expect(created.has("/tmp/openrig-workspace")).toBe(true);
expect(created.has(path.join("/tmp/openrig-workspace", "missions"))).toBe(true);
expect(created.has(path.join("/tmp/openrig-workspace", "exhaust"))).toBe(true);
@@ -110,6 +134,20 @@ describe("Daemon Lifecycle", () => {
expect(written.get(path.join("/tmp/openrig-workspace", ".gitignore"))).toContain("/exhaust/");
});
it("start: refuses an exact user-owned type conflict before any write or spawn", async () => {
const conflict = "/tmp/openrig-context";
const deps = startableDeps({
pathKind: vi.fn((candidate: string) => candidate === conflict ? "file" : "missing"),
});
await expect(startDaemon({ contextRoot: conflict }, deps)).rejects.toThrow(
`${conflict}: expected directory, found file`,
);
expect(deps.mkdirp).not.toHaveBeenCalled();
expect(deps.writeFile).not.toHaveBeenCalled();
expect(deps.spawn).not.toHaveBeenCalled();
});
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
const deps = startableDeps();
+4 -1
View File
@@ -12,7 +12,10 @@
"@openrig/daemon/spec-conformance": [
"../daemon/dist/spec-conformance-surface.d.ts"
],
"@openrig/daemon/gateway-protocol": ["../daemon/dist/gateway-protocol-surface.d.ts"]
"@openrig/daemon/gateway-protocol": ["../daemon/dist/gateway-protocol-surface.d.ts"],
"@openrig/daemon/instance-initialization": [
"../daemon/dist/instance-initialization-surface.d.ts"
]
}
},
"include": ["src"],
@@ -3,7 +3,7 @@
<!-- world-claim: world-example-purpose -->
A world pack situates an agent: it describes where the agent is, what exists there, and how to act.
<!-- world-claim: world-example-install -->
Use `rig context add <pack-directory>` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.packs_root` only when a path is needed.
Use `rig context add <pack-directory>` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.root` only when a path is needed.
<!-- world-claim: world-example-authoring -->
Copy this pack, update its manifest, and replace each prompt below with your world's facts.
@@ -124,7 +124,7 @@ claims:
kind: judgment
flagged: "Authored purpose and taste; no live scan can prove it."
- id: world-example-install
statement: "Use `rig context add <pack-directory>` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.packs_root` only when a path is needed."
statement: "Use `rig context add <pack-directory>` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.root` only when a path is needed."
kind: operational
check: world-example-install
- id: world-example-authoring
@@ -368,19 +368,19 @@ else
fail private-ref-boundary 'the world-public or local-world namespace projection is false'
fi
configured_packs_root=$(rig config get context.packs_root 2>/dev/null)
configured_packs_status=$?
configured_context_root=$(rig config get context.root 2>/dev/null)
configured_context_status=$?
example_install_ok=1
[ "$configured_packs_status" -eq 0 ] || example_install_ok=0
[ -n "$configured_packs_root" ] || example_install_ok=0
if [ -n "${OPENRIG_CONTEXT_PACKS_ROOT:-}" ] && [ "$configured_packs_root" != "$OPENRIG_CONTEXT_PACKS_ROOT" ]; then
[ "$configured_context_status" -eq 0 ] || example_install_ok=0
[ -n "$configured_context_root" ] || example_install_ok=0
if [ -n "${OPENRIG_CONTEXT_ROOT:-}" ] && [ "$configured_context_root" != "$OPENRIG_CONTEXT_ROOT" ]; then
example_install_ok=0
fi
rig context add --help >/dev/null 2>&1 || example_install_ok=0
rig context list --help >/dev/null 2>&1 || example_install_ok=0
probe_ref="world-public-verify-$$"
probe_target="$configured_packs_root/$probe_ref"
probe_target="$configured_context_root/$probe_ref"
probe_installed=0
cleanup_probe() {
[ "$probe_installed" -eq 1 ] || return 0
@@ -517,7 +517,7 @@ if [ "$example_regions_ok" -eq 1 ] &&
grep -Fq 'rig context get world-example' "$example/your-world.md" &&
grep -Fq 'rig context add <pack-directory>' "$example/your-world.md" &&
grep -Fq 'rig context list' "$example/your-world.md" &&
grep -Fq 'rig config get context.packs_root' "$example/your-world.md" &&
grep -Fq 'rig config get context.root' "$example/your-world.md" &&
grep -Fq "Copy this pack, update its manifest, and replace each prompt below with your world's facts." "$example/your-world.md" &&
grep -Fq 'Run `rig context get world-example`, copy this pack, and make the project a book world by describing the writer, manuscript, sources, editorial rules, decisions, current draft state, and next useful actions in a few coherent files.' "$example/your-world.md" &&
grep -Fq "Name the world and the agent's place in it." "$example/your-world.md" &&
+4
View File
@@ -36,6 +36,10 @@
"./skill-loadout": {
"types": "./dist/skill-loadout-surface.d.ts",
"import": "./dist/skill-loadout-surface.js"
},
"./instance-initialization": {
"types": "./dist/instance-initialization-surface.d.ts",
"import": "./dist/instance-initialization-surface.js"
}
},
"scripts": {
@@ -60,9 +60,9 @@ export interface RouteContextPacksInput {
/** Relative paths to context-pack manifest.yaml files declared in the
* bundle manifest's context_packs[] block. */
declaredContextPacks: string[];
/** Absolute path to the operator context-packs library (per
* context-pack-types.ts:9-10: typically `<openrigHome>/context-packs`
* or a workspace-local `.openrig/context-packs`). Caller resolves. */
/** Absolute path to the operator context library (per
* context-pack-types.ts:9-10: typically `<context.root>`; the caller may
* also route to a workspace-local `.openrig/context-packs`). */
targetContextPacksDir: string;
}
@@ -6,7 +6,7 @@
// reviewable, sendable. Parallel to skills + workflow_specs in shape.
//
// MVP single-host context: storage filesystem-canonical at
// ~/.openrig/context-packs/<name>/ (host) + workspace-local
// <context.root>/<ref>/ (host; default $OPENRIG_HOME/context) + workspace-local
// .openrig/context-packs/<name>/. NO new SQLite tables; library cache
// is in-memory at the daemon scope.
@@ -1,7 +1,7 @@
// Rig Context / Composable Context Injection v0 (PL-014) — manifest
// parser.
//
// Parses ~/.openrig/context-packs/<name>/manifest.yaml into a typed
// Parses <context.root>/<ref>/manifest.yaml into a typed
// shape with structured-error rejects on malformed input. Pure
// (no fs touches in the parser itself; caller hands in raw YAML).
@@ -0,0 +1,104 @@
import { join } from "node:path";
import {
ensureDefaultWorkspace,
nodeInitializationFs,
type InitializationConflict,
type InitializationFsOps,
} from "./workspace/default-workspace-scaffold.js";
export interface OpenRigInstanceInitializationOptions {
home: string;
workspaceRoot?: string;
contextRoot?: string;
skillsRoot?: string;
topologyRoot?: string;
dryRun?: boolean;
fs?: InitializationFsOps;
}
export interface OpenRigInstanceInitializationResult {
ok: boolean;
home: string;
createdPaths: string[];
conflicts: InitializationConflict[];
dryRun: boolean;
}
export function openRigContextLibraryRoots(contextRoot: string): [string, string] {
return [contextRoot, join(contextRoot, "system")];
}
/** Reconcile one canonical OpenRig instance layout. This is the shared owner
* for CLI setup and daemon first-start. It owns empty roots and config.json;
* S01 owns workspace contents, S04 owns skill contents, and topology owns its
* contents. */
export function ensureOpenRigInstance(
options: OpenRigInstanceInitializationOptions,
): OpenRigInstanceInitializationResult {
const fs = options.fs ?? nodeInitializationFs();
const dryRun = options.dryRun ?? false;
const workspaceRoot = options.workspaceRoot ?? join(options.home, "workspace");
const contextRoot = options.contextRoot ?? join(options.home, "context");
const skillsRoot = options.skillsRoot ?? join(options.home, "skills");
const topologyRoot = options.topologyRoot ?? join(options.home, "topology");
const [contextLibraryRoot, systemContextRoot] = openRigContextLibraryRoots(contextRoot);
const directories = [...new Set([
options.home,
join(options.home, "state"),
contextLibraryRoot,
systemContextRoot,
skillsRoot,
join(options.home, "specs"),
topologyRoot,
join(options.home, "plugins"),
join(options.home, "run"),
join(options.home, "logs"),
join(options.home, "transcripts"),
join(options.home, "backups"),
join(options.home, "secrets"),
])];
const configPath = join(options.home, "config.json");
const conflicts: InitializationConflict[] = [];
for (const path of directories) {
const actual = fs.pathKind(path);
if (actual !== "missing" && actual !== "directory") {
conflicts.push({ path, expected: "directory", actual });
}
}
const configKind = fs.pathKind(configPath);
if (configKind !== "missing" && configKind !== "file") {
conflicts.push({ path: configPath, expected: "file", actual: configKind });
}
const workspacePlan = ensureDefaultWorkspace({ root: workspaceRoot, dryRun: true, fs });
conflicts.push(...workspacePlan.conflicts);
if (conflicts.length > 0) {
return { ok: false, home: options.home, createdPaths: [], conflicts, dryRun };
}
const missingDirectories = directories.filter((path) => fs.pathKind(path) === "missing");
const createdPaths = [
...missingDirectories,
...(configKind === "missing" ? [configPath] : []),
...(workspacePlan.rootCreated ? [workspaceRoot] : []),
...workspacePlan.subdirs.filter((entry) => entry.created).map((entry) => entry.path),
...workspacePlan.files.filter((entry) => entry.created).map((entry) => entry.absPath),
];
if (!dryRun) {
for (const path of missingDirectories) fs.mkdirp(path);
if (configKind === "missing") fs.writeFile(configPath, "{}\n");
ensureDefaultWorkspace({ root: workspaceRoot, fs });
}
return { ok: true, home: options.home, createdPaths, conflicts: [], dryRun };
}
export function formatInstanceInitializationConflicts(
result: Pick<OpenRigInstanceInitializationResult, "conflicts">,
): string {
return result.conflicts
.map((conflict) => `${conflict.path}: expected ${conflict.expected}, found ${conflict.actual}`)
.join("; ");
}
@@ -37,11 +37,10 @@ const DEFAULT_TOPOLOGY_ROOT = path.join(
"topology",
);
// OPR.0.5.3.7 R4 — the context-pack library landing zone's derived default,
// under $OPENRIG_HOME, matching the CLI twin's getDefaultOpenRigPath("context-packs").
const DEFAULT_CONTEXT_PACKS_ROOT = path.join(
// OPR.0.5.9.5 Wave B — canonical addressable context library.
const DEFAULT_CONTEXT_ROOT = path.join(
process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"),
"context-packs",
"context",
);
const DEFAULT_SKILLS_ROOT = path.join(
@@ -102,8 +101,8 @@ export const SETTINGS_VALID_KEYS = [
// for that literal, so walkers carry none. Lockstep with the CLI
// config-store twin (each side's parity test pins its own list).
"topology.root",
// OPR.0.5.3.7 R4 — context-pack landing zone; lockstep with the CLI twin.
"context.packs_root",
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"skills.root",
"onboarding.default_pack.enabled",
"files.allowlist",
@@ -228,8 +227,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"workspace.projects_root": { primary: "OPENRIG_WORKSPACE_PROJECTS_ROOT" },
"workspace.catalog_path": { primary: "OPENRIG_WORKSPACE_CATALOG_PATH" },
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
// OPR.0.5.3.7 R4 — new key, OPENRIG_* only (no RIGGED_* legacy).
"context.packs_root": { primary: "OPENRIG_CONTEXT_PACKS_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
@@ -306,7 +304,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"workspace.projects_root": ["workspace", "projectsRoot"],
"workspace.catalog_path": ["workspace", "catalogPath"],
"topology.root": ["topology", "root"],
"context.packs_root": ["context", "packsRoot"],
"context.root": ["context", "root"],
"skills.root": ["skills", "root"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
"files.allowlist": ["files", "allowlist"],
@@ -371,6 +369,28 @@ export function isSettingsValidKey(key: string): key is SettingsValidKey {
return (SETTINGS_VALID_KEYS as readonly string[]).includes(key);
}
const REMOVED_CONTEXT_KEY = "context.packs_root";
const REMOVED_CONTEXT_ENV = "OPENRIG_CONTEXT_PACKS_ROOT";
export function removedContextSettingMessage(key: string): string | null {
return key === REMOVED_CONTEXT_KEY
? 'Config key "context.packs_root" was removed; use "context.root".'
: null;
}
function assertNoRemovedContextSetting(fileConfig: Record<string, unknown>): void {
if (process.env[REMOVED_CONTEXT_ENV]?.trim()) {
throw new Error(
"OPENRIG_CONTEXT_PACKS_ROOT was removed; use OPENRIG_CONTEXT_ROOT (config key context.root).",
);
}
if (getNestedValue(fileConfig, ["context", "packsRoot"]) !== undefined) {
throw new Error(
'Config file contains removed key "context.packs_root" (context.packsRoot); replace it with "context.root" (context.root).',
);
}
}
// ── OPR.0.4.4.15 (guard G15-P1 fold, arch-endorsed) ─────────────────────────
// ONE registered dynamic key CLASS — NOT a general dynamic-key mechanism:
// `feed.subscriptions.<hostId>.enabled` (boolean; the v1 per-host key set is
@@ -539,7 +559,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "workspace.root": return DEFAULT_WORKSPACE_ROOT;
// OPR.0.5.3.6 D1 — derived under $OPENRIG_HOME, never a shared-docs literal.
case "topology.root": return DEFAULT_TOPOLOGY_ROOT;
case "context.packs_root": return DEFAULT_CONTEXT_PACKS_ROOT;
case "context.root": return DEFAULT_CONTEXT_ROOT;
case "skills.root": return DEFAULT_SKILLS_ROOT;
case "onboarding.default_pack.enabled": return true;
// Preview Terminal v0 (PL-018) defaults — match cli/src/config-store.ts.
@@ -772,6 +792,8 @@ export interface ClaudeCompactionPolicy {
export interface ResolvedConfig {
skillsRoot: string;
contextRoot: string;
topologyRoot: string;
workspaceRoot: string;
workspaceSlicesRoot: string;
workspaceSteeringPath: string;
@@ -802,6 +824,7 @@ export class SettingsStore {
resolveOne(key: SettingsValidKey, fileConfig?: Record<string, unknown>, workspaceRoot?: string): ResolvedSetting {
const fc = fileConfig ?? this.readConfigFile();
assertNoRemovedContextSetting(fc);
const wr = workspaceRoot ?? this.resolveWorkspaceRootRaw(fc);
const defaultValue = getDefaultValue(key, wr);
// Slice 27 BLOCKING-FIX-2 — env override is validated; on invalid
@@ -857,6 +880,8 @@ export class SettingsStore {
const wr = this.resolveWorkspaceRootRaw(fc);
return {
skillsRoot: this.resolveOne("skills.root", fc, wr).value as string,
contextRoot: this.resolveOne("context.root", fc, wr).value as string,
topologyRoot: this.resolveOne("topology.root", fc, wr).value as string,
workspaceRoot: wr,
workspaceSlicesRoot: this.resolveOne("workspace.slices_root", fc, wr).value as string,
workspaceSteeringPath: this.resolveOne("workspace.steering_path", fc, wr).value as string,
@@ -916,6 +941,8 @@ export class SettingsStore {
}
set(key: string, value: string): void {
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) throw new Error(removedMessage);
// OPR.0.4.4.15: the ONE registered dynamic class is accepted here;
// every OTHER unknown key keeps the reject-loud behavior below
// byte-for-byte.
@@ -986,6 +1013,8 @@ export class SettingsStore {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) throw new Error(removedMessage);
// OPR.0.4.4.15: dynamic-class reset removes the whole host node
// (unsubscribe leaves no residue).
const feedHost = parseFeedHostSubscriptionKey(key);
@@ -1019,15 +1048,19 @@ export class SettingsStore {
}
private readConfigFile(): Record<string, unknown> {
if (!existsSync(this.configPath)) return {};
const raw = readFileSync(this.configPath, "utf-8");
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`,
);
let parsed: Record<string, unknown> = {};
if (existsSync(this.configPath)) {
const raw = readFileSync(this.configPath, "utf-8");
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
throw new Error(
`Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`,
);
}
}
assertNoRemovedContextSetting(parsed);
return parsed;
}
}
@@ -1,8 +1,8 @@
// Canonical daemon-side project-workspace scaffold.
//
// Keep this byte-compatible with
// packages/cli/src/commands/config-init-workspace.ts. The two packages do not
// depend on one another, and the parity test pins their shared output.
// Canonical project-workspace scaffold. S01 owns these bytes; S05's instance
// initializer calls this owner rather than copying its behavior.
import { lstatSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const WORKSPACE_DIRS = ["missions", "exhaust"] as const;
@@ -52,3 +52,102 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
{ relPath: ".gitignore", content: WORKSPACE_GITIGNORE },
];
}
export type ManagedPathKind = "missing" | "file" | "directory" | "other";
export interface InitializationFsOps {
pathKind(path: string): ManagedPathKind;
mkdirp(path: string): void;
writeFile(path: string, content: string): void;
}
export interface InitializationConflict {
path: string;
expected: "file" | "directory";
actual: Exclude<ManagedPathKind, "missing">;
}
export interface InitWorkspaceResult {
ok: boolean;
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
conflicts: InitializationConflict[];
dryRun: boolean;
}
export function nodeInitializationFs(): InitializationFsOps {
return {
pathKind(path) {
try {
const value = lstatSync(path);
if (value.isDirectory()) return "directory";
if (value.isFile()) return "file";
return "other";
} catch {
return "missing";
}
},
mkdirp: (path) => mkdirSync(path, { recursive: true }),
writeFile: (path, content) => writeFileSync(path, content, "utf8"),
};
}
/** Additively reconcile the S01 project workspace. All collisions are found
* before the first write, so a malformed user-owned path never leaves a
* half-created scaffold. */
export function ensureDefaultWorkspace(options: {
root: string;
dryRun?: boolean;
fs?: InitializationFsOps;
}): InitWorkspaceResult {
const fs = options.fs ?? nodeInitializationFs();
const dryRun = options.dryRun ?? false;
const rootKind = fs.pathKind(options.root);
const subdirs = workspaceScaffoldDirs().map((name) => {
const path = join(options.root, name);
return { name, path, created: fs.pathKind(path) === "missing" };
});
const files = workspaceScaffoldFiles().map(({ relPath }) => {
const absPath = join(options.root, relPath);
const created = fs.pathKind(absPath) === "missing";
return { relPath, absPath, created, skipped: created ? null : "exists" as const };
});
const conflicts: InitializationConflict[] = [];
if (rootKind !== "missing" && rootKind !== "directory") {
conflicts.push({ path: options.root, expected: "directory", actual: rootKind });
}
for (const subdir of subdirs) {
const actual = fs.pathKind(subdir.path);
if (actual !== "missing" && actual !== "directory") {
conflicts.push({ path: subdir.path, expected: "directory", actual });
}
}
for (const file of files) {
const actual = fs.pathKind(file.absPath);
if (actual !== "missing" && actual !== "file") {
conflicts.push({ path: file.absPath, expected: "file", actual });
}
}
if (conflicts.length === 0 && !dryRun) {
if (rootKind === "missing") fs.mkdirp(options.root);
for (const subdir of subdirs) if (subdir.created) fs.mkdirp(subdir.path);
for (const file of workspaceScaffoldFiles()) {
const absPath = join(options.root, file.relPath);
if (fs.pathKind(absPath) === "missing") fs.writeFile(absPath, file.content);
}
}
return {
ok: conflicts.length === 0,
root: options.root,
rootCreated: rootKind === "missing",
subdirs,
files,
conflicts,
dryRun,
};
}
+16
View File
@@ -25,6 +25,11 @@ import {
} from "./middleware/auth-bearer-token.js";
import type { SlowOperationInstrumentation } from "./domain/slow-op-recorder.js";
import type { ProviderService } from "./domain/provider/provider-service.js";
import { SettingsStore } from "./domain/user-settings/settings-store.js";
import {
ensureOpenRigInstance,
formatInstanceInitializationConflicts,
} from "./domain/instance-initialization.js";
/** OPR.0.4.3.21 (51elv2) — bound the graceful-shutdown recorder drain. */
export const SLOW_OP_SHUTDOWN_DRAIN_TIMEOUT_MS = 5_000;
@@ -230,6 +235,17 @@ export async function startServer(port?: number) {
// D15 — anchor the default db under OPENRIG_HOME, never a bare CWD-relative
// filename (which could open the shared fleet db). Explicit OPENRIG_DB wins.
const dbPath = resolveDaemonDbPath(readOpenRigEnv("OPENRIG_DB", "RIGGED_DB"), OPENRIG_HOME);
const instanceConfig = new SettingsStore().resolveConfig();
const initialization = ensureOpenRigInstance({
home: OPENRIG_HOME,
workspaceRoot: instanceConfig.workspaceRoot,
contextRoot: instanceConfig.contextRoot,
skillsRoot: instanceConfig.skillsRoot,
topologyRoot: instanceConfig.topologyRoot,
});
if (!initialization.ok) {
throw new Error(`OpenRig instance initialization blocked: ${formatInstanceInitializationConflicts(initialization)}`);
}
// bug-fix slice auth-bearer-tailscale-trust: distinguish "explicit
// operator opt-in" from "default" by treating an undefined env as
@@ -0,0 +1,16 @@
export {
ensureOpenRigInstance,
formatInstanceInitializationConflicts,
type OpenRigInstanceInitializationOptions,
type OpenRigInstanceInitializationResult,
} from "./domain/instance-initialization.js";
export {
ensureDefaultWorkspace,
nodeInitializationFs,
workspaceScaffoldDirs,
workspaceScaffoldFiles,
type InitializationConflict,
type InitializationFsOps,
type InitWorkspaceResult,
type ManagedPathKind,
} from "./domain/workspace/default-workspace-scaffold.js";
+4 -6
View File
@@ -456,11 +456,9 @@ async function routeAgentImagesAfterBootstrap(bundlePath: string): Promise<Route
* the operator context-packs library. Returns null when bundle has no
* context_packs[] (no-op).
*
* Target resolution: <openrigHome>/context-packs — per startup.ts:496
* (the user-file root the live ContextPackLibraryService is constructed
* against). No SettingsStore complexity (unlike workflow_specs);
* context-packs canonical path is OPENRIG_HOME-rooted per
* context-pack-types.ts:9-10.
* Target resolution uses context.root, exactly like the live
* ContextPackLibraryService. A configured root replaces the default; bundle
* routing must not silently create a second writable library.
*
* Mirror of routeSkillsAfterBootstrap / routePluginsAfterBootstrap /
* routeWorkflowSpecsAfterBootstrap pattern: takes bundlePath only
@@ -468,7 +466,7 @@ async function routeAgentImagesAfterBootstrap(bundlePath: string): Promise<Route
* fire on the dual-override path too).
*/
async function routeContextPacksAfterBootstrap(bundlePath: string): Promise<RouteContextPacksResult | null> {
const targetContextPacksDir = getDefaultOpenRigPath("context-packs");
const targetContextPacksDir = new ContextPackSettingsStore().resolveOne("context.root").value as string;
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-context-packs-route-"));
try {
await unpack(bundlePath, tmpDir);
+16 -42
View File
@@ -13,17 +13,15 @@
// agents stay on CLI-shell-out per the shipped openrig-user-settings skill.
import { Hono } from "hono";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
SETTINGS_VALID_KEYS,
isSettingsValidKey,
parseFeedHostSubscriptionKey,
removedContextSettingMessage,
type SettingsStore,
} from "../domain/user-settings/settings-store.js";
import {
workspaceScaffoldDirs,
workspaceScaffoldFiles,
ensureDefaultWorkspace,
} from "../domain/workspace/default-workspace-scaffold.js";
interface InitWorkspaceBody {
@@ -41,7 +39,11 @@ export function configRoutes(): Hono {
if (!store) return c.json({ error: "settings_unavailable" }, 503);
// OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the
// static settings map (existing consumers of `settings` unaffected).
return c.json({ settings: store.resolveAllWithSource(), feedHostSubscriptions: store.listFeedHostSubscriptions() });
try {
return c.json({ settings: store.resolveAllWithSource(), feedHostSubscriptions: store.listFeedHostSubscriptions() });
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
router.post("/init-workspace", async (c) => {
@@ -50,49 +52,17 @@ export function configRoutes(): Hono {
const body = (await c.req.json<InitWorkspaceBody>().catch(() => ({}))) as InitWorkspaceBody;
const root = body.root ?? (store.resolveOne("workspace.root").value as string);
const dryRun = !!body.dryRun;
// Precompute both sides before mutation so the daemon and CLI retain the
// same inspect-then-write shape.
let scaffoldFiles: ReturnType<typeof workspaceScaffoldFiles>;
let scaffoldDirs: ReturnType<typeof workspaceScaffoldDirs>;
try {
scaffoldFiles = workspaceScaffoldFiles();
scaffoldDirs = workspaceScaffoldDirs();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: "init_workspace_scaffold_unavailable", message }, 400);
}
const subdirs: Array<{ name: string; path: string; created: boolean }> = [];
const files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }> = [];
const rootExists = existsSync(root);
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of scaffoldDirs) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
subdirs.push({ name: sub, path: subPath, created: !subExists });
}
for (const file of scaffoldFiles) {
const absPath = join(root, file.relPath);
const fileExists = existsSync(absPath);
if (fileExists) {
files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
}
return c.json({ root, rootCreated: !rootExists, subdirs, files, dryRun });
const result = ensureDefaultWorkspace({ root, dryRun });
if (!result.ok) return c.json({ error: "init_workspace_conflict", ...result }, 409);
return c.json(result);
});
router.get("/:key", (c) => {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400);
// OPR.0.4.4.15: the ONE registered dynamic class resolves here; every
// other unknown key keeps the 400 below byte-for-byte.
const dynamic = store.resolveFeedHostSubscription(key);
@@ -107,6 +77,8 @@ export function configRoutes(): Hono {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400);
const isDynamic = parseFeedHostSubscriptionKey(key) !== null;
if (!isDynamic && !isSettingsValidKey(key)) {
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
@@ -127,6 +99,8 @@ export function configRoutes(): Hono {
const store = c.get("settingsStore" as never) as SettingsStore | undefined;
if (!store) return c.json({ error: "settings_unavailable" }, 503);
const key = c.req.param("key");
const removedMessage = removedContextSettingMessage(key);
if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400);
const isDynamic = parseFeedHostSubscriptionKey(key) !== null;
if (!isDynamic && !isSettingsValidKey(key)) {
return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400);
+10 -6
View File
@@ -109,6 +109,7 @@ import { PluginDiscoveryService } from "./domain/plugin-discovery-service.js";
// Slice 28 Checkpoint C-3 — skill-library discovery (SC-29 #11 cumulative).
import { SkillLibraryDiscoveryService } from "./domain/skill-library-discovery.js";
import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js";
import { openRigContextLibraryRoots } from "./domain/instance-initialization.js";
import { AgentImageLibraryService } from "./domain/agent-images/agent-image-library-service.js";
import { SnapshotCapturer } from "./domain/agent-images/snapshot-capturer.js";
import { SettingsStore as ContextPackSettingsStore } from "./domain/user-settings/settings-store.js";
@@ -749,18 +750,21 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// routes. Startup context-pack expansion is intentionally unsupported;
// dedicated send/broadcast/walk/queue verbs own delivery.
const contextPackLibrary = (() => {
// OPR.0.5.3.7 R4 — config-resolved (env > config > $OPENRIG_HOME/context-packs),
// the same key `rig context add` writes to, so both agree on the landing zone.
const userPacksRoot = new ContextPackSettingsStore().resolveOne("context.packs_root").value as string;
try { fs.mkdirSync(userPacksRoot, { recursive: true }); } catch { /* best-effort */ }
// OPR.0.5.9.5 Wave B — config-resolved canonical context library. The
// shared instance initializer normally creates both roots; the mkdirs keep
// direct createDaemon test harnesses compatible.
const userPacksRoot = new ContextPackSettingsStore().resolveOne("context.root").value as string;
const [contextRoot, systemPacksRoot] = openRigContextLibraryRoots(userPacksRoot);
try { fs.mkdirSync(systemPacksRoot, { recursive: true }); } catch { /* best-effort */ }
const roots: Array<{ path: string; sourceType: "builtin" | "user_file" | "workspace" }> = [
{ path: userPacksRoot, sourceType: "user_file" },
{ path: contextRoot, sourceType: "user_file" },
{ path: systemPacksRoot, sourceType: "user_file" },
];
try {
const settingsStore = new ContextPackSettingsStore();
const cfg = settingsStore.resolveConfig();
const workspacePacksRoot = nodePath.join(cfg.workspaceRoot, ".openrig", "context-packs");
if (workspacePacksRoot !== userPacksRoot && fs.existsSync(workspacePacksRoot)) {
if (workspacePacksRoot !== userPacksRoot && workspacePacksRoot !== systemPacksRoot && fs.existsSync(workspacePacksRoot)) {
roots.push({ path: workspacePacksRoot, sourceType: "workspace" });
}
} catch { /* settings unavailable; fall through with user-file root only */ }
@@ -43,7 +43,7 @@ function packFixture(packParentDir: string): { dirs: string[]; files: string[] }
}
const BUNDLE_ROOT = "/bundle/root";
const TARGET = "/operator/.openrig/context-packs";
const TARGET = "/operator/.openrig/context";
function makeInput(overrides?: Partial<RouteContextPacksInput>): RouteContextPacksInput {
return {
+20 -6
View File
@@ -2310,16 +2310,19 @@ describe("Bundle API routes", () => {
});
// Item 6 / Checkpoint 7.3f step 3: /install routes declared context_packs
// after successful bootstrap. Target = <openrigHome>/context-packs
// after successful bootstrap. Target = the configured context.root
// (startup.ts:496 user-file root). Bundle has context_packs[] with paths
// to context-pack manifest.yaml files; router copies the parent dir to
// <openrigHome>/context-packs/<dirname>/. Consumer-scan reachability
// <context.root>/<dirname>/. Consumer-scan reachability
// proven via real ContextPackLibraryService.scan() against the routed
// root (guard d491eca9 + 3cd581e3 file-vs-dir discrimination lessons).
it("POST /api/bundles/install routes declared context_packs after successful bootstrap (completed branch) — proves consumer-scan reachability", async () => {
const origHome = process.env.OPENRIG_HOME;
const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-context-packs-route-test-"));
const configuredContextRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-context-root-test-"));
process.env.OPENRIG_HOME = auditHome;
process.env.OPENRIG_CONTEXT_ROOT = configuredContextRoot;
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
@@ -2385,7 +2388,7 @@ files:
// Router copies the PARENT DIR of manifest.yaml: declared
// "packages/test-pkg/context-packs/intent/manifest.yaml" → parent
// basename "intent" → target/intent/
const expectedPackDir = path.join(auditHome, "context-packs", "intent");
const expectedPackDir = path.join(configuredContextRoot, "intent");
expect(fs.existsSync(expectedPackDir)).toBe(true);
expect(fs.existsSync(path.join(expectedPackDir, "manifest.yaml"))).toBe(true);
expect(fs.existsSync(path.join(expectedPackDir, "brief.md"))).toBe(true);
@@ -2394,7 +2397,7 @@ files:
// against the routed target root + assert the pack is visible.
const { ContextPackLibraryService } = await import("../src/domain/context-packs/context-pack-library-service.js");
const consumer = new ContextPackLibraryService({
roots: [{ path: path.join(auditHome, "context-packs"), sourceType: "user_file" }],
roots: [{ path: configuredContextRoot, sourceType: "user_file" }],
});
const scanResult = consumer.scan();
expect(scanResult.count).toBeGreaterThanOrEqual(1);
@@ -2412,7 +2415,10 @@ files:
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT;
else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot;
fs.rmSync(auditHome, { recursive: true, force: true });
fs.rmSync(configuredContextRoot, { recursive: true, force: true });
}
});
@@ -2421,8 +2427,10 @@ files:
// 5f410eee decoupling lesson; PROACTIVELY shipped).
it("POST /api/bundles/install routes declared context_packs even when both --skip-version-check and --force are set (B1 mirror)", async () => {
const origHome = process.env.OPENRIG_HOME;
const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-cpacks-dual-override-"));
process.env.OPENRIG_HOME = auditHome;
process.env.OPENRIG_CONTEXT_ROOT = path.join(auditHome, "context");
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
@@ -2482,7 +2490,7 @@ files:
expect(body.contextPacksRouting).toBeDefined();
expect(body.contextPacksRouting.routedCount).toBe(1);
expect(body.contextPacksRouting.records[0].status).toBe("routed");
expect(fs.existsSync(path.join(auditHome, "context-packs", "dualcontext", "manifest.yaml"))).toBe(true);
expect(fs.existsSync(path.join(auditHome, "context", "dualcontext", "manifest.yaml"))).toBe(true);
} finally {
fs.rmSync(targetRoot, { recursive: true, force: true });
}
@@ -2490,6 +2498,8 @@ files:
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT;
else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
@@ -2500,8 +2510,10 @@ files:
// status=missing, NO pack copied to target.
it("POST /api/bundles/install context_packs degenerate-input: declared manifest absent → status=missing, no false routedCount", async () => {
const origHome = process.env.OPENRIG_HOME;
const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-cpacks-degenerate-"));
process.env.OPENRIG_HOME = auditHome;
process.env.OPENRIG_CONTEXT_ROOT = path.join(auditHome, "context");
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
@@ -2550,7 +2562,7 @@ files:
expect(body.contextPacksRouting.rejectedCount).toBe(1);
expect(body.contextPacksRouting.records[0].status).toBe("missing");
// CRUCIAL: nothing landed at target
expect(fs.existsSync(path.join(auditHome, "context-packs", "nonexistent"))).toBe(false);
expect(fs.existsSync(path.join(auditHome, "context", "nonexistent"))).toBe(false);
} finally {
fs.rmSync(targetRoot, { recursive: true, force: true });
}
@@ -2558,6 +2570,8 @@ files:
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT;
else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it } from "vitest";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import {
ensureOpenRigInstance,
openRigContextLibraryRoots,
} from "../src/domain/instance-initialization.js";
const roots: string[] = [];
function freshRoot(): string {
const root = mkdtempSync(join(tmpdir(), "openrig-instance-init-"));
roots.push(root);
return root;
}
function tree(root: string): Array<{ path: string; kind: "directory" | "file"; content?: string }> {
const out: Array<{ path: string; kind: "directory" | "file"; content?: string }> = [];
const walk = (dir: string): void => {
for (const name of readdirSync(dir).sort()) {
const abs = join(dir, name);
const rel = relative(root, abs);
if (statSync(abs).isDirectory()) {
out.push({ path: rel, kind: "directory" });
walk(abs);
} else {
out.push({ path: rel, kind: "file", content: readFileSync(abs, "utf8") });
}
}
};
walk(root);
return out;
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe("canonical OpenRig instance initialization", () => {
it("declares the canonical addressable and System World library roots together", () => {
expect(openRigContextLibraryRoots("/instance/context")).toEqual([
"/instance/context",
"/instance/context/system",
]);
});
it("creates the canonical roots plus the S01 workspace through one idempotent owner", () => {
const parent = freshRoot();
const home = join(parent, "home");
const first = ensureOpenRigInstance({ home });
const firstTree = tree(home);
const second = ensureOpenRigInstance({ home });
expect(first.ok).toBe(true);
expect(first.createdPaths.length).toBeGreaterThan(0);
expect(second).toMatchObject({ ok: true, createdPaths: [], conflicts: [] });
expect(tree(home)).toEqual(firstTree);
expect(firstTree).toEqual(expect.arrayContaining([
{ path: "config.json", kind: "file", content: "{}\n" },
{ path: "state", kind: "directory" },
{ path: "context", kind: "directory" },
{ path: "context/system", kind: "directory" },
{ path: "skills", kind: "directory" },
{ path: "workspace", kind: "directory" },
{ path: "workspace/missions", kind: "directory" },
{ path: "workspace/exhaust", kind: "directory" },
{ path: "specs", kind: "directory" },
{ path: "topology", kind: "directory" },
{ path: "plugins", kind: "directory" },
{ path: "run", kind: "directory" },
{ path: "logs", kind: "directory" },
{ path: "transcripts", kind: "directory" },
{ path: "backups", kind: "directory" },
{ path: "secrets", kind: "directory" },
]));
});
it("preserves user-owned files byte-for-byte", () => {
const home = join(freshRoot(), "home");
mkdirSync(join(home, "workspace"), { recursive: true });
writeFileSync(join(home, "config.json"), "{\"operator\":true}\n");
writeFileSync(join(home, "workspace", "SPEC.md"), "# Mine\n");
const result = ensureOpenRigInstance({ home });
expect(result.ok).toBe(true);
expect(readFileSync(join(home, "config.json"), "utf8")).toBe("{\"operator\":true}\n");
expect(readFileSync(join(home, "workspace", "SPEC.md"), "utf8")).toBe("# Mine\n");
});
it("reports exact type conflicts and performs no partial writes", () => {
const home = join(freshRoot(), "home");
mkdirSync(home, { recursive: true });
writeFileSync(join(home, "context"), "owned file\n");
const result = ensureOpenRigInstance({ home });
expect(result.ok).toBe(false);
expect(result.conflicts).toEqual([
{ path: join(home, "context"), expected: "directory", actual: "file" },
]);
expect(existsSync(join(home, "state"))).toBe(false);
expect(readFileSync(join(home, "context"), "utf8")).toBe("owned file\n");
});
it("treats a user-owned symlink as a conflict instead of writing through it", () => {
const parent = freshRoot();
const home = join(parent, "home");
const outside = join(parent, "outside");
mkdirSync(home, { recursive: true });
mkdirSync(outside);
symlinkSync(outside, join(home, "context"));
const result = ensureOpenRigInstance({ home });
expect(result.conflicts).toContainEqual({
path: join(home, "context"),
expected: "directory",
actual: "other",
});
expect(readdirSync(outside)).toEqual([]);
expect(existsSync(join(home, "state"))).toBe(false);
});
});
+22 -22
View File
@@ -177,7 +177,7 @@ function writeRigFixture(options: {
{ relativePath: "world/install", sourceType: "user_file", sourcePath: "/fixture/private-world" },
]);
const configRoot = options.configRoot === undefined
? '"${OPENRIG_CONTEXT_PACKS_ROOT:-/fixture/context-packs}"'
? '"${OPENRIG_CONTEXT_ROOT:-/fixture/context}"'
: `'${options.configRoot}'`;
writeFileSync(
join(fixturePath, "world-public-get.txt"),
@@ -223,13 +223,13 @@ function writeRigFixture(options: {
" fi",
' [ "$4" = "--name" ] || exit 64',
` if [ ${options.partialSync ? 1 : 0} -eq 1 ]; then`,
' target="$OPENRIG_CONTEXT_PACKS_ROOT/$5"',
' target="$OPENRIG_CONTEXT_ROOT/$5"',
' mkdir -p "$target"',
' printf \'%s\\n\' \'name: partial-sync-fixture\' > "$target/manifest.yaml"',
' printf \'{"installedAt":"%s","syncError":"HTTP 503"}\\n\' "$target"',
" exit 0",
" fi",
' target="$OPENRIG_CONTEXT_PACKS_ROOT/$5"',
' target="$OPENRIG_CONTEXT_ROOT/$5"',
' mkdir -p "$target"',
' cp -R "$3"/. "$target"/',
' printf \'%s\\n\' "$5" > "$install_state"',
@@ -238,7 +238,7 @@ function writeRigFixture(options: {
' if [ -f "$install_state" ]; then',
' installed_ref=$(sed -n \'1p\' "$install_state")',
' cat "$fixture_dir/context-list.json" | sed \'s/]$//\'',
" printf ',{\"relativePath\":\"%s\",\"sourceType\":\"user_file\",\"sourcePath\":\"%s/%s\"}]\\n' \"$installed_ref\" \"$OPENRIG_CONTEXT_PACKS_ROOT\" \"$installed_ref\"",
" printf ',{\"relativePath\":\"%s\",\"sourceType\":\"user_file\",\"sourcePath\":\"%s/%s\"}]\\n' \"$installed_ref\" \"$OPENRIG_CONTEXT_ROOT\" \"$installed_ref\"",
" else",
' cat "$fixture_dir/context-list.json"',
" fi",
@@ -246,10 +246,10 @@ function writeRigFixture(options: {
` [ ${options.partialSync ? 1 : 0} -eq 0 ] || exit 72`,
' [ -f "$install_state" ] || exit 1',
' [ "$(sed -n \'1p\' "$install_state")" = "$3" ] || exit 1',
' rm -rf -- "$OPENRIG_CONTEXT_PACKS_ROOT/$3"',
' rm -rf -- "$OPENRIG_CONTEXT_ROOT/$3"',
' rm -f "$install_state"',
' printf \'{"removed":true,"ref":"%s"}\\n\' "$3"',
'elif [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "context.packs_root" ]; then',
'elif [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "context.root" ]; then',
` printf '%s\\n' ${configRoot}`,
'elif [ "$1" = "whoami" ]; then',
` printf '%s\\n' '${options.identityJson ?? '{"identity":{"rigName":"fixture","memberId":"qa","sessionName":"qa@fixture"}}'}'`,
@@ -425,7 +425,7 @@ describe("public world pack", () => {
const result = runVerifier(redPack, {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -458,7 +458,7 @@ describe("public world pack", () => {
const result = runVerifier(redPack, {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -505,7 +505,7 @@ describe("public world pack", () => {
const result = runVerifier(redPack, {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -520,7 +520,7 @@ describe("public world pack", () => {
const fixtureEnv = {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
};
@@ -547,7 +547,7 @@ describe("public world pack", () => {
const wrongIdentity = runVerifier(packDir, {
...process.env,
PATH: `${wrongIdentityPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -559,7 +559,7 @@ describe("public world pack", () => {
const wrongTopology = runVerifier(packDir, {
...process.env,
PATH: `${wrongTopologyPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -585,7 +585,7 @@ describe("public world pack", () => {
const result = runVerifier(publicWorldDir(), {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -596,11 +596,11 @@ describe("public world pack", () => {
});
it("fails when the context store projection ignores its typed override", () => {
const rigPath = writeRigFixture({ configRoot: "/fixture/wrong-context-packs" });
const rigPath = writeRigFixture({ configRoot: "/fixture/wrong-context" });
const result = runVerifier(publicWorldDir(), {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/expected-context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/expected-context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -614,7 +614,7 @@ describe("public world pack", () => {
const result = runVerifier(publicWorldDir(), {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -632,7 +632,7 @@ describe("public world pack", () => {
const result = runVerifier(publicWorldDir(), {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs",
OPENRIG_CONTEXT_ROOT: "/fixture/context",
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -648,7 +648,7 @@ describe("public world pack", () => {
const result = runVerifier(publicWorldDir(), {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: contextRoot,
OPENRIG_CONTEXT_ROOT: contextRoot,
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
});
@@ -662,7 +662,7 @@ describe("public world pack", () => {
const strangerRoot = mkdtempSync(join(tmpdir(), "public-world-stranger-"));
temporaryRoots.push(strangerRoot);
const openrigHome = join(strangerRoot, "openrig-home");
const contextRoot = join(openrigHome, "context-packs");
const contextRoot = join(openrigHome, "context");
mkdirSync(openrigHome);
expect(readdirSync(openrigHome)).toEqual([]);
@@ -671,7 +671,7 @@ describe("public world pack", () => {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_HOME: openrigHome,
OPENRIG_CONTEXT_PACKS_ROOT: contextRoot,
OPENRIG_CONTEXT_ROOT: contextRoot,
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
};
@@ -721,7 +721,7 @@ describe("public world pack", () => {
const env = {
...process.env,
PATH: `${rigPath}:${process.env.PATH ?? ""}`,
OPENRIG_CONTEXT_PACKS_ROOT: join(strangerRoot, "context-packs"),
OPENRIG_CONTEXT_ROOT: join(strangerRoot, "context"),
OPENRIG_SESSION_NAME: "qa@fixture",
RIGGED_SESSION_NAME: "",
};
@@ -747,7 +747,7 @@ describe("public world pack", () => {
expect(exercise).toContain("rig context get world-example");
expect(exercise).toContain("rig context add <pack-directory>");
expect(exercise).toContain("rig context list");
expect(exercise).toContain("rig config get context.packs_root");
expect(exercise).toContain("rig config get context.root");
expect(exercise).not.toMatch(/identity\/|ontology\/|terrain\//);
const packDir = publicWorldDir();
@@ -22,6 +22,7 @@ function clearEnv(): () => void {
"OPENRIG_PORT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
@@ -93,7 +94,7 @@ describe("config routes (User Settings v0)", () => {
// + 2 B6 founder ruling (policies.idle_gate_qitem.auto_register /
// opt_in_sessions — the not-default-on gate)
// + 1 OPR.0.5.3.6 D1 (topology.root — the topology tree root)
// + 1 OPR.0.5.3.7 R4 (context.packs_root)
// + 1 OPR.0.5.9.5 Wave B (context.root)
// + 1 S15 (onboarding.default_pack.enabled)
// + 1 S04 (queue.pickup_stall_threshold_minutes)
// + 2 S02 (queue.stuck_sweep_interval_seconds /
@@ -113,6 +114,7 @@ describe("config routes (User Settings v0)", () => {
expect(body.settings["onboarding.default_pack.enabled"]).toMatchObject({ value: true, source: "default" });
expect(String(body.settings["workspace.projects_root"]?.value)).toMatch(/projects$/);
expect(String(body.settings["workspace.catalog_path"]?.value)).toMatch(/workspace\.yaml$/);
expect(String(body.settings["context.root"]?.value)).toMatch(/context$/);
expect(body.settings["workspace.field_notes_root"]).toBeUndefined();
expect(body.settings["workspace.dogfood_evidence_root"]).toBeUndefined();
});
@@ -153,6 +155,27 @@ describe("config routes (User Settings v0)", () => {
expect(body.validKeys).toContain("workspace.root");
});
it.each(["GET", "POST", "DELETE"] as const)(
"%s /api/config/context.packs_root refuses the removed key with its replacement",
async (method) => {
const app = buildApp();
const res = await app.request("/api/config/context.packs_root", {
method,
headers: { "Content-Type": "application/json" },
body: method === "POST" ? JSON.stringify({ value: "/legacy" }) : undefined,
});
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ replacement: "context.root" });
},
);
it("GET /api/config refuses a persisted context.packsRoot before projecting settings", async () => {
writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } }));
const res = await buildApp().request("/api/config");
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: expect.stringContaining("context.root") });
});
it("POST /api/config/:key sets the value and persists to disk", async () => {
const app = buildApp();
const res = await app.request("/api/config/workspace.slices_root", {
@@ -32,6 +32,7 @@ function clearEnv(): () => void {
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_SKILLS_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS",
@@ -101,7 +102,7 @@ describe("SettingsStore (User Settings v0)", () => {
"workspace.catalog_path",
// OPR.0.5.3.6 D1 — the topology tree root (instance at its top).
"topology.root",
"context.packs_root",
"context.root",
"skills.root",
"onboarding.default_pack.enabled",
"files.allowlist", "progress.scan_roots",
@@ -162,6 +163,25 @@ describe("SettingsStore (User Settings v0)", () => {
]);
});
it("resolves context.root and refuses the removed key, file field, and env", () => {
const store = new SettingsStore(configPath);
expect(store.resolveOne("context.root")).toMatchObject({
value: expect.stringMatching(/context$/),
source: "default",
});
process.env["OPENRIG_CONTEXT_ROOT"] = join(tmpDir, "context-library");
expect(store.resolveOne("context.root")).toMatchObject({ value: join(tmpDir, "context-library"), source: "env" });
delete process.env["OPENRIG_CONTEXT_ROOT"];
expect(() => store.set("context.packs_root", "/legacy")).toThrow(/removed.*context\.root/i);
writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } }));
expect(() => store.resolveConfig()).toThrow(/context\.packs_root.*context\.root/i);
writeFileSync(configPath, "{}\n");
process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = "/legacy-env";
expect(() => store.resolveOne("context.root")).toThrow(/OPENRIG_CONTEXT_PACKS_ROOT.*OPENRIG_CONTEXT_ROOT/i);
});
it("W2c idle-gate-qitem cadence defaults to scan=60 and active-wake=900", () => {
const store = new SettingsStore(configPath);
expect(() => store.resolveOne("policies.idle_gate_qitem.scan_interval_seconds"))