@unblocklabs/unblock-codex-prompt 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Unblock Codex Prompt
2
2
 
3
3
  A small, explicit prompt-management bridge for dedicated OpenClaw Mac agents.
4
- **Version 0.1.0.** The frozen bridge is pinned to OpenClaw/Codex plugin 2026.9.2.
4
+ **Version 0.2.1.** The frozen bridge supports OpenClaw/Codex 2026.9.2 and newer
5
+ stable releases with a compatible context-assembly layout.
5
6
 
6
7
  ## What you control
7
8
 
@@ -27,15 +28,22 @@ files use private permissions. Missing identity documents are recorded as absent
27
28
 
28
29
  ## Compatibility
29
30
 
30
- - Frozen bridge: **OpenClaw 2026.9.2 and @openclaw/codex 2026.9.2 only**. The exact
31
- original adapter SHA-256 is checked before patching. Unknown builds are refused.
31
+ - Frozen bridge: **OpenClaw and @openclaw/codex >=2026.9.2**. Reviewed layouts cover
32
+ 2026.9.2, 2026.9.3, 2026.9.4 and 2026.9.8, including parent-local context and split
33
+ `dist/.setup/` bundles. Bundle names
34
+ may change; fingerprints of the context-assembly functions must still match.
35
+ Changed assembly is refused pending review, rather than patched blindly.
32
36
  - Managed local stdio Codex, agent-scoped home, no custom command/arguments.
33
37
  - One target agent per installation. Other agents use upstream behavior. Native
34
38
  children may inherit the parent's frozen context.
35
39
 
36
40
  This bridge uses two private upstream seams because public prompt hooks cannot
37
- fix the dropped-context path: one skill-catalog builder and two entry points in
38
- one Codex adapter bundle. It is deliberately version-pinned, not upgrade-proof.
41
+ fix the dropped-context path: one skill-catalog builder and guarded Codex context
42
+ carriers. Newer builds also require parent-local suppression and removal of the
43
+ separate thread-level skills append. The installer backs up and tracks every
44
+ patched bundle; old single-file receipts remain restorable. Runtime module paths
45
+ come from the loader's selected entry, never its temporary execution capture.
46
+ It validates private seams and is not a promise of compatibility with unseen layouts.
39
47
  The catalog uses OpenClaw's eligibility, allowlist, visibility and size-limit
40
48
  logic. Session-specific and execution-directory skills are not snapshotted.
41
49
  Remote-node-only skills are not added; this is for dedicated local Mac agents.
@@ -46,7 +54,7 @@ Remote-node-only skills are not added; this is for dedicated local Mac agents.
46
54
  npm ci
47
55
  npm run preflight
48
56
  npm pack
49
- openclaw plugins install ./unblocklabs-unblock-codex-prompt-0.1.0.tgz --force --accept-capabilities
57
+ openclaw plugins install ./unblocklabs-unblock-codex-prompt-0.2.1.tgz --force --accept-capabilities
50
58
  ```
51
59
 
52
60
  Include the plugin in `plugins.allow` if applicable, then configure:
@@ -181,6 +189,26 @@ contents without a Gateway restart. Content-only updates report
181
189
  are not reset, and their adoption is not guaranteed. The plugin never restarts
182
190
  the Gateway automatically; revalidate this behavior when upgrading Codex.
183
191
 
192
+ ## Codex skills removal
193
+
194
+ Codex lists every skill it finds in the managed Codex home, including skills from
195
+ ChatGPT remote plugins that sync in on their own. `disabledCodexSkills` names the
196
+ ones to remove: a plugin name removes all of that plugin's skills, and an exact
197
+ skill name (`pages:write-page`, `imagegen`) removes one. It defaults to
198
+ `["pages", "work-pets"]` (ChatGPT Pages and Pets). Other skills are untouched.
199
+
200
+ ```json
201
+ "disabledCodexSkills": ["pages", "work-pets"]
202
+ ```
203
+
204
+ The plugin scans `skills/` and `plugins/cache/` under the managed Codex home and
205
+ writes one marked block of `[[skills.config]] name = "…" enabled = false` rules at
206
+ the end of `config.toml`. It re-applies the block at Gateway startup, `sync`,
207
+ `refresh`, and the daily refresh, so newly synced plugin skills are caught.
208
+ Codex reads the rules when its app-server starts; running sessions keep their
209
+ list until the Gateway or app-server restarts. Set `[]` to remove the block.
210
+ `restore` also removes it. User-authored `[[skills.config]]` entries stay as written.
211
+
184
212
  ## Restore
185
213
 
186
214
  ```sh
@@ -212,7 +240,7 @@ without restoring leaves the frozen base but re-enables live injections.
212
240
 
213
241
  ```sh
214
242
  npm run preflight
215
- npm run release:check -- v0.1.0
243
+ npm run release:check -- v0.2.1
216
244
  ```
217
245
 
218
246
  Tests cover compilation, explicit refresh, native/private-body exclusion,
@@ -221,7 +249,8 @@ pointer ownership and foreign edits. See [VALIDATION.md](VALIDATION.md) for live
221
249
  proof and limits.
222
250
 
223
251
  Install with
224
- `openclaw plugins install npm:@unblocklabs/unblock-codex-prompt@0.1.0 --accept-capabilities`.
252
+ the packed archive above for an unreleased candidate. After publication, use
253
+ `openclaw plugins install npm:@unblocklabs/unblock-codex-prompt@0.2.1 --accept-capabilities`.
225
254
  The existing [GitHub Release workflow](RELEASING.md) publishes npm only when a
226
255
  GitHub Release is published. Normal pushes do not release the package.
227
256
 
package/RELEASING.md CHANGED
@@ -25,7 +25,7 @@ If first-package bootstrapping is needed, an authenticated owner can publish the
25
25
  reviewed version once with `npm publish --access public`, then configure trust:
26
26
 
27
27
  ```sh
28
- npm trust github @unblocklabs/unblock-codex-prompt --repo unblocklabs-ai/unblock-codex-prompt --file release.yml --yes
28
+ npx --yes --package npm@12.0.2 npm trust github @unblocklabs/unblock-codex-prompt --repo unblocklabs-ai/unblock-codex-prompt --file release.yml --allow-publish --yes
29
29
  ```
30
30
 
31
31
  The Release workflow verifies the registry's tarball integrity against its own
@@ -33,6 +33,11 @@ packed artifact before accepting an already-published version. Different bytes
33
33
  fail the job. Subsequent new versions publish through OIDC with provenance; a
34
34
  local bootstrap publication does not have GitHub Actions provenance.
35
35
 
36
+ Trusted publishing was configured during the 0.1.0 release. The trust-management
37
+ command above uses npm 12 because the current registry requires explicit publish
38
+ permissions; npm 11.13.0's older trust request was rejected. Normal CI publishing
39
+ continues to use the pinned CLI in `release.yml`.
40
+
36
41
  ClawHub publishing is not configured.
37
42
 
38
43
  ## Release checklist
package/dist/adapter.d.ts CHANGED
@@ -1,24 +1,37 @@
1
- type Receipt = {
2
- schema: 1;
3
- packageDir: string;
1
+ type AdapterFile = {
4
2
  path: string;
5
3
  originalSha256: string;
6
4
  patchedSha256: string;
5
+ previousPatchedSha256?: string;
6
+ };
7
+ type Receipt = ({
8
+ schema: 1;
9
+ packageDir: string;
10
+ } & AdapterFile) | {
11
+ schema: 2;
12
+ packageDir: string;
13
+ files: AdapterFile[];
7
14
  };
8
15
  export declare function readReceipt(agentDir: string): Promise<Receipt | null>;
9
- export declare function patchAdapterSource(source: string, agentDir: string, agentId: string, runtimePath: string): string;
10
- export declare function prepareAdapter(agentDir: string, agentId: string, suppliedDir?: string): Promise<{
11
- path: string;
12
- current: string;
13
- original: string;
14
- patched: string;
16
+ export declare function patchAdapterSources(sources: string[], agentDir: string, agentId: string, runtimePath: string): string[];
17
+ export declare function prepareAdapter(agentDir: string, agentId: string, suppliedDir: string | undefined, runtimePath: string): Promise<{
18
+ files: {
19
+ patched: string;
20
+ path: string;
21
+ current: string;
22
+ original: string;
23
+ }[];
15
24
  receipt: {
16
- schema: 1;
25
+ schema: 2;
17
26
  packageDir: string;
18
- path: string;
19
- originalSha256: string;
20
- patchedSha256: string;
27
+ files: {
28
+ previousPatchedSha256?: string;
29
+ path: string;
30
+ originalSha256: string;
31
+ patchedSha256: string;
32
+ }[];
21
33
  };
34
+ previousReceipt: string | null;
22
35
  }>;
23
36
  export declare function installAdapter(agentDir: string, plan: Awaited<ReturnType<typeof prepareAdapter>>): Promise<{
24
37
  adapterChanged: boolean;
@@ -27,11 +40,13 @@ export declare function installAdapter(agentDir: string, plan: Awaited<ReturnTyp
27
40
  export declare function adapterStatus(agentDir: string): Promise<{
28
41
  installed: boolean;
29
42
  path?: never;
30
- supportedVersion?: never;
43
+ paths?: never;
44
+ minimumVersion?: never;
31
45
  } | {
32
46
  installed: boolean;
33
47
  path: string;
34
- supportedVersion: string;
48
+ paths: string[];
49
+ minimumVersion: string;
35
50
  }>;
36
51
  export declare function restoreAdapter(agentDir: string): Promise<void>;
37
52
  export {};
package/dist/adapter.js CHANGED
@@ -1,31 +1,94 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import { lstat, readFile, readdir } from "node:fs/promises";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import { parse } from "acorn";
4
4
  import { readOptional, replaceFile, sha256 } from "./manager.js";
5
- import { SUPPORTED_OPENCLAW, openclawRoot } from "./compiler.js";
6
- const ORIGINAL_SHA256 = "344d4a6332d59b5eb1d7551d6d308563f339d5d2251809c8f25e8cec04ede342";
5
+ import { openclawRoot } from "./compiler.js";
6
+ import { assertSupportedVersion, MINIMUM_OPENCLAW } from "./compatibility.js";
7
7
  const MARKER = "// unblock-codex-prompt bridge v1";
8
- const developer = "function buildDeveloperInstructions(params, options = {}) {";
9
- const collaboration = "function buildTurnScopedCollaborationInstructions(params, options = {}) {";
8
+ const BUNDLE_NAME = /^thread-(lifecycle|requests)-[\w-]+\.m?js$/u;
9
+ const HASH = /^[a-f0-9]{64}$/u;
10
+ // Reviewed context carriers, independent of release-specific bundle names.
11
+ const CONTEXT_HASHES = {
12
+ legacy: "1fb45c46c50a63f68e8b6bdfbc5d7ae2d323470c00db69779d159592868940bb",
13
+ parent: "5ee80edf3f3887e841def5d64896a3d27c35063f6d4803dceeb8b8bf07b654f0",
14
+ splitParent: "6515e383260e0edd4379a2e694d9b066ddb02a79b55fd7494bf7f85affee72c6",
15
+ collaboration: "aea7e95ca43f0997b0a3f616f327132bfdd057bb731e6eead132fba21de3f981",
16
+ legacyThread: "703a32a0b18eacd45a9fd3d68c04a13527bdbeaae50a11a6b76768b84b99a9ed",
17
+ thread: "3aa4c414cb449cdb9167e6ed2df9397559176310ce7cff882652bd669ac8cee8",
18
+ };
10
19
  function receiptPath(agentDir) { return join(agentDir, "codex-home", ".unblock-codex-prompt-adapter.json"); }
11
- function backupPath(agentDir) { return join(agentDir, "codex-home", ".unblock-codex-prompt-adapter-original.js"); }
12
- export async function readReceipt(agentDir) {
13
- const source = await readOptional(receiptPath(agentDir));
20
+ function receiptFiles(receipt) { return receipt.schema === 1 ? [receipt] : receipt.files; }
21
+ function backupPath(agentDir, file, legacy = false) {
22
+ return join(agentDir, "codex-home", legacy ? ".unblock-codex-prompt-adapter-original.js" : ".unblock-codex-prompt-adapter-original-" + basename(file.path));
23
+ }
24
+ function ownsSource(file, source) {
25
+ if (source === null)
26
+ return false;
27
+ const hash = sha256(source);
28
+ return hash === file.originalSha256 || hash === file.patchedSha256 || hash === file.previousPatchedSha256;
29
+ }
30
+ function parseReceipt(source) {
14
31
  if (source === null)
15
32
  return null;
16
33
  const value = JSON.parse(source);
17
- if (!value || typeof value !== "object" || !("schema" in value) || value.schema !== 1 ||
18
- !("packageDir" in value) || typeof value.packageDir !== "string" ||
19
- !("path" in value) || typeof value.path !== "string" ||
20
- !("originalSha256" in value) || value.originalSha256 !== ORIGINAL_SHA256 ||
21
- !("patchedSha256" in value) || typeof value.patchedSha256 !== "string" ||
22
- value.path !== join(value.packageDir, "dist", "thread-lifecycle-DPK30jad.js"))
34
+ if (!value || typeof value !== "object" || !("schema" in value) || (value.schema !== 1 && value.schema !== 2) ||
35
+ !("packageDir" in value) || typeof value.packageDir !== "string" || !isAbsolute(value.packageDir))
36
+ throw new Error("Invalid adapter receipt");
37
+ const packageDir = value.packageDir;
38
+ const entries = value.schema === 1 ? [value] : "files" in value && Array.isArray(value.files) ? value.files : [];
39
+ if (!entries.length)
23
40
  throw new Error("Invalid adapter receipt");
24
- return { schema: 1, packageDir: value.packageDir, path: value.path, originalSha256: value.originalSha256, patchedSha256: value.patchedSha256 };
41
+ const files = entries.map((entry) => {
42
+ if (!entry || typeof entry !== "object" || !("path" in entry) || typeof entry.path !== "string" ||
43
+ !BUNDLE_NAME.test(basename(entry.path)) ||
44
+ ![join(packageDir, "dist", basename(entry.path)), ...(value.schema === 2 ? [join(packageDir, "dist", ".setup", basename(entry.path))] : [])].includes(entry.path) ||
45
+ (value.schema === 1 && !basename(entry.path).startsWith("thread-lifecycle-")) ||
46
+ !("originalSha256" in entry) || typeof entry.originalSha256 !== "string" || !HASH.test(entry.originalSha256) ||
47
+ !("patchedSha256" in entry) || typeof entry.patchedSha256 !== "string" || !HASH.test(entry.patchedSha256))
48
+ throw new Error("Invalid adapter receipt");
49
+ const previous = "previousPatchedSha256" in entry ? entry.previousPatchedSha256 : undefined;
50
+ if (previous !== undefined && (typeof previous !== "string" || !HASH.test(previous)))
51
+ throw new Error("Invalid adapter receipt");
52
+ return { path: entry.path, originalSha256: entry.originalSha256, patchedSha256: entry.patchedSha256,
53
+ ...(typeof previous === "string" ? { previousPatchedSha256: previous } : {}) };
54
+ });
55
+ if (new Set(files.map(file => basename(file.path))).size !== files.length)
56
+ throw new Error("Invalid adapter receipt");
57
+ return value.schema === 1 ? { schema: 1, packageDir, ...files[0] } : { schema: 2, packageDir, files };
25
58
  }
26
- export function patchAdapterSource(source, agentDir, agentId, runtimePath) {
27
- if (source.includes(MARKER) || source.split(developer).length !== 2 || source.split(collaboration).length !== 2)
28
- throw new Error("Unsupported Codex adapter shape");
59
+ export async function readReceipt(agentDir) { return parseReceipt(await readOptional(receiptPath(agentDir))); }
60
+ export function patchAdapterSources(sources, agentDir, agentId, runtimePath) {
61
+ if (sources.some(source => source.includes(MARKER)))
62
+ throw new Error("Unsupported Codex adapter shape: already patched");
63
+ const functions = sources.flatMap((source, bundle) => parse(source, { ecmaVersion: "latest", sourceType: "module" }).body
64
+ .filter(node => node.type === "FunctionDeclaration").map(node => ({ node, bundle })));
65
+ const find = (name) => {
66
+ const matches = functions.filter(fn => fn.node.id?.name === name);
67
+ if (matches.length !== 1)
68
+ throw new Error("Unsupported Codex adapter shape: " + name);
69
+ return matches[0];
70
+ };
71
+ const developer = find("buildDeveloperInstructions");
72
+ const collaboration = find("buildTurnScopedCollaborationInstructions");
73
+ const parent = functions.some(fn => fn.node.id?.name === "buildCodexParentLocalInstructions") ? find("buildCodexParentLocalInstructions") : null;
74
+ const thread = functions.some(fn => fn.node.id?.name === "buildCodexThreadConfiguration") ? find("buildCodexThreadConfiguration") : null;
75
+ for (const { node, bundle } of [developer, collaboration, ...(parent ? [parent] : []), ...(thread ? [thread] : [])]) {
76
+ if (node.async || node.generator || node.params.length !== 2 || node.params[0]?.type !== "Identifier" || node.params[0].name !== "params" ||
77
+ !/^(options|options\s*=\s*\{\})$/u.test(sources[bundle].slice(node.params[1].start, node.params[1].end)))
78
+ throw new Error("Unsupported Codex adapter shape: parameters");
79
+ }
80
+ for (const name of ["buildDefaultCollaborationInstructions", "buildCronCollaborationInstructions"]) {
81
+ const helper = find(name);
82
+ if (helper.node.params.length !== 0 || helper.bundle !== collaboration.bundle)
83
+ throw new Error("Unsupported Codex adapter shape: mode helpers");
84
+ }
85
+ const fingerprint = (fn) => sha256(sources[fn.bundle].slice(fn.node.start, fn.node.end));
86
+ const split = parent !== null && fingerprint(parent) === CONTEXT_HASHES.splitParent;
87
+ if (fingerprint(collaboration) !== (parent ? CONTEXT_HASHES.collaboration : CONTEXT_HASHES.legacy) ||
88
+ (parent && (parent.bundle !== collaboration.bundle || ![CONTEXT_HASHES.parent, CONTEXT_HASHES.splitParent].includes(fingerprint(parent)))) ||
89
+ (thread && thread.bundle !== developer.bundle) ||
90
+ (split ? !thread || fingerprint(thread) !== CONTEXT_HASHES.thread : thread !== null && fingerprint(thread) !== CONTEXT_HASHES.legacyThread))
91
+ throw new Error("Unsupported Codex context assembly; review the changed upstream functions before syncing");
29
92
  const prefix = `${MARKER}
30
93
  import { createRequire as unblockPromptCreateRequire } from "node:module";
31
94
  const unblockPromptRequire = unblockPromptCreateRequire(import.meta.url);
@@ -40,65 +103,119 @@ function unblockFrozenPolicy(params) {
40
103
  return unblockPromptRequire(${JSON.stringify(runtimePath)}).readFrozenPolicy(${JSON.stringify(agentDir)});
41
104
  }
42
105
  `;
43
- return prefix + source.replace(developer, `${developer}
44
- \tconst unblockPolicy = unblockFrozenPolicy(params);
45
- \tif (unblockPolicy !== null) return [unblockPolicy, params.extraSystemPrompt].filter(section => typeof section === "string" && section.trim()).join("\\n\\n");`)
46
- .replace(collaboration, `${collaboration}
47
- \tif (unblockFrozenPolicy(params) !== null) return params.trigger === "cron" ? buildCronCollaborationInstructions() : buildDefaultCollaborationInstructions();`);
106
+ const insertions = [
107
+ { fn: developer, text: '\n\tconst unblockPolicy = unblockFrozenPolicy(params);\n\tif (unblockPolicy !== null) return [unblockPolicy, params.gitCoauthorPrompt, params.extraSystemPrompt].filter(section => typeof section === "string" && section.trim()).join("\\n\\n");' },
108
+ { fn: collaboration, text: '\n\tif (unblockFrozenPolicy(params) !== null) return params.trigger === "cron" ? buildCronCollaborationInstructions() : buildDefaultCollaborationInstructions();' },
109
+ ...(parent ? [{ fn: parent, text: '\n\tif (unblockFrozenPolicy(params) !== null) return params.trigger === "cron" ? buildCronCollaborationInstructions() : null;' }] : []),
110
+ // 2026.9.8 moved skills to the start/resume/fork carrier, outside developer policy.
111
+ ...(split && thread ? [{ fn: thread, text: '\n\tif (unblockFrozenPolicy(params) !== null) options = { ...options, skillsInstructions: undefined };' }] : []),
112
+ ];
113
+ return sources.map((source, bundle) => {
114
+ const edits = insertions.filter(edit => edit.fn.bundle === bundle).sort((a, b) => b.fn.node.body.start - a.fn.node.body.start);
115
+ for (const { fn, text } of edits) {
116
+ const at = fn.node.body.start + 1;
117
+ source = source.slice(0, at) + text + source.slice(at);
118
+ }
119
+ return edits.length ? prefix + source : source;
120
+ });
48
121
  }
49
- export async function prepareAdapter(agentDir, agentId, suppliedDir) {
122
+ export async function prepareAdapter(agentDir, agentId, suppliedDir, runtimePath) {
50
123
  await openclawRoot();
51
- const receipt = await readReceipt(agentDir);
124
+ if (!isAbsolute(runtimePath) || await readOptional(runtimePath) === null)
125
+ throw new Error("Missing durable plugin runtime");
126
+ const previousReceipt = await readOptional(receiptPath(agentDir));
127
+ const receipt = parseReceipt(previousReceipt);
52
128
  const packageDir = suppliedDir ? resolve(suppliedDir) : receipt?.packageDir;
53
129
  if (!packageDir)
54
130
  throw new Error("First frozen sync requires --codex-plugin-dir from openclaw plugins inspect codex --json");
55
131
  if (receipt && receipt.packageDir !== packageDir)
56
132
  throw new Error("Restore the previous adapter before changing Codex installations");
57
133
  const pkg = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
58
- if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== "@openclaw/codex" || !("version" in pkg) || pkg.version !== SUPPORTED_OPENCLAW)
59
- throw new Error(`Frozen bridge requires @openclaw/codex ${SUPPORTED_OPENCLAW}`);
60
- const files = (await readdir(join(packageDir, "dist"))).filter(name => /^thread-lifecycle-[\w-]+\.js$/u.test(name));
61
- if (files.length !== 1 || files[0] !== "thread-lifecycle-DPK30jad.js")
134
+ if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== "@openclaw/codex")
135
+ throw new Error("Expected the official @openclaw/codex package");
136
+ assertSupportedVersion("version" in pkg ? pkg.version : undefined, "@openclaw/codex");
137
+ const dist = join(packageDir, "dist");
138
+ if (!(await lstat(dist)).isDirectory())
62
139
  throw new Error("Unsupported Codex bundle layout");
63
- const path = join(packageDir, "dist", files[0]);
64
- const current = await readOptional(path);
65
- if (current === null)
66
- throw new Error("Missing Codex adapter");
67
- const original = receipt ? await readOptional(backupPath(agentDir)) : current;
68
- if (original === null || sha256(original) !== ORIGINAL_SHA256)
69
- throw new Error("Unknown Codex adapter bytes; refusing to patch");
70
- if (receipt && sha256(current) !== receipt.patchedSha256 && current !== original)
71
- throw new Error("Codex adapter changed by another writer; refusing to overwrite");
72
- const patched = patchAdapterSource(original, agentDir, agentId, fileURLToPath(new URL("./runtime.js", import.meta.url)));
73
- return { path, current, original, patched, receipt: { schema: 1, packageDir, path, originalSha256: ORIGINAL_SHA256, patchedSha256: sha256(patched) } };
140
+ const entries = await readdir(dist, { withFileTypes: true });
141
+ const setup = entries.find(entry => entry.name === ".setup");
142
+ if (setup && !setup.isDirectory())
143
+ throw new Error("Unsupported Codex bundle layout");
144
+ const paths = entries.filter(entry => BUNDLE_NAME.test(entry.name)).map(entry => join(dist, entry.name));
145
+ if (setup)
146
+ paths.push(...(await readdir(join(dist, ".setup"))).filter(name => BUNDLE_NAME.test(name)).map(name => join(dist, ".setup", name)));
147
+ paths.sort();
148
+ if (!paths.length || (receipt && receiptFiles(receipt).some(file => !paths.includes(file.path))))
149
+ throw new Error("Unsupported Codex bundle layout; restore before changing installations");
150
+ const bundles = await Promise.all(paths.map(async (path) => {
151
+ const current = await readOptional(path);
152
+ if (current === null)
153
+ throw new Error("Missing Codex adapter");
154
+ const file = receipt && receiptFiles(receipt).find(file => file.path === path);
155
+ const original = file ? await readOptional(backupPath(agentDir, file, receipt?.schema === 1)) : current;
156
+ if (original === null || (file && sha256(original) !== file.originalSha256))
157
+ throw new Error("Codex adapter backup invalid; refusing to patch");
158
+ if (file && !ownsSource(file, current))
159
+ throw new Error("Codex adapter changed by another writer; refusing to overwrite");
160
+ return { path, current, original };
161
+ }));
162
+ const patches = patchAdapterSources(bundles.map(bundle => bundle.original), agentDir, agentId, runtimePath);
163
+ const files = bundles.flatMap((bundle, index) => {
164
+ const patched = patches[index];
165
+ return patched === bundle.original ? [] : [{ ...bundle, patched }];
166
+ });
167
+ if (receipt && (files.length !== receiptFiles(receipt).length || files.some(file => !receiptFiles(receipt).some(previous => previous.path === file.path))))
168
+ throw new Error("Codex adapter path changed; restore before changing installations");
169
+ const nextReceipt = { schema: 2, packageDir, files: files.map(file => ({
170
+ path: file.path, originalSha256: sha256(file.original), patchedSha256: sha256(file.patched),
171
+ // Receipt-first upgrades must remain recoverable if source swapping is interrupted.
172
+ ...(file.current !== file.original && file.current !== file.patched ? { previousPatchedSha256: sha256(file.current) } : {}),
173
+ })) };
174
+ return { files, receipt: nextReceipt, previousReceipt };
74
175
  }
75
176
  export async function installAdapter(agentDir, plan) {
76
- const backup = await readOptional(backupPath(agentDir));
77
- if (backup !== null && backup !== plan.original)
78
- throw new Error("Foreign adapter backup");
79
- await replaceFile(backupPath(agentDir), backup, plan.original);
80
- // Receipt first allows crash recovery; source swap is atomic and guarded.
81
- await replaceFile(receiptPath(agentDir), await readOptional(receiptPath(agentDir)), `${JSON.stringify(plan.receipt, null, 2)}\n`);
82
- return { adapterChanged: await replaceFile(plan.path, plan.current, plan.patched), restartRequired: true };
177
+ // Validate every target before any bundle changes; retain exact backups for recovery.
178
+ const backups = await Promise.all(plan.files.map(async (file) => {
179
+ const path = backupPath(agentDir, file);
180
+ const backup = await readOptional(path);
181
+ if (backup !== null && backup !== file.original)
182
+ throw new Error("Foreign adapter backup");
183
+ if (await readOptional(file.path) !== file.current)
184
+ throw new Error("Concurrent adapter edit; refusing to overwrite");
185
+ return { path, backup, original: file.original };
186
+ }));
187
+ for (const backup of backups)
188
+ await replaceFile(backup.path, backup.backup, backup.original);
189
+ await replaceFile(receiptPath(agentDir), plan.previousReceipt, JSON.stringify(plan.receipt, null, 2) + "\n");
190
+ let adapterChanged = false;
191
+ for (const file of plan.files)
192
+ adapterChanged = await replaceFile(file.path, file.current, file.patched) || adapterChanged;
193
+ return { adapterChanged, restartRequired: true };
83
194
  }
84
195
  export async function adapterStatus(agentDir) {
85
196
  const receipt = await readReceipt(agentDir);
86
197
  if (!receipt)
87
198
  return { installed: false };
88
- const current = await readOptional(receipt.path);
89
- return { installed: current !== null && sha256(current) === receipt.patchedSha256, path: receipt.path, supportedVersion: SUPPORTED_OPENCLAW };
199
+ const files = receiptFiles(receipt);
200
+ const installed = (await Promise.all(files.map(async (file) => {
201
+ const current = await readOptional(file.path);
202
+ return current !== null && sha256(current) === file.patchedSha256;
203
+ }))).every(Boolean);
204
+ return { installed, path: files[0].path, paths: files.map(file => file.path), minimumVersion: MINIMUM_OPENCLAW };
90
205
  }
91
206
  export async function restoreAdapter(agentDir) {
92
207
  const receipt = await readReceipt(agentDir);
93
208
  if (!receipt)
94
209
  return;
95
- const current = await readOptional(receipt.path);
96
- const original = await readOptional(backupPath(agentDir));
97
- if (original === null || sha256(original) !== receipt.originalSha256)
98
- throw new Error("Adapter backup invalid");
99
- if (current === original)
100
- return;
101
- if (current === null || sha256(current) !== receipt.patchedSha256)
102
- throw new Error("Foreign adapter edit; refusing restore");
103
- await replaceFile(receipt.path, current, original);
210
+ const files = await Promise.all(receiptFiles(receipt).map(async (file) => {
211
+ const current = await readOptional(file.path);
212
+ const original = await readOptional(backupPath(agentDir, file, receipt.schema === 1));
213
+ if (original === null || sha256(original) !== file.originalSha256)
214
+ throw new Error("Adapter backup invalid");
215
+ if (!ownsSource(file, current))
216
+ throw new Error("Foreign adapter edit; refusing restore");
217
+ return { path: file.path, current, original };
218
+ }));
219
+ for (const file of files)
220
+ await replaceFile(file.path, file.current, file.original);
104
221
  }
@@ -0,0 +1,2 @@
1
+ export declare const MINIMUM_OPENCLAW = "2026.9.2";
2
+ export declare function assertSupportedVersion(version: unknown, product: string): void;
@@ -0,0 +1,9 @@
1
+ export const MINIMUM_OPENCLAW = "2026.9.2";
2
+ export function assertSupportedVersion(version, product) {
3
+ const match = typeof version === "string" ? /^(\d+)\.(\d+)\.(\d+)(?:-\d+)?$/u.exec(version) : null;
4
+ const supported = match && (Number(match[1]) > 2026 ||
5
+ (Number(match[1]) === 2026 && (Number(match[2]) > 9 ||
6
+ (Number(match[2]) === 9 && Number(match[3]) >= 2))));
7
+ if (!supported)
8
+ throw new Error(`Frozen bridge requires ${product} >=${MINIMUM_OPENCLAW} (stable release)`);
9
+ }
@@ -1,5 +1,4 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
2
- export declare const SUPPORTED_OPENCLAW = "2026.9.2";
3
2
  export declare function openclawRoot(): Promise<string>;
4
3
  export declare function buildSkillsCatalog(config: OpenClawConfig, agentId: string, workspaceDir: string): Promise<{
5
4
  prompt: string;
package/dist/compiler.js CHANGED
@@ -2,7 +2,7 @@ import { readFile, readdir } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { readOptional, sha256 } from "./manager.js";
5
- export const SUPPORTED_OPENCLAW = "2026.9.2";
5
+ import { assertSupportedVersion } from "./compatibility.js";
6
6
  export async function openclawRoot() {
7
7
  let directory = dirname(fileURLToPath(import.meta.resolve("openclaw/plugin-sdk/agent-runtime")));
8
8
  for (;;) {
@@ -10,8 +10,7 @@ export async function openclawRoot() {
10
10
  if (source) {
11
11
  const pkg = JSON.parse(source);
12
12
  if (pkg && typeof pkg === "object" && "name" in pkg && pkg.name === "openclaw") {
13
- if (!("version" in pkg) || pkg.version !== SUPPORTED_OPENCLAW)
14
- throw new Error(`Frozen bridge supports OpenClaw ${SUPPORTED_OPENCLAW} only; review upgrades before syncing`);
13
+ assertSupportedVersion("version" in pkg ? pkg.version : undefined, "OpenClaw");
15
14
  return directory;
16
15
  }
17
16
  }
@@ -21,21 +20,23 @@ export async function openclawRoot() {
21
20
  directory = parent;
22
21
  }
23
22
  }
24
- // OpenClaw has no public catalog-building SDK. Pin this single internal export,
23
+ // OpenClaw has no public catalog-building SDK. Validate this internal export,
25
24
  // retaining its eligibility, agent allowlist, prompt visibility and size limits.
26
25
  export async function buildSkillsCatalog(config, agentId, workspaceDir) {
27
26
  const dist = join(await openclawRoot(), "dist");
28
- const names = (await readdir(dist)).filter(name => /^workspace-skill-prompt-[\w-]+\.js$/u.test(name));
27
+ const names = (await readdir(dist)).filter(name => /^workspace-skill-prompt-[\w-]+\.m?js$/u.test(name));
29
28
  if (names.length !== 1)
30
29
  throw new Error("Unsupported OpenClaw skills module layout");
31
30
  const path = join(dist, names[0]);
32
31
  const source = await readFile(path, "utf8");
33
- if (!source.includes("buildSkillSnapshot as t"))
32
+ const exports = [...source.matchAll(/\bbuildSkillSnapshot as ([\w$]+)\b/gu)];
33
+ if (exports.length !== 1)
34
34
  throw new Error("Unsupported OpenClaw skills export");
35
35
  const module = await import(pathToFileURL(path).href);
36
- if (typeof module.t !== "function")
36
+ const buildSnapshot = module[exports[0][1]];
37
+ if (typeof buildSnapshot !== "function")
37
38
  throw new Error("Missing OpenClaw skill snapshot builder");
38
- const result = module.t(workspaceDir, { config, agentId });
39
+ const result = await buildSnapshot(workspaceDir, { config, agentId });
39
40
  if (!result || typeof result !== "object" || !("prompt" in result) || typeof result.prompt !== "string" ||
40
41
  !("resolvedSkills" in result) || !Array.isArray(result.resolvedSkills))
41
42
  throw new Error("Unsupported OpenClaw skill snapshot result");
package/dist/config.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export declare const PLUGIN_ID = "unblock-codex-prompt";
2
+ export declare const DEFAULT_DISABLED_CODEX_SKILLS: string[];
2
3
  export declare function parseConfig(raw: unknown): {
3
4
  agentId: string;
4
5
  agentName: string | undefined;
5
6
  frozenContext: boolean;
6
7
  promptUrl: string | undefined;
7
8
  suppressExplicitDelegationPrompt: boolean;
9
+ disabledCodexSkills: string[];
8
10
  };
9
11
  export declare function validatePromptUrl(value: string): URL;
10
12
  export declare function renderPrompt(template: string, agentName?: string): string;
package/dist/config.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export const PLUGIN_ID = "unblock-codex-prompt";
2
+ // ChatGPT Pages and Pets skills reach Codex through remote plugins and do not apply to OpenClaw agents.
3
+ export const DEFAULT_DISABLED_CODEX_SKILLS = ["pages", "work-pets"];
2
4
  export function parseConfig(raw) {
3
5
  const value = raw ?? {};
4
6
  if (typeof value !== "object" || Array.isArray(value))
5
7
  throw new Error("Plugin config must be an object");
6
8
  for (const key of Object.keys(value)) {
7
- if (key !== "agentId" && key !== "agentName" && key !== "frozenContext" && key !== "promptUrl" && key !== "suppressExplicitDelegationPrompt")
9
+ if (key !== "agentId" && key !== "agentName" && key !== "frozenContext" && key !== "promptUrl" && key !== "suppressExplicitDelegationPrompt" && key !== "disabledCodexSkills")
8
10
  throw new Error(`Unknown plugin option: ${key}`);
9
11
  }
10
12
  const agentId = "agentId" in value ? value.agentId : "main";
@@ -32,7 +34,12 @@ export function parseConfig(raw) {
32
34
  throw new Error("suppressExplicitDelegationPrompt must be boolean");
33
35
  if (suppressExplicitDelegationPrompt && !frozenContext)
34
36
  throw new Error("suppressExplicitDelegationPrompt requires frozenContext");
35
- return { agentId, agentName: agentName?.trim(), frozenContext, promptUrl, suppressExplicitDelegationPrompt };
37
+ const disabledCodexSkills = "disabledCodexSkills" in value ? value.disabledCodexSkills : DEFAULT_DISABLED_CODEX_SKILLS;
38
+ if (!Array.isArray(disabledCodexSkills) || disabledCodexSkills.length > 64 ||
39
+ disabledCodexSkills.some(entry => typeof entry !== "string" || !/^[\w.@:-]{1,128}$/u.test(entry)) || new Set(disabledCodexSkills).size !== disabledCodexSkills.length) {
40
+ throw new Error("disabledCodexSkills must be a list of up to 64 unique skill or plugin names");
41
+ }
42
+ return { agentId, agentName: agentName?.trim(), frozenContext, promptUrl, suppressExplicitDelegationPrompt, disabledCodexSkills: disabledCodexSkills };
36
43
  }
37
44
  export function validatePromptUrl(value) {
38
45
  const url = new URL(value);
package/dist/index.js CHANGED
@@ -8,13 +8,15 @@ import { buildSkillsCatalog, compilePrompt } from "./compiler.js";
8
8
  import { readFrozenPolicy } from "./runtime.js";
9
9
  import { fetchPrompt, REMOTE_POLICY } from "./remote.js";
10
10
  import { readOptional } from "./manager.js";
11
- import { join } from "node:path";
11
+ import { basename, dirname, isAbsolute, join } from "node:path";
12
12
  import { CATALOG_REFRESH_MS, catalogStatus, refreshCatalog, restoreCatalog, startCatalogRefresh } from "./catalog.js";
13
+ import { codexSkillsStatus, refreshCodexSkills, removeCodexSkills } from "./skills.js";
13
14
  async function refresh(target, automatic = false) {
14
15
  const failures = [];
15
16
  let catalog;
16
17
  let prompt;
17
- // Attempt both independently, sequentially because they share the enrollment lock.
18
+ let skills;
19
+ // Attempt each independently, sequentially because they share the enrollment lock.
18
20
  if (target.settings.suppressExplicitDelegationPrompt) {
19
21
  try {
20
22
  catalog = await refreshCatalog(target.agentDir, { automatic });
@@ -32,9 +34,15 @@ async function refresh(target, automatic = false) {
32
34
  failures.push(error);
33
35
  }
34
36
  }
37
+ try {
38
+ skills = await refreshCodexSkills(target.agentDir, target.settings.disabledCodexSkills);
39
+ }
40
+ catch (error) {
41
+ failures.push(error);
42
+ }
35
43
  if (failures.length)
36
44
  throw new AggregateError(failures, "Refresh incomplete; failed components retain their installed data");
37
- return { catalog, prompt };
45
+ return { catalog, prompt, skills };
38
46
  }
39
47
  async function resolveTarget(config) {
40
48
  const settings = parseConfig(config.plugins?.entries?.[PLUGIN_ID]?.config);
@@ -83,7 +91,7 @@ async function status(config) {
83
91
  export default definePluginEntry({
84
92
  id: PLUGIN_ID,
85
93
  name: "Unblock Codex Prompt",
86
- description: "Explicitly compile a frozen Codex prompt with a narrow, version-pinned OpenClaw bridge.",
94
+ description: "Explicitly compile a frozen Codex prompt with a guarded OpenClaw bridge.",
87
95
  register(api) {
88
96
  let stopCatalogRefresh;
89
97
  // Registration/import stays read-only, including plugin inspection and CLI discovery.
@@ -94,11 +102,20 @@ export default definePluginEntry({
94
102
  const result = await status(ctx.config);
95
103
  ctx.logger.info(`${PLUGIN_ID}: ${JSON.stringify(result)}`);
96
104
  const target = await resolveTarget(ctx.config);
97
- if ((target.settings.suppressExplicitDelegationPrompt || target.settings.promptUrl) && result.enrolled) {
105
+ if (result.enrolled) {
106
+ // Remote Codex plugins can add skills at any time; reconcile before app-servers start.
107
+ try {
108
+ ctx.logger.info(`${PLUGIN_ID}: codex skills ${JSON.stringify(await refreshCodexSkills(target.agentDir, target.settings.disabledCodexSkills))}`);
109
+ }
110
+ catch (error) {
111
+ ctx.logger.error(`${PLUGIN_ID}: codex skills not applied: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ }
114
+ if (result.enrolled) {
98
115
  const catalog = await catalogStatus(target.agentDir);
99
116
  const remote = "remotePrompt" in result ? result.remotePrompt : null;
100
117
  const checkedAt = remote && typeof remote === "object" && "checkedAt" in remote && typeof remote.checkedAt === "number" ? remote.checkedAt : 0;
101
- const nextAt = Math.min(target.settings.suppressExplicitDelegationPrompt ? catalog.nextRefreshAt ?? 0 : Infinity, target.settings.promptUrl ? checkedAt ? checkedAt + CATALOG_REFRESH_MS : 0 : Infinity);
118
+ const nextAt = Math.min(target.settings.suppressExplicitDelegationPrompt ? catalog.nextRefreshAt ?? 0 : Infinity, target.settings.promptUrl ? checkedAt ? checkedAt + CATALOG_REFRESH_MS : 0 : Infinity, Date.now() + CATALOG_REFRESH_MS);
102
119
  stopCatalogRefresh = startCatalogRefresh(async () => {
103
120
  const refreshed = await refresh(target, true);
104
121
  ctx.logger.info(`${PLUGIN_ID}: daily refresh ${JSON.stringify(refreshed)}`);
@@ -113,7 +130,8 @@ export default definePluginEntry({
113
130
  root.command("status").description("Inspect disk state without writing or claiming live adoption")
114
131
  .action(async () => {
115
132
  const target = await resolveTarget(config);
116
- console.log(JSON.stringify({ ...await status(config), catalog: await catalogStatus(target.agentDir) }, null, 2));
133
+ console.log(JSON.stringify({ ...await status(config), catalog: await catalogStatus(target.agentDir),
134
+ codexSkills: await codexSkillsStatus(target.agentDir, target.settings.disabledCodexSkills) }, null, 2));
117
135
  });
118
136
  root.command("refresh-catalog").description("Fetch fresh provider metadata and repair the managed delegation-policy catalog")
119
137
  .action(async () => {
@@ -122,7 +140,7 @@ export default definePluginEntry({
122
140
  throw new Error("Enable suppressExplicitDelegationPrompt before refreshing the catalog");
123
141
  console.log(JSON.stringify(await refreshCatalog(target.agentDir), null, 2));
124
142
  });
125
- root.command("refresh").description("Refresh remote prompt and enabled catalog without rereading frozen agent context")
143
+ root.command("refresh").description("Refresh remote prompt, enabled catalog and codex skills without rereading frozen agent context")
126
144
  .action(async () => console.log(JSON.stringify(await refresh(await resolveTarget(config)), null, 2)));
127
145
  const sync = root.command("sync").description("Explicitly refresh the prompt snapshot and install the reviewed adapter")
128
146
  .option("--adopt", "Take ownership of an existing custom prompt pointer")
@@ -130,7 +148,12 @@ export default definePluginEntry({
130
148
  sync.action(async () => {
131
149
  const target = await resolveTarget(config);
132
150
  const options = sync.opts();
133
- const plan = target.settings.frozenContext ? await prepareAdapter(target.agentDir, target.settings.agentId, options.codexPluginDir) : null;
151
+ // Captured import.meta.url paths expire with the CLI. Older hosts expose the
152
+ // published dist/index.js via source; newer hosts select it via runtimeSource.
153
+ const source = "runtimeSource" in api && typeof api.runtimeSource === "string" ? api.runtimeSource : api.source;
154
+ if (target.settings.frozenContext && (!isAbsolute(source) || basename(source) !== "index.js"))
155
+ throw new Error("Frozen sync requires the packaged plugin runtime entry");
156
+ const plan = target.settings.frozenContext ? await prepareAdapter(target.agentDir, target.settings.agentId, options.codexPluginDir, join(dirname(source), "runtime.js")) : null;
134
157
  const remote = target.settings.promptUrl ? await fetchPrompt(target.settings.promptUrl) : null;
135
158
  const prompt = remote?.prompt ?? await bundledPrompt(target.settings.agentName);
136
159
  const compiled = target.settings.frozenContext ? await compilePrompt(prompt, remote ? REMOTE_POLICY : await readFile(new URL("../prompts/developer.md", import.meta.url), "utf8"), target.workspaceDir, await buildSkillsCatalog(config, target.settings.agentId, target.workspaceDir)) : null;
@@ -139,12 +162,14 @@ export default definePluginEntry({
139
162
  const result = await syncPrompt({ ...target, prompt, ...compiled }, { adopt: options.adopt === true });
140
163
  const adapter = plan ? await installAdapter(target.agentDir, plan) : undefined;
141
164
  const catalog = target.settings.suppressExplicitDelegationPrompt ? await refreshCatalog(target.agentDir) : undefined;
142
- console.log(JSON.stringify({ ...result, adapter, skillCount: compiled?.bundle.skillCount, remotePrompt: remote?.source, catalog }, null, 2));
165
+ const codexSkills = await refreshCodexSkills(target.agentDir, target.settings.disabledCodexSkills);
166
+ console.log(JSON.stringify({ ...result, adapter, skillCount: compiled?.bundle.skillCount, remotePrompt: remote?.source, catalog, codexSkills }, null, 2));
143
167
  });
144
168
  root.command("restore").description("Restore the original pointer; pause automatic management and retain files")
145
169
  .action(async () => {
146
170
  const target = await resolveTarget(config);
147
171
  await restoreCatalog(target.agentDir);
172
+ await removeCodexSkills(target.agentDir);
148
173
  await restoreAdapter(target.agentDir);
149
174
  console.log(JSON.stringify(await restorePrompt({ ...target, prompt: "" }), null, 2));
150
175
  });
@@ -0,0 +1,34 @@
1
+ export type CodexSkill = {
2
+ name: string;
3
+ plugin: string | null;
4
+ };
5
+ /** Skills Codex loads from this agent's home: system, user and cached plugin skills (`plugin:skill`). */
6
+ export declare function discoverCodexSkills(agentDir: string): Promise<CodexSkill[]>;
7
+ /** A list entry names a skill exactly, or a plugin to disable all of its skills. */
8
+ export declare function isListed(skill: CodexSkill, list: readonly string[]): boolean;
9
+ /** `null` when nothing discovered is listed, so the config carries no empty block. */
10
+ export declare function renderSkillsBlock(skills: readonly CodexSkill[], disable: readonly string[]): string | null;
11
+ /** Replace, append or (with `null`) remove the managed block, always leaving it last in the file. */
12
+ export declare function setSkillsBlock(source: string, block: string | null): string;
13
+ /** Disable the listed skills in this agent's Codex home. New plugin skills are caught on the next refresh. */
14
+ export declare function refreshCodexSkills(agentDir: string, disable: readonly string[]): Promise<{
15
+ skipped: boolean;
16
+ changed: boolean;
17
+ restartRequired?: never;
18
+ disabled?: never;
19
+ notFound?: never;
20
+ } | {
21
+ changed: boolean;
22
+ restartRequired: boolean;
23
+ disabled: string[];
24
+ notFound: string[];
25
+ skipped?: never;
26
+ }>;
27
+ export declare function removeCodexSkills(agentDir: string): Promise<{
28
+ changed: boolean;
29
+ }>;
30
+ export declare function codexSkillsStatus(agentDir: string, disable: readonly string[]): Promise<{
31
+ managed: boolean;
32
+ configured: readonly string[];
33
+ disabled: string[];
34
+ }>;
package/dist/skills.js ADDED
@@ -0,0 +1,181 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { getStaticTOMLValue, parseTOML } from "toml-eslint-parser";
4
+ import { readOptional, replaceFile, withLock } from "./manager.js";
5
+ // Codex loads every discovered skill unless a [[skills.config]] rule turns it off, and remote
6
+ // plugins can add skills at any time. This block disables the listed skills and plugins by name,
7
+ // the selector Codex applies to plugin skills (`plugin:skill`) as well as system skills.
8
+ const BEGIN = "# BEGIN unblock-codex-prompt codex skills (managed; replaced on refresh)";
9
+ const END = "# END unblock-codex-prompt codex skills";
10
+ function files(agentDir) {
11
+ const home = join(resolve(agentDir), "codex-home");
12
+ return { home, config: join(home, "config.toml"), state: join(home, ".unblock-codex-prompt.json") };
13
+ }
14
+ function isMissing(error) {
15
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
16
+ }
17
+ async function directories(path) {
18
+ try {
19
+ return (await readdir(path, { withFileTypes: true })).filter(entry => entry.isDirectory() && !entry.name.startsWith("."));
20
+ }
21
+ catch (error) {
22
+ if (isMissing(error))
23
+ return [];
24
+ throw error;
25
+ }
26
+ }
27
+ async function readText(path) {
28
+ try {
29
+ return await readFile(path, "utf8");
30
+ }
31
+ catch (error) {
32
+ if (isMissing(error))
33
+ return null;
34
+ throw error;
35
+ }
36
+ }
37
+ /** Codex names a skill by its SKILL.md frontmatter `name`, falling back to the directory name. */
38
+ function skillName(doc, directory) {
39
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(doc)?.[1] ?? "";
40
+ return /^name:\s*["']?(.+?)["']?\s*$/mu.exec(frontmatter)?.[1]?.trim() || directory;
41
+ }
42
+ async function skillsUnder(root, depth) {
43
+ const names = [];
44
+ for (const entry of await directories(root)) {
45
+ const directory = join(root, entry.name);
46
+ const doc = await readText(join(directory, "SKILL.md"));
47
+ if (doc !== null)
48
+ names.push(skillName(doc, entry.name));
49
+ else if (depth > 1)
50
+ names.push(...await skillsUnder(directory, depth - 1));
51
+ }
52
+ return names;
53
+ }
54
+ /** Skills Codex loads from this agent's home: system, user and cached plugin skills (`plugin:skill`). */
55
+ export async function discoverCodexSkills(agentDir) {
56
+ const { home } = files(agentDir);
57
+ const found = [];
58
+ for (const name of await skillsUnder(join(home, "skills", ".system"), 1))
59
+ found.push({ name, plugin: null });
60
+ for (const name of await skillsUnder(join(home, "skills"), 1))
61
+ found.push({ name, plugin: null });
62
+ // Cache layout: plugins/cache/<marketplace>/<plugin>/<version>/.codex-plugin/plugin.json
63
+ const cache = join(home, "plugins", "cache");
64
+ for (const marketplace of await directories(cache)) {
65
+ for (const plugin of await directories(join(cache, marketplace.name))) {
66
+ for (const version of await directories(join(cache, marketplace.name, plugin.name))) {
67
+ const root = join(cache, marketplace.name, plugin.name, version.name);
68
+ const manifestText = await readText(join(root, ".codex-plugin", "plugin.json"));
69
+ if (manifestText === null)
70
+ continue;
71
+ let manifest;
72
+ try {
73
+ manifest = JSON.parse(manifestText);
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const record = manifest && typeof manifest === "object" ? manifest : {};
79
+ const namespace = typeof record.name === "string" && record.name ? record.name : plugin.name;
80
+ const roots = typeof record.skills === "string" ? [record.skills]
81
+ : Array.isArray(record.skills) ? record.skills.filter((path) => typeof path === "string") : ["./skills/"];
82
+ for (const skillsRoot of roots) {
83
+ for (const name of await skillsUnder(resolve(root, skillsRoot), 3))
84
+ found.push({ name: `${namespace}:${name}`, plugin: namespace });
85
+ }
86
+ }
87
+ }
88
+ }
89
+ const unique = new Map(found.map(skill => [skill.name, skill]));
90
+ return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
91
+ }
92
+ /** A list entry names a skill exactly, or a plugin to disable all of its skills. */
93
+ export function isListed(skill, list) {
94
+ return list.includes(skill.name) || (skill.plugin !== null && list.includes(skill.plugin));
95
+ }
96
+ /** `null` when nothing discovered is listed, so the config carries no empty block. */
97
+ export function renderSkillsBlock(skills, disable) {
98
+ const rules = skills.filter(skill => isListed(skill, disable))
99
+ .map(skill => `[[skills.config]]\nname = ${JSON.stringify(skill.name)}\nenabled = false\n`);
100
+ return rules.length ? [BEGIN, `# Disabled: ${disable.join(", ")}`, ...rules, END].join("\n") + "\n" : null;
101
+ }
102
+ function locateBlock(source) {
103
+ const begin = source.indexOf(BEGIN);
104
+ const end = source.indexOf(END);
105
+ if ((begin === -1) !== (end === -1) || end < begin || source.indexOf(BEGIN, begin + 1) !== -1) {
106
+ throw new Error("The managed codex skills block in config.toml is damaged; restore its BEGIN/END markers or remove it");
107
+ }
108
+ return begin === -1 ? null : { begin, end: end + END.length };
109
+ }
110
+ function skillRules(source) {
111
+ let config;
112
+ try {
113
+ config = getStaticTOMLValue(parseTOML(source, { tomlVersion: "1.0" }));
114
+ }
115
+ catch {
116
+ // Parser messages can include source snippets from credential-bearing TOML.
117
+ throw new Error("config.toml would be invalid with the managed skills block; is skills.config defined inline?");
118
+ }
119
+ const skills = config && typeof config === "object" ? config.skills : undefined;
120
+ const rules = skills && typeof skills === "object" ? skills.config : undefined;
121
+ return Array.isArray(rules) ? rules : [];
122
+ }
123
+ /** Replace, append or (with `null`) remove the managed block, always leaving it last in the file. */
124
+ export function setSkillsBlock(source, block) {
125
+ const found = locateBlock(source);
126
+ let rest = source;
127
+ if (found) {
128
+ const after = source.slice(found.end).replace(/^\r?\n/u, "");
129
+ const before = source.slice(0, found.begin).replace(/\n+$/u, "\n");
130
+ rest = before === "\n" ? after : before + after;
131
+ }
132
+ const result = block === null ? rest : `${rest === "" ? "" : `${rest.replace(/\n*$/u, "\n")}\n`}${block}`;
133
+ if (block !== null) {
134
+ const expected = [...block.matchAll(/^name = (".*")\nenabled = (true|false)$/gmu)].map(match => `${JSON.parse(match[1])}=${match[2]}`);
135
+ const actual = skillRules(result).slice(-expected.length).map(rule => `${String(rule.name)}=${String(rule.enabled)}`);
136
+ if (expected.length && actual.join("\n") !== expected.join("\n"))
137
+ throw new Error("Managed skills rules did not parse as the last skills.config entries");
138
+ }
139
+ return result;
140
+ }
141
+ async function enrolled(agentDir) {
142
+ const source = await readOptional(files(agentDir).state);
143
+ if (source === null)
144
+ return false;
145
+ const state = JSON.parse(source);
146
+ return !!state && typeof state === "object" && "active" in state && state.active === true;
147
+ }
148
+ /** Disable the listed skills in this agent's Codex home. New plugin skills are caught on the next refresh. */
149
+ export async function refreshCodexSkills(agentDir, disable) {
150
+ return withLock({ agentDir, prompt: "" }, async () => {
151
+ if (!await enrolled(agentDir))
152
+ return { skipped: true, changed: false };
153
+ const path = files(agentDir).config;
154
+ const current = await readOptional(path) ?? "";
155
+ const skills = await discoverCodexSkills(agentDir);
156
+ const changed = await replaceFile(path, current, setSkillsBlock(current, renderSkillsBlock(skills, disable)));
157
+ return {
158
+ changed,
159
+ // Codex reads skills config when its app-server starts; running sessions keep their list.
160
+ restartRequired: changed,
161
+ disabled: skills.filter(skill => isListed(skill, disable)).map(skill => skill.name),
162
+ notFound: disable.filter(entry => !skills.some(skill => isListed(skill, [entry]))),
163
+ };
164
+ });
165
+ }
166
+ export async function removeCodexSkills(agentDir) {
167
+ return withLock({ agentDir, prompt: "" }, async () => {
168
+ const path = files(agentDir).config;
169
+ const current = await readOptional(path);
170
+ if (current === null)
171
+ return { changed: false };
172
+ return { changed: await replaceFile(path, current, setSkillsBlock(current, null)) };
173
+ });
174
+ }
175
+ export async function codexSkillsStatus(agentDir, disable) {
176
+ const source = await readOptional(files(agentDir).config) ?? "";
177
+ const found = locateBlock(source);
178
+ const managed = found ? source.slice(found.begin, found.end) : "";
179
+ const disabled = [...managed.matchAll(/^name = (".*")\nenabled = false$/gmu)].map(match => JSON.parse(match[1]));
180
+ return { managed: found !== null, configured: disable, disabled };
181
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-codex-prompt",
3
3
  "name": "Unblock Codex Prompt",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "description": "Maintain an agent-scoped Codex base prompt and its model_instructions_file pointer.",
6
6
  "activation": { "onStartup": true },
7
7
  "configSchema": {
@@ -12,14 +12,16 @@
12
12
  "agentName": { "type": "string", "minLength": 1, "maxLength": 100 },
13
13
  "frozenContext": { "type": "boolean", "default": false },
14
14
  "promptUrl": { "type": "string", "format": "uri", "pattern": "^https://" },
15
- "suppressExplicitDelegationPrompt": { "type": "boolean", "default": false }
15
+ "suppressExplicitDelegationPrompt": { "type": "boolean", "default": false },
16
+ "disabledCodexSkills": { "type": "array", "default": ["pages", "work-pets"], "maxItems": 64, "uniqueItems": true, "items": { "type": "string", "pattern": "^[\\w.@:-]{1,128}$" } }
16
17
  }
17
18
  },
18
19
  "uiHints": {
19
20
  "agentId": { "label": "OpenClaw agent ID", "help": "Only this agent's managed Codex home is modified. Defaults to main." },
20
21
  "agentName": { "label": "Agent name", "help": "Optional name in the shared prompt. Omit for a generic opening." },
21
- "frozenContext": { "label": "Frozen context bridge", "help": "Opt in to the reviewed OpenClaw 2026.9.2 adapter. Initial setup requires explicit sync and activation. Local documents and skills stay frozen until another sync." },
22
+ "frozenContext": { "label": "Frozen context bridge", "help": "Opt in on OpenClaw/Codex >=2026.9.2 with a supported context-assembly layout. Initial setup requires explicit sync and activation. Local documents and skills stay frozen until another sync." },
22
23
  "promptUrl": { "label": "Fleet prompt endpoint", "help": "Public HTTPS plain-text endpoint with X-Prompt-Revision SHA-256. After initial sync, refreshed daily and when overdue on startup without refreshing agent context. Requires frozenContext; replaces both bundled policy files." },
23
- "suppressExplicitDelegationPrompt": { "label": "Suppress conflicting Codex delegation policy", "help": "Manage a model_catalog_json override, refreshed from the configured provider daily and when overdue on startup. Requires frozenContext and an available provider env_key. Generated-file edits are replaced on refresh." }
24
+ "suppressExplicitDelegationPrompt": { "label": "Suppress conflicting Codex delegation policy", "help": "Manage a model_catalog_json override, refreshed from the configured provider daily and when overdue on startup. Requires frozenContext and an available provider env_key. Generated-file edits are replaced on refresh." },
25
+ "disabledCodexSkills": { "label": "Codex skills to remove", "help": "Plugin names (all of their skills) or exact skill names to disable in Codex's native skills list. Defaults to ChatGPT Pages and Pets. Applied through a managed [[skills.config]] block at startup, sync and daily refresh; other skills are untouched. Set [] to stop managing Codex skills." }
24
26
  }
25
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-codex-prompt",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Manage a shared Codex base prompt for an OpenClaw agent.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "release:check": "node scripts/check-release-version.mjs",
18
18
  "prepack": "npm run build"
19
19
  },
20
- "dependencies": { "toml-eslint-parser": "^1.0.3" },
20
+ "dependencies": { "acorn": "^8.18.0", "toml-eslint-parser": "^1.0.3" },
21
21
  "devDependencies": {
22
22
  "@openclaw/plugin-inspector": "^0.3.25",
23
23
  "@types/node": "^22.0.0",