mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
feat(policy): surface lifecycle discovery
This commit is contained in:
1 parent
01cfe05f9c
commit
e9b258914f
4 files changed
+243
-30
No files matched your search
Generated
+1
-22
@@ -11,8 +11,7 @@
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
"packages/ui",
|
||||
"packages/cli",
|
||||
"packages/tui"
|
||||
"packages/cli"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
@@ -1167,10 +1166,6 @@
|
||||
"resolved": "packages/daemon",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openrig/tui": {
|
||||
"resolved": "packages/tui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@openrig/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
@@ -7473,7 +7468,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-tui": "tui/dist/main.js",
|
||||
"rig": "dist/bin-wrapper.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -7510,21 +7504,6 @@
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@openrig/tui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-tui": "dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.4.7",
|
||||
|
||||
@@ -287,7 +287,7 @@ export class RigInstantiator {
|
||||
|
||||
import { RigSpecCodec as PodRigSpecCodec } from "./rigspec-codec.js";
|
||||
import { RigSpecSchema as PodRigSpecSchema, VALID_EDGE_KINDS } from "./rigspec-schema.js";
|
||||
import { rigPreflight, preflightValidatedSpec } from "./rigspec-preflight.js";
|
||||
import { permissionPolicyDiscoveryWarnings, rigPreflight, preflightValidatedSpec } from "./rigspec-preflight.js";
|
||||
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
|
||||
import { resolveNodeConfig } from "./profile-resolver.js";
|
||||
import { resolveStartup } from "./startup-resolver.js";
|
||||
@@ -343,7 +343,8 @@ export interface MaterializeResult {
|
||||
specName: string;
|
||||
specVersion: string;
|
||||
nodes: Array<{ logicalId: string; status: "materialized" }>;
|
||||
/** NONFATAL READY-path preflight warnings (e.g. managed activity-hook delivery gaps). */
|
||||
/** NONFATAL READY-path warnings: managed activity-hook delivery gaps (main's floor) FOLLOWED BY
|
||||
* permission-policy lifecycle-discovery warnings (§6 reconciliation — activity-hook-first). */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@@ -685,7 +686,12 @@ export class PodRigInstantiator {
|
||||
specName: rigSpec.name,
|
||||
specVersion: rigSpec.version,
|
||||
nodes: nodeResults,
|
||||
warnings: carryWarnings && carryWarnings.length > 0 ? carryWarnings : undefined,
|
||||
// §6 reconciliation (activity-hook-first, policy-appended): both proven warning sources ride;
|
||||
// undefined-when-empty preserved from main's floor.
|
||||
warnings: ((): string[] | undefined => {
|
||||
const merged = [...(carryWarnings ?? []), ...permissionPolicyDiscoveryWarnings(rigSpec, { rigRoot, fsOps: this.deps.fsOps })];
|
||||
return merged.length > 0 ? merged : undefined;
|
||||
})(),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -1084,10 +1090,10 @@ export class PodRigInstantiator {
|
||||
const nodeResults: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string }[] = [];
|
||||
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
|
||||
const launchedSessionNames: string[] = []; // Track for orphan cleanup on total failure
|
||||
// Seed with the READY-path preflight warnings (nonfatal — e.g. managed activity-hook
|
||||
// delivery gaps) so they ride along in the successful instantiate result alongside
|
||||
// launch/materialization warnings; rig up stays rc0.
|
||||
const podInstantiateWarnings: string[] = [...preflight.warnings];
|
||||
// §6 reconciliation (activity-hook-first, policy-appended): seed with the READY-path preflight
|
||||
// warnings (nonfatal — managed activity-hook delivery gaps, main's floor) FIRST, then append the
|
||||
// permission-policy lifecycle-discovery warnings; both proven sources ride, rig up stays rc0.
|
||||
const podInstantiateWarnings: string[] = [...preflight.warnings, ...permissionPolicyDiscoveryWarnings(rigSpec, { rigRoot, fsOps: this.deps.fsOps })];
|
||||
// Store per-member context for deferred launch
|
||||
const memberContext = new Map<string, { pod: typeof rigSpec.pods[0]; member: typeof rigSpec.pods[0]["members"][0]; podId: string; nodeId: string; resolveResult: any; configResult: any }>();
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ import { resolveNodeConfig, type ResolutionContext } from "./profile-resolver.js
|
||||
import { getOpenRigInstallCwdError, resolveLaunchCwd } from "./cwd-resolution.js";
|
||||
import nodePath from "node:path";
|
||||
import { validateClaudeActivityHookDelivery, CLAUDE_ACTIVITY_HOOKS_RESOURCE_TYPE } from "./claude-activity-hooks.js";
|
||||
import {
|
||||
resolvePermissionPolicyAttachment,
|
||||
resolvePermissionPolicyRefValue,
|
||||
} from "./permission-policy/policy-ref.js";
|
||||
|
||||
const SUPPORTED_RUNTIMES = new Set(["claude-code", "codex", "pi", "terminal"]);
|
||||
|
||||
@@ -162,6 +166,33 @@ export interface PreflightSpecContext {
|
||||
claudeActivityAssets?: { relayPath?: string; manifestPath?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seam C: describe each seat's effective permission-policy attachment without
|
||||
* changing readiness or state. The existing Seam-B resolver remains the one
|
||||
* source of truth for member-over-rig precedence, origin, and launch posture.
|
||||
*/
|
||||
export function permissionPolicyDiscoveryWarnings(
|
||||
rigSpec: PodRigSpec,
|
||||
preflightCtx: Pick<PreflightSpecContext, "rigRoot" | "fsOps">,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
for (const pod of rigSpec.pods) {
|
||||
for (const member of pod.members) {
|
||||
const logicalId = `${pod.id}.${member.id}`;
|
||||
const ref = resolvePermissionPolicyRefValue(member.permissionPolicy, rigSpec.permissionPolicy);
|
||||
if (!ref) {
|
||||
warnings.push(`${logicalId}: permission_policy absent; launch_posture=floor`);
|
||||
continue;
|
||||
}
|
||||
const attachment = resolvePermissionPolicyAttachment(ref, preflightCtx.rigRoot, {
|
||||
readFile: (path) => preflightCtx.fsOps.readFile(path),
|
||||
});
|
||||
warnings.push(`${logicalId}: permission_policy ref="${attachment.ref}" origin=${attachment.origin} launch_posture=${attachment.launchPosture}`);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebooted rig preflight: validates rig spec, resolves all agent refs + profiles,
|
||||
* checks runtimes and cwd. Pure domain, no side effects beyond filesystem reads.
|
||||
@@ -205,7 +236,7 @@ export async function rigPreflight(input: RigPreflightInput & { exec?: (cmd: str
|
||||
*/
|
||||
export async function preflightValidatedSpec(rigSpec: PodRigSpec, preflightCtx: PreflightSpecContext): Promise<PreflightResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const warnings = permissionPolicyDiscoveryWarnings(rigSpec, preflightCtx);
|
||||
|
||||
// 2. Validate session name components for all pod members
|
||||
const effectiveRigName = preflightCtx.rigNameOverride ?? rigSpec.name;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rigPreflight } from "../src/domain/rigspec-preflight.js";
|
||||
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
|
||||
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { SystemPreflight } from "../../cli/src/system-preflight.js";
|
||||
|
||||
const RIG_ROOT = "/project/rigs/policy-discovery";
|
||||
const AGENT_YAML = `name: impl
|
||||
version: "1.0.0"
|
||||
resources:
|
||||
skills: []
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: []
|
||||
`;
|
||||
const CUSTOM_POLICY = `---
|
||||
policy_schema_version: 1
|
||||
name: operator-full
|
||||
source: custom
|
||||
description: full-bypass flag policy
|
||||
surface: flag
|
||||
launch_posture: full_bypass
|
||||
---
|
||||
# Operator full
|
||||
`;
|
||||
|
||||
function fsOps(): AgentResolverFsOps {
|
||||
return {
|
||||
exists: (path) => path.includes("agents/impl") || path.endsWith("policies/operator-full.md"),
|
||||
readFile: (path) => {
|
||||
if (path.includes("agents/impl")) return AGENT_YAML;
|
||||
if (path.endsWith("policies/operator-full.md")) return CUSTOM_POLICY;
|
||||
throw new Error(`Not found: ${path}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rigYaml(opts: { name?: string; rigPolicy?: string; memberPolicy?: string } = {}): string {
|
||||
const rigPolicy = opts.rigPolicy ? `permission_policy: ${opts.rigPolicy}\n` : "";
|
||||
const memberPolicy = opts.memberPolicy ? ` permission_policy: ${opts.memberPolicy}\n` : "";
|
||||
return `version: "0.2"
|
||||
name: ${opts.name ?? "policy-discovery"}
|
||||
${rigPolicy}pods:
|
||||
- id: dev
|
||||
label: Dev
|
||||
members:
|
||||
- id: impl
|
||||
runtime: claude-code
|
||||
agent_ref: local:agents/impl
|
||||
profile: default
|
||||
${memberPolicy} cwd: .
|
||||
edges: []
|
||||
edges: []
|
||||
`;
|
||||
}
|
||||
|
||||
const policyLines = (warnings: string[]) => warnings.filter((warning) => warning.includes("permission_policy"));
|
||||
|
||||
describe("Seam C permission-policy discovery", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("surfaces an attached builtin ref with honest origin and bound posture", async () => {
|
||||
const result = await rigPreflight({
|
||||
rigSpecYaml: rigYaml({ rigPolicy: "builtin:yolo" }),
|
||||
rigRoot: RIG_ROOT,
|
||||
fsOps: fsOps(),
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(policyLines(result.warnings)).toEqual([
|
||||
'dev.impl: permission_policy ref="builtin:yolo" origin=builtin launch_posture=full_bypass',
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces the per-member custom override instead of the rig-level policy", async () => {
|
||||
const result = await rigPreflight({
|
||||
rigSpecYaml: rigYaml({ rigPolicy: "builtin:standard", memberPolicy: "policies/operator-full.md" }),
|
||||
rigRoot: RIG_ROOT,
|
||||
fsOps: fsOps(),
|
||||
});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(policyLines(result.warnings)).toEqual([
|
||||
'dev.impl: permission_policy ref="policies/operator-full.md" origin=custom launch_posture=full_bypass',
|
||||
]);
|
||||
expect(result.warnings.join("\n")).not.toContain("builtin:standard");
|
||||
});
|
||||
|
||||
it("surfaces honest absence as the floor without errors or blocking", async () => {
|
||||
const result = await rigPreflight({ rigSpecYaml: rigYaml(), rigRoot: RIG_ROOT, fsOps: fsOps() });
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(policyLines(result.warnings)).toEqual([
|
||||
"dev.impl: permission_policy absent; launch_posture=floor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps launch outcomes identical while carrying discovery on the rig-up result", async () => {
|
||||
const bindings: NodeBinding[] = [];
|
||||
const adapter: RuntimeAdapter = {
|
||||
runtime: "claude-code",
|
||||
listInstalled: async () => [],
|
||||
project: async () => ({ projected: [], skipped: [], failed: [] }),
|
||||
deliverStartup: async () => ({ delivered: 0, failed: [] }),
|
||||
launchHarness: async (binding) => { bindings.push(binding); return { ok: true }; },
|
||||
checkReady: async () => ({ ready: true }),
|
||||
} as RuntimeAdapter;
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const setup = createTestApp(db, {
|
||||
adapters: { "claude-code": adapter },
|
||||
podInstantiatorFsOps: fsOps(),
|
||||
});
|
||||
const absent = await setup.podInstantiator.instantiate(rigYaml({ name: "policy-absent" }), RIG_ROOT);
|
||||
const attached = await setup.podInstantiator.instantiate(
|
||||
rigYaml({ name: "policy-attached", rigPolicy: "builtin:locked" }),
|
||||
RIG_ROOT,
|
||||
);
|
||||
|
||||
expect(absent.ok).toBe(true);
|
||||
expect(attached.ok).toBe(true);
|
||||
if (!absent.ok || !attached.ok) return;
|
||||
expect(absent.result.nodes.map(({ logicalId, status }) => ({ logicalId, status })))
|
||||
.toEqual(attached.result.nodes.map(({ logicalId, status }) => ({ logicalId, status })));
|
||||
expect(bindings).toHaveLength(2);
|
||||
expect(policyLines(absent.result.warnings ?? [])).toEqual([
|
||||
"dev.impl: permission_policy absent; launch_posture=floor",
|
||||
]);
|
||||
expect(policyLines(attached.result.warnings ?? [])).toEqual([
|
||||
'dev.impl: permission_policy ref="builtin:locked" origin=builtin launch_posture=floor',
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps SYSTEM setup preflight policy-blind while rig lifecycle preflight discovers policy", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "seam-c-system-preflight-"));
|
||||
tempDirs.push(home);
|
||||
const system = new SystemPreflight({
|
||||
exec: async (cmd) => cmd === "tmux -V" ? "tmux 3.6" : "",
|
||||
configStore: {
|
||||
resolve: () => ({
|
||||
daemon: { host: "127.0.0.1", port: 0 },
|
||||
db: { path: join(home, "openrig.db") },
|
||||
transcripts: { path: join(home, "transcripts") },
|
||||
}),
|
||||
} as never,
|
||||
getDaemonStatus: async () => ({ state: "stopped" }),
|
||||
openrigHome: home,
|
||||
});
|
||||
|
||||
const systemResult = await system.run({ port: 0 });
|
||||
expect(JSON.stringify(systemResult)).not.toContain("permission_policy");
|
||||
|
||||
const lifecycleResult = await rigPreflight({
|
||||
rigSpecYaml: rigYaml({ rigPolicy: "builtin:standard" }),
|
||||
rigRoot: RIG_ROOT,
|
||||
fsOps: fsOps(),
|
||||
});
|
||||
expect(policyLines(lifecycleResult.warnings)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an invalid ref upstream and never misreports it as floor discovery", async () => {
|
||||
const invalid = await rigPreflight({
|
||||
rigSpecYaml: rigYaml({ rigPolicy: "builtin:missing" }),
|
||||
rigRoot: RIG_ROOT,
|
||||
fsOps: fsOps(),
|
||||
});
|
||||
|
||||
expect(invalid.ready).toBe(false);
|
||||
expect(invalid.errors.join("\n")).toContain("unknown built-in policy 'missing'");
|
||||
expect(policyLines(invalid.warnings)).toEqual([]);
|
||||
|
||||
const valid = await rigPreflight({
|
||||
rigSpecYaml: rigYaml({ rigPolicy: "builtin:standard" }),
|
||||
rigRoot: RIG_ROOT,
|
||||
fsOps: fsOps(),
|
||||
});
|
||||
expect(valid.ready).toBe(true);
|
||||
expect(policyLines(valid.warnings)).toEqual([
|
||||
'dev.impl: permission_policy ref="builtin:standard" origin=builtin launch_posture=floor',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user