mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix: renew content approval for verified OpenRig Codex plugin upgrades
This commit is contained in:
1 parent
32633a3d8f
commit
ed8ca39666
4 files changed
+282
-30
No files matched your search
@@ -37,10 +37,12 @@ launched before this change, a manual run) the hooks do what they always did. Th
|
||||
the seat's own `settings.local.json` copy only when that entry is exactly OpenRig's and the script it names is the same
|
||||
script; an entry from an older install doesn't match, so both report until the seat's next launch refreshes it.
|
||||
OpenRig automatically installs its Codex plugin for seats that select it. On its first installation, the six exact shipped
|
||||
hook definitions are trusted before launch, after checking the installed hook files against this daemon's shipped copy.
|
||||
Existing plugin disables and hook choices are retained. An existing untrusted installation stays under native review:
|
||||
Codex's "continue without trusting" leaves no record, so OpenRig does not assume an absent trust record is permission.
|
||||
The file check happens only when trust is granted: a later edit to the cached hook scripts keeps that trust, as with any hook trusted in Codex.
|
||||
hook definitions and bundle content are trusted before launch, after checking the entire installed plugin against this daemon's shipped copy.
|
||||
On upgrade, OpenRig renews content approval only for a previously approved OpenRig-owned bundle with unchanged approved hook definitions.
|
||||
It keeps explicit disables, refusals, unknown config layers and other plugins' state. An installation without a proven prior approval stays under native review.
|
||||
Codex's "continue without trusting" leaves no record: a skip of an otherwise eligible OpenRig-owned update may therefore be superseded on retry.
|
||||
OpenRig records its approved bundle in `CODEX_HOME/.openrig-core-hook-trust.json` and leaves Codex's immutable `.hook-trust` baseline untouched.
|
||||
Codex 0.164 checks bundle contents when hooks run; a later edit requires native approval again. Older Codex versions enforce their own trust rules.
|
||||
The Codex
|
||||
column above then applies only to a Codex seat whose plugin selection includes openrig-core: every Codex hook command
|
||||
exits 0 before running anything unless OpenRig gave that seat `OPENRIG_SEAT_PROJECT_DIR` and an explicit
|
||||
|
||||
@@ -66,6 +66,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
|
||||
|
||||
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
|
||||
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
|
||||
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
|
||||
|
||||
// Shared by all probes of ONE launch, never reset by a delayed screen or an
|
||||
// ambiguous transport result. A separately requested launch gets a new attempt.
|
||||
@@ -311,9 +312,9 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
/**
|
||||
* Install the vendored openrig-core plugin into Codex through Codex's own commands, so a seat that selected it gets its
|
||||
* refocus and skills, trusting only exact shipped hooks on its first install. It runs only when the person's config
|
||||
* refocus and skills, trusting exact shipped hooks on first install and renewing proven owned content approvals. It runs only when the person's config
|
||||
* doesn't disable the plugin (codexCorePluginEnablement), and only when the installed version differs from the vendored
|
||||
* one. Codex owns its plugin cache; OpenRig writes the marketplace manifest and first-install exact-hook trust.
|
||||
* one. Codex owns its plugin cache; its immutable legacy trust baseline is never rewritten.
|
||||
*/
|
||||
async ensureCodexCorePlugin(): Promise<{ outcome: "installed" | "current" | "skipped"; reason: string }> {
|
||||
const report = (outcome: "installed" | "current" | "skipped", reason: string) => {
|
||||
@@ -328,28 +329,19 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (typeof version !== "string" || !/^[0-9A-Za-z._-]+$/.test(version)) return report("skipped", "the vendored plugin's Codex manifest has no usable version");
|
||||
|
||||
const shipped = nodePath.resolve(import.meta.dirname, "../../assets/plugins/openrig-core");
|
||||
const ownFiles: string[] = [];
|
||||
const collect = (dir: string, relative = "hooks") => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const child = nodePath.join(relative, entry.name);
|
||||
if (entry.isDirectory()) collect(nodePath.join(dir, entry.name), child);
|
||||
else ownFiles.push(child);
|
||||
}
|
||||
};
|
||||
collect(nodePath.join(shipped, "hooks"));
|
||||
ownFiles.push(".codex-plugin/plugin.json");
|
||||
const matchesShipped = (dir: string) => ownFiles.every(relative => {
|
||||
try { return fs.readFileSync(nodePath.join(dir, relative)).equals(fs.readFileSync(nodePath.join(shipped, relative))); }
|
||||
const matchesShipped = (dir: string) => {
|
||||
try { return codexPluginContentHash(dir, true) === codexPluginContentHash(shipped, true); }
|
||||
catch { return false; }
|
||||
});
|
||||
if (!matchesShipped(pluginDir)) return report("skipped", "vendored hook files differ from this daemon's shipped plugin; existing choices are left alone");
|
||||
};
|
||||
if (!matchesShipped(pluginDir)) return report("skipped", "vendored files differ from this daemon's shipped plugin; existing choices are left alone");
|
||||
|
||||
const codexHome = nodePath.dirname(this.resolveCodexConfigPath());
|
||||
const layers: CodexConfigLayerRead[] = codexReadableConfigLayers(codexHome, this.codexSystemConfigDir).map((layer) => {
|
||||
const readLayers = (): CodexConfigLayerRead[] => codexReadableConfigLayers(codexHome, this.codexSystemConfigDir).map((layer) => {
|
||||
if (!this.fs.exists(layer.path)) return { ...layer, state: "absent" as const };
|
||||
try { return { ...layer, state: "read" as const, content: this.fs.readFile(layer.path) }; }
|
||||
catch (error) { return { ...layer, state: "unreadable" as const, reason: (error as Error).message }; }
|
||||
});
|
||||
const layers = readLayers();
|
||||
const enablement = codexCorePluginEnablement(layers);
|
||||
if (enablement.decision !== "allow") return report("skipped", enablement.reason);
|
||||
|
||||
@@ -370,11 +362,27 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const cachedDir = nodePath.join(cacheRoot, version);
|
||||
const cached = this.fs.exists(cachedDir);
|
||||
// Codex's "continue without trusting" choice writes no state. An existing install/configuration may therefore be a
|
||||
// decline even with no hooks.state entry. Only the very first installation earns automatic trust; reinstalls and
|
||||
// upgrades retain whatever native choices already exist, including an unrecorded decline.
|
||||
// decline even with no hooks.state entry. Absent grants still stay under native review. A proven owned grant may
|
||||
// renew its content approval; an unrecorded skip of that exact owned upgrade cannot be distinguished from pending approval.
|
||||
const firstInstall = !this.fs.exists(cacheRoot) && !layers.some(layer => layer.state === "read"
|
||||
&& Object.hasOwn((parseToml(layer.content).plugins ?? {}) as object, CODEX_CORE_PLUGIN_KEY));
|
||||
const renewContentApproval = (freshGrant = false) => {
|
||||
const configPath = this.resolveCodexConfigPath();
|
||||
const currentLayers = readLayers();
|
||||
if (codexCorePluginEnablement(currentLayers).decision !== "allow") return;
|
||||
const before = this.fs.readFile(configPath);
|
||||
const currentSource = recordedMarketplaceSource(before);
|
||||
if (currentSource == null || canonical(currentSource) !== canonical(pluginsDir) || !userLayerEnablesCorePlugin(before)) return;
|
||||
let content = before;
|
||||
const planned = planOwnedCodexContentApproval({ configPath, content, layers: currentLayers, shipped, installed: cachedDir, freshGrant });
|
||||
if (!planned) return;
|
||||
for (const [key, hash] of Object.entries(planned.hashes)) content = upsertCodexHookTrust(content, key, hash, "trusted_content_hash");
|
||||
if (this.fs.readFile(configPath) !== before) return; // A concurrent native choice wins.
|
||||
if (content !== before) this.fs.writeFile(configPath, content);
|
||||
recordOwnedCodexApproval(configPath, planned.approval);
|
||||
};
|
||||
if (cached && recorded !== undefined && userLayerEnablesCorePlugin(userConfig)) {
|
||||
renewContentApproval();
|
||||
return report("current", `${CODEX_CORE_PLUGIN_KEY} ${version} is installed and registered`);
|
||||
}
|
||||
const manifestPath = nodePath.join(pluginsDir, CODEX_MARKETPLACE_MANIFEST_PATH);
|
||||
@@ -390,9 +398,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
const installed = await this.runCodexCli(["plugin", "add", CODEX_CORE_PLUGIN_KEY], env);
|
||||
if (installed.status !== 0) return report("skipped", `codex plugin add failed: ${(installed.stderr || installed.stdout).trim() || `exit ${installed.status}`}`);
|
||||
// Trust is over normalized definitions, not script contents. Byte equality is an additional pre-grant check;
|
||||
// Codex remains responsible for later native review if definitions change. No cache files are rewritten here.
|
||||
if (firstInstall && matchesShipped(cachedDir)) {
|
||||
// First install grants declarations; Codex0.164 also requires full-bundle content approval. Existing declaration
|
||||
// choices are never rewritten. No cache files or native baseline files are rewritten here.
|
||||
let freshGrants = 0;
|
||||
const grantLayers = readLayers();
|
||||
if (firstInstall && codexCorePluginEnablement(grantLayers).decision === "allow" && matchesShipped(cachedDir)) {
|
||||
let content = this.fs.readFile(this.resolveCodexConfigPath());
|
||||
const hookManifest = JSON.parse(fs.readFileSync(nodePath.join(shipped, "hooks/codex.json"), "utf8"));
|
||||
for (const [event, groups] of Object.entries(hookManifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]> ) {
|
||||
@@ -407,13 +417,15 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
const value = doc as { hooks?: { state?: Record<string, unknown> } };
|
||||
return Object.hasOwn(value.hooks?.state ?? {}, key) || Object.values(doc).some(hasChoice);
|
||||
};
|
||||
if (!hasChoice(parseToml(content)) && !layers.some(layer => layer.state === "read" && hasChoice(parseToml(layer.content)))) {
|
||||
if (!hasChoice(parseToml(content)) && !grantLayers.some(layer => layer.state === "read" && hasChoice(parseToml(layer.content)))) {
|
||||
content = upsertCodexHookTrust(content, key, hash);
|
||||
freshGrants++;
|
||||
}
|
||||
}));
|
||||
}
|
||||
this.fs.writeFile(this.resolveCodexConfigPath(), content);
|
||||
}
|
||||
renewContentApproval(freshGrants === 6);
|
||||
return report("installed", `${CODEX_CORE_PLUGIN_KEY} ${version} through Codex's own install (${enablement.reason})`);
|
||||
}
|
||||
|
||||
@@ -1743,9 +1755,9 @@ export function computeCodexHookTrust(
|
||||
* `[hooks.state]` / `[projects]` entry and the managed hook block byte-identical. Same key+hash
|
||||
* ⇒ no-op. Only called for our authored inline or exact first-install plugin keys (never blanket trust).
|
||||
*/
|
||||
export function upsertCodexHookTrust(content: string, key: string, hash: string): string {
|
||||
export function upsertCodexHookTrust(content: string, key: string, hash: string, field: "trusted_hash" | "trusted_content_hash" = "trusted_hash"): string {
|
||||
const header = `[hooks.state.${JSON.stringify(key)}]`;
|
||||
const trustLine = `trusted_hash = ${JSON.stringify(hash)}`;
|
||||
const trustLine = `${field} = ${JSON.stringify(hash)}`;
|
||||
const lines = content.length > 0 ? content.split("\n") : [];
|
||||
const headerIndex = lines.findIndex((line) => line.trim() === header);
|
||||
|
||||
@@ -1764,7 +1776,7 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string)
|
||||
}
|
||||
|
||||
const hashIndex = lines.findIndex(
|
||||
(line, index) => index > headerIndex && index < nextSectionIndex && line.trim().startsWith("trusted_hash"),
|
||||
(line, index) => index > headerIndex && index < nextSectionIndex && line.trim().startsWith(field),
|
||||
);
|
||||
if (hashIndex >= 0) {
|
||||
lines[hashIndex] = trustLine;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { parse } from "smol-toml";
|
||||
import { CODEX_CORE_PLUGIN_KEY, type CodexConfigLayerRead } from "./codex-core-plugin-install.js";
|
||||
|
||||
const { computeCodexHookTrust } = createRequire(import.meta.url)("../../assets/plugins/openrig-core/hooks/scripts/codex-hook-trust.cjs");
|
||||
// Published openrig-core0.1.17: Codex0.164's native baseline and the complete shipped tree agree.
|
||||
const LEGACY_CONTENT = process.platform === "win32" ? "sha256:f94b92a51872b7a5c0ba57d762a8cd68208a10f4e4ffed701220feafab626889" : "sha256:3317076e0df0d10402c17f26a17f26a21264980a6f0b551a1e21a3c0f785efca";
|
||||
const LEGACY_DECLARATIONS: Record<string, string> = {
|
||||
session_start: "a746b5abb568d50725b08bf39660bbf08d15fa3d3a5961208862e7beaadba43d",
|
||||
user_prompt_submit_0: "f56dfb2a70982d6a7ae4a6c4ace58b943e16dcb08fe3bc87d3c8803477f4a5c1",
|
||||
user_prompt_submit_1: "c648714b3a02675ba4ea029b8e62dbcd77569afc317ad4f1bc3e8cbfbe7b34ee",
|
||||
stop: "bb444b151db38aff570d0ceadc2cc84b21afb021f00b15b326aef55a27d87d40",
|
||||
post_compact: "f4dd49518599c67ac23c80daef3537bb03b02e23a1e8c1da1210534766afefae",
|
||||
permission_request: "75c66b08f505a22f84def73ecf6d61e98dd1b24ed8ecdcc05306eba3013e64d4",
|
||||
};
|
||||
const legacyDeclarations = Object.fromEntries(Object.entries(LEGACY_DECLARATIONS).map(([event, hash]) => {
|
||||
const match = /^(user_prompt_submit)_([01])$/.exec(event);
|
||||
return [`${CODEX_CORE_PLUGIN_KEY}:hooks/codex.json:${match?.[1] ?? event}:${match?.[2] ?? 0}:0`, `sha256:${hash}`];
|
||||
}));
|
||||
const readFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
|
||||
|
||||
/** Codex rust-v0.164.0 plugin_fingerprint.rs. Strict comparison also includes entries Codex excludes as generated files. */
|
||||
export function codexPluginContentHash(root: string, strict = false): string {
|
||||
if (!fs.lstatSync(root).isDirectory()) throw new Error("plugin root is not a directory");
|
||||
const hash = createHash("sha256").update("codex-plugin-bundle-v1");
|
||||
const length = (value: number) => { const bytes = Buffer.alloc(8); bytes.writeBigUInt64LE(BigInt(value)); hash.update(bytes); };
|
||||
const visit = (file: string) => {
|
||||
const name = path.basename(file);
|
||||
if (file !== root && !strict && (name === "__pycache__" || name === ".venv" || name.endsWith(".pyc") || name.endsWith(".egg-info"))) return;
|
||||
const relative = Buffer.from(path.relative(root, file)); length(relative.length); hash.update(relative);
|
||||
const before = fs.lstatSync(file);
|
||||
if (before.isDirectory()) {
|
||||
hash.update("directory");
|
||||
for (const child of fs.readdirSync(file).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) visit(path.join(file, child));
|
||||
} else if (before.isFile()) {
|
||||
hash.update("file"); length(before.size);
|
||||
const fd = fs.openSync(file, readFlags);
|
||||
try {
|
||||
const opened = fs.fstatSync(fd);
|
||||
if (!opened.isFile() || opened.ino !== before.ino || opened.dev !== before.dev) throw new Error("plugin file changed while fingerprinting");
|
||||
const buffer = Buffer.alloc(65536); let total = 0;
|
||||
while (total <= before.size) {
|
||||
const count = fs.readSync(fd, buffer, 0, Math.min(buffer.length, before.size + 1 - total), null);
|
||||
if (!count) break;
|
||||
total += count; hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
if (total !== before.size) throw new Error("plugin file changed while fingerprinting");
|
||||
} finally { fs.closeSync(fd); }
|
||||
} else throw new Error("unsupported plugin entry type");
|
||||
};
|
||||
visit(root);
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
function readRecord(file: string): string | undefined {
|
||||
let fd: number;
|
||||
try { fd = fs.openSync(file, readFlags); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
||||
try {
|
||||
const info = fs.fstatSync(fd);
|
||||
if (!info.isFile() || info.size > 65536 || (process.platform !== "win32" && (info.mode & 0o022) !== 0)
|
||||
|| (process.getuid && info.uid !== process.getuid())) throw new Error("unreadable hook approval record");
|
||||
return fs.readFileSync(fd, "utf8");
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
export function codexLegacyPluginTrustPath(configPath: string): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const part of [configPath, CODEX_CORE_PLUGIN_KEY]) {
|
||||
const bytes = Buffer.from(part), size = Buffer.alloc(8); size.writeBigUInt64LE(BigInt(bytes.length)); hash.update(size); hash.update(bytes);
|
||||
}
|
||||
return path.join(path.dirname(configPath), ".hook-trust", hash.digest("hex"));
|
||||
}
|
||||
|
||||
interface OwnedApproval { schema: 1; plugin: string; contentHash: string; declarations: Record<string, string> }
|
||||
export const ownedCodexApprovalPath = (configPath: string) => path.join(path.dirname(configPath), ".openrig-core-hook-trust.json");
|
||||
|
||||
/** No native baseline is changed. Existing declarations must already be approved; only content approval may advance. */
|
||||
export function planOwnedCodexContentApproval(input: {
|
||||
configPath: string; content: string; layers: CodexConfigLayerRead[]; shipped: string; installed: string; freshGrant?: boolean;
|
||||
}): { hashes: Record<string, string>; approval: OwnedApproval } | undefined {
|
||||
try {
|
||||
if (codexPluginContentHash(input.shipped, true) !== codexPluginContentHash(input.installed, true)) return;
|
||||
const contentHash = codexPluginContentHash(input.installed);
|
||||
const declarations: Record<string, string> = {};
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(input.shipped, "hooks/codex.json"), "utf8"));
|
||||
for (const [event, groups] of Object.entries(manifest.hooks) as Array<[string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout: number }> }>]> ) {
|
||||
groups.forEach((group, groupIndex) => group.hooks.forEach((handler, handlerIndex) => {
|
||||
const { key, hash } = computeCodexHookTrust(event, { ...handler, matcher: group.matcher, timeoutSec: handler.timeout,
|
||||
keySource: `${CODEX_CORE_PLUGIN_KEY}:hooks/codex.json`, groupIndex, handlerIndex });
|
||||
declarations[key] = hash;
|
||||
}));
|
||||
}
|
||||
if (Object.keys(declarations).length !== 6) return;
|
||||
const docs = input.layers.map(layer => {
|
||||
if (layer.state === "unreadable") throw new Error("unknown config layer");
|
||||
return { ...layer, doc: layer.state === "read" ? parse(layer.content) : {} };
|
||||
});
|
||||
const doc = parse(input.content) as any;
|
||||
const otherChoice = (value: any, key: string, checkRoot = true): boolean => value !== null && typeof value === "object"
|
||||
&& ((checkRoot && Object.hasOwn(value.hooks?.state ?? {}, key)) || Object.values(value).some(child => otherChoice(child, key)));
|
||||
const nestedPluginChoice = (value: any, nested = false): boolean => value !== null && typeof value === "object"
|
||||
&& ((nested && (Object.hasOwn(value.plugins ?? {}, CODEX_CORE_PLUGIN_KEY) || Object.hasOwn(value.plugins ?? {}, "_default")))
|
||||
|| Object.values(value).some(child => nestedPluginChoice(child, true)));
|
||||
if (docs.some(layer => nestedPluginChoice(layer.doc))) return;
|
||||
const states = doc.hooks?.state ?? {};
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
const state = states[key];
|
||||
if (!state || (state.enabled !== undefined && state.enabled !== true) || state.trusted_hash !== hash
|
||||
|| Object.keys(state).some(field => !["enabled", "trusted_hash", "trusted_content_hash"].includes(field))
|
||||
|| !input.content.split("\n").some(line => line.trim() === `[hooks.state.${JSON.stringify(key)}]`)
|
||||
|| docs.some(layer => otherChoice(layer.doc, key, layer.path !== input.configPath))) return;
|
||||
}
|
||||
const approval: OwnedApproval = { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY, contentHash, declarations };
|
||||
const hashes = Object.fromEntries(Object.keys(declarations).map(key => [key, contentHash]));
|
||||
if (input.freshGrant) return { hashes, approval };
|
||||
if (Object.keys(declarations).every(key => states[key].trusted_content_hash === contentHash)) return { hashes: {}, approval };
|
||||
const baseline = readRecord(codexLegacyPluginTrustPath(input.configPath));
|
||||
if (Object.keys(declarations).every(key => (states[key].trusted_content_hash ?? baseline) === contentHash)) return { hashes, approval };
|
||||
const saved = readRecord(ownedCodexApprovalPath(input.configPath));
|
||||
const previous: OwnedApproval = saved === undefined ? { schema: 1, plugin: CODEX_CORE_PLUGIN_KEY,
|
||||
contentHash: LEGACY_CONTENT, declarations: legacyDeclarations } : JSON.parse(saved);
|
||||
if (previous.schema !== 1 || previous.plugin !== CODEX_CORE_PLUGIN_KEY || !/^sha256:[a-f0-9]{64}$/.test(previous.contentHash)
|
||||
|| Object.keys(previous.declarations).length !== 6) return;
|
||||
for (const [key, hash] of Object.entries(declarations)) {
|
||||
if (previous.declarations[key] !== hash || (states[key].trusted_content_hash ?? baseline) !== previous.contentHash) return;
|
||||
}
|
||||
return { hashes, approval };
|
||||
} catch { return undefined; } // Unknown ownership or config leaves native review in place.
|
||||
}
|
||||
|
||||
export function recordOwnedCodexApproval(configPath: string, approval: OwnedApproval): void {
|
||||
const file = ownedCodexApprovalPath(configPath), content = `${JSON.stringify(approval, null, 2)}\n`;
|
||||
if (readRecord(file) === content) return;
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
try { fs.writeFileSync(temporary, content, { flag: "wx", mode: 0o600 }); fs.renameSync(temporary, file); }
|
||||
finally { try { fs.unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parse } from "smol-toml";
|
||||
import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { codexPluginContentHash, codexLegacyPluginTrustPath, planOwnedCodexContentApproval } from "../src/domain/codex-owned-plugin-trust.js";
|
||||
|
||||
const shipped = path.resolve(import.meta.dirname, "../assets/plugins/openrig-core");
|
||||
const roots: string[] = [];
|
||||
@@ -50,6 +52,7 @@ it("a fresh install has all six exact plugin hooks trusted before the first seat
|
||||
"openrig-core@openrig:hooks/codex.json:permission_request:0:0": "sha256:75c66b08f505a22f84def73ecf6d61e98dd1b24ed8ecdcc05306eba3013e64d4"
|
||||
};
|
||||
expect(Object.fromEntries(Object.entries(states).map(([key,value]:[string,any])=>[key,value.trusted_hash]))).toEqual(expected);
|
||||
expect(Object.values(states).every((state: any) => state.trusted_content_hash === codexPluginContentHash(shipped))).toBe(true);
|
||||
expect(f.calls.map(a=>a.slice(0,2))).toEqual([["plugin","marketplace"],["plugin","add"]]);
|
||||
});
|
||||
it.each(['[plugins."openrig-core@openrig"]\nenabled = false\n','[plugins._default]\nenabled = false\n'])("a disabled plugin stays disabled on restart and upgrade: %s", async config => {
|
||||
@@ -112,3 +115,97 @@ it("a same-named marketplace owned by someone else is not installed or trusted",
|
||||
const f=fixture(config);await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file,"utf8")).toBe(config);expect(f.calls).toEqual([]);
|
||||
});
|
||||
|
||||
// The interrupted-upgrade state: the new bundle is already installed, but Codex0.164
|
||||
// retains the declaration-only grants and immutable content baseline from published0.7.0.
|
||||
async function legacyUpgrade() {
|
||||
const f = fixture();
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(/^trusted_content_hash = .*\n/gm, ""));
|
||||
fs.rmSync(path.join(f.home, ".openrig-core-hook-trust.json"), { force: true });
|
||||
const identity = createHash("sha256");
|
||||
for (const part of [f.file, "openrig-core@openrig"]) {
|
||||
const bytes = Buffer.from(part), length = Buffer.alloc(8); length.writeBigUInt64LE(BigInt(bytes.length));
|
||||
identity.update(length); identity.update(bytes);
|
||||
}
|
||||
const baseline = path.join(f.home, ".hook-trust", identity.digest("hex"));
|
||||
fs.mkdirSync(path.dirname(baseline), { recursive: true });
|
||||
fs.writeFileSync(baseline, process.platform === "win32" ? "sha256:f94b92a51872b7a5c0ba57d762a8cd68208a10f4e4ffed701220feafab626889" : "sha256:3317076e0df0d10402c17f26a17f26a21264980a6f0b551a1e21a3c0f785efca");
|
||||
return { ...f, baseline };
|
||||
}
|
||||
|
||||
it("an already-installed owned upgrade renews all six content approvals without changing other choices or the native baseline", async () => {
|
||||
const f = await legacyUpgrade();
|
||||
fs.appendFileSync(f.file, '\n[hooks.state."outside:stop:0:0"]\nenabled = false\ntrusted_hash = "outside-choice"\n');
|
||||
const before = fs.readFileSync(f.file, "utf8"), baseline = fs.readFileSync(f.baseline, "utf8"), calls = f.calls.length;
|
||||
const expectedContent = codexPluginContentHash(shipped);
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
const states = Object.entries(f.read().hooks.state).filter(([key]) => key.startsWith("openrig-core@openrig:"));
|
||||
expect(states).toHaveLength(6);
|
||||
for (const [, state] of states as Array<[string, any]>) {
|
||||
expect(state.trusted_content_hash).toBe(expectedContent);
|
||||
}
|
||||
expect(fs.readFileSync(f.file, "utf8").replace(/^trusted_content_hash = .*\n/gm, "")).toBe(before);
|
||||
expect(fs.readFileSync(f.baseline, "utf8")).toBe(baseline);
|
||||
expect(f.calls).toHaveLength(calls);
|
||||
const renewed = fs.readFileSync(f.file, "utf8");
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(renewed);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "utf8"))).toMatchObject({
|
||||
contentHash: expectedContent,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["disabled", "refused", "unknown baseline", "changed script", "changed skill", "extra file", ...(process.platform === "win32" ? [] : ["symlink"]), "unknown layer", "profile refusal", "profile plugin disable", "invalid provenance"])(
|
||||
"an owned upgrade preserves native review for %s", async condition => {
|
||||
const f = await legacyUpgrade();
|
||||
const header = '[hooks.state."openrig-core@openrig:hooks/codex.json:stop:0:0"]';
|
||||
if (condition === "disabled") fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(header, `${header}\nenabled = false`));
|
||||
if (condition === "refused") fs.writeFileSync(f.file, fs.readFileSync(f.file, "utf8").replace(/trusted_hash = "sha256:bb444[^"]+"/, 'trusted_hash = "not-the-owned-declaration"'));
|
||||
if (condition === "unknown baseline") fs.writeFileSync(f.baseline, "unknown");
|
||||
if (condition === "changed script") fs.appendFileSync(path.join(f.cache, "hooks/scripts/refocus.cjs"), "\nmodified");
|
||||
if (condition === "changed skill") fs.appendFileSync(path.join(f.cache, "skills/openrig-user/SKILL.md"), "\nmodified");
|
||||
if (condition === "extra file") fs.writeFileSync(path.join(f.cache, "unexpected"), "extra");
|
||||
if (condition === "symlink") fs.symlinkSync(f.plugin, path.join(f.cache, "unexpected-link"));
|
||||
if (condition === "unknown layer") { fs.mkdirSync(path.join(f.home, "../etc")); fs.writeFileSync(path.join(f.home, "../etc/config.toml"), "not valid toml"); }
|
||||
if (condition === "profile refusal") fs.appendFileSync(f.file, '\n[profiles.special.hooks.state."openrig-core@openrig:hooks/codex.json:stop:0:0"]\nenabled = false\n');
|
||||
if (condition === "profile plugin disable") fs.appendFileSync(f.file, '\n[profiles.special.plugins."openrig-core@openrig"]\nenabled = false\n');
|
||||
if (condition === "invalid provenance") fs.writeFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "broken");
|
||||
const before = fs.readFileSync(f.file, "utf8");
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(fs.readFileSync(f.file, "utf8")).toBe(before);
|
||||
if (condition !== "invalid provenance") expect(fs.existsSync(path.join(f.home, ".openrig-core-hook-trust.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("the owned receipt carries approval into a later bundle while preserving the previous declarations", async () => {
|
||||
const f = fixture(); await f.adapter().ensureCodexCorePlugin();
|
||||
const next = path.join(f.home, "next-shipped"); fs.cpSync(shipped, next, { recursive: true });
|
||||
fs.appendFileSync(path.join(next, "skills/openrig-user/SKILL.md"), "\nnext shipped revision");
|
||||
fs.cpSync(next, f.cache, { recursive: true });
|
||||
const plan = planOwnedCodexContentApproval({ configPath: f.file, content: fs.readFileSync(f.file, "utf8"),
|
||||
layers: [{ name: "user config", path: f.file, state: "read", content: fs.readFileSync(f.file, "utf8") }], shipped: next, installed: f.cache });
|
||||
expect(plan).toBeDefined();
|
||||
expect(Object.values(plan!.hashes)).toEqual(Array(6).fill(codexPluginContentHash(next)));
|
||||
expect(f.read().hooks.state["openrig-core@openrig:hooks/codex.json:stop:0:0"].trusted_hash).toBe(plan!.approval.declarations["openrig-core@openrig:hooks/codex.json:stop:0:0"]);
|
||||
});
|
||||
|
||||
it("records an already-approved current legacy bundle for subsequent upgrades", async () => {
|
||||
const f = await legacyUpgrade(), current = codexPluginContentHash(shipped);
|
||||
fs.writeFileSync(f.baseline, current);
|
||||
await f.adapter().ensureCodexCorePlugin();
|
||||
expect(Object.values(f.read().hooks.state).every((state: any) => state.trusted_content_hash === current)).toBe(true);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(f.home, ".openrig-core-hook-trust.json"), "utf8"))).toMatchObject({ contentHash: current });
|
||||
expect(fs.readFileSync(f.baseline, "utf8")).toBe(current);
|
||||
});
|
||||
|
||||
it("uses the native bundle fingerprint and legacy identity recipes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-fingerprint-")); roots.push(root);
|
||||
fs.mkdirSync(path.join(root, "nested")); fs.writeFileSync(path.join(root, "hello.txt"), "hello\n");
|
||||
fs.writeFileSync(path.join(root, "nested/other.txt"), "world\n");
|
||||
// Independent Python implementation of the rust-v0.164.0 byte stream, not the grant planner.
|
||||
expect(codexPluginContentHash(root)).toBe(process.platform === "win32" ? "sha256:e7690d54fade38332715024d03cf7c7e5cb26f77dbba1c60e589e2be9ef42d5e" : "sha256:bc4d2c619163c3c9e4fd0a0740b7de1e5b39b3eb2cef3d52b8a8f582a1f13ce5");
|
||||
fs.mkdirSync(path.join(root, ".venv")); fs.writeFileSync(path.join(root, ".venv/ignored"), "generated");
|
||||
expect(codexPluginContentHash(root)).toBe(process.platform === "win32" ? "sha256:e7690d54fade38332715024d03cf7c7e5cb26f77dbba1c60e589e2be9ef42d5e" : "sha256:bc4d2c619163c3c9e4fd0a0740b7de1e5b39b3eb2cef3d52b8a8f582a1f13ce5");
|
||||
expect(codexPluginContentHash(root, true)).not.toBe(codexPluginContentHash(root));
|
||||
expect(path.basename(codexLegacyPluginTrustPath("/home/operator/.codex/config.toml"))).toBe("5107db44b2124b5303069076c8cbcc824811a62710c30a9188b2f8465e5e7cb4");
|
||||
});
|
||||
Reference in new issue
Block a user