@unblocklabs/unblock-codex-prompt 0.1.0 → 0.2.0

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.0.** 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,18 @@ 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 and 2026.9.4, including the newer parent-local context path. Bundle names
33
+ may change; fingerprints of the context-assembly functions must still match.
34
+ Changed assembly is refused pending review, rather than patched blindly.
32
35
  - Managed local stdio Codex, agent-scoped home, no custom command/arguments.
33
36
  - One target agent per installation. Other agents use upstream behavior. Native
34
37
  children may inherit the parent's frozen context.
35
38
 
36
39
  This bridge uses two private upstream seams because public prompt hooks cannot
37
40
  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
+ one Codex adapter bundle (plus the parent-local context entry point on newer builds).
42
+ It validates private seams and is not a promise of compatibility with unseen layouts.
39
43
  The catalog uses OpenClaw's eligibility, allowlist, visibility and size-limit
40
44
  logic. Session-specific and execution-directory skills are not snapshotted.
41
45
  Remote-node-only skills are not added; this is for dedicated local Mac agents.
@@ -46,7 +50,7 @@ Remote-node-only skills are not added; this is for dedicated local Mac agents.
46
50
  npm ci
47
51
  npm run preflight
48
52
  npm pack
49
- openclaw plugins install ./unblocklabs-unblock-codex-prompt-0.1.0.tgz --force --accept-capabilities
53
+ openclaw plugins install ./unblocklabs-unblock-codex-prompt-0.2.0.tgz --force --accept-capabilities
50
54
  ```
51
55
 
52
56
  Include the plugin in `plugins.allow` if applicable, then configure:
@@ -181,6 +185,26 @@ contents without a Gateway restart. Content-only updates report
181
185
  are not reset, and their adoption is not guaranteed. The plugin never restarts
182
186
  the Gateway automatically; revalidate this behavior when upgrading Codex.
183
187
 
188
+ ## Codex skills removal
189
+
190
+ Codex lists every skill it finds in the managed Codex home, including skills from
191
+ ChatGPT remote plugins that sync in on their own. `disabledCodexSkills` names the
192
+ ones to remove: a plugin name removes all of that plugin's skills, and an exact
193
+ skill name (`pages:write-page`, `imagegen`) removes one. It defaults to
194
+ `["pages", "work-pets"]` (ChatGPT Pages and Pets). Other skills are untouched.
195
+
196
+ ```json
197
+ "disabledCodexSkills": ["pages", "work-pets"]
198
+ ```
199
+
200
+ The plugin scans `skills/` and `plugins/cache/` under the managed Codex home and
201
+ writes one marked block of `[[skills.config]] name = "…" enabled = false` rules at
202
+ the end of `config.toml`. It re-applies the block at Gateway startup, `sync`,
203
+ `refresh`, and the daily refresh, so newly synced plugin skills are caught.
204
+ Codex reads the rules when its app-server starts; running sessions keep their
205
+ list until the Gateway or app-server restarts. Set `[]` to remove the block.
206
+ `restore` also removes it. User-authored `[[skills.config]]` entries stay as written.
207
+
184
208
  ## Restore
185
209
 
186
210
  ```sh
@@ -212,7 +236,7 @@ without restoring leaves the frozen base but re-enables live injections.
212
236
 
213
237
  ```sh
214
238
  npm run preflight
215
- npm run release:check -- v0.1.0
239
+ npm run release:check -- v0.2.0
216
240
  ```
217
241
 
218
242
  Tests cover compilation, explicit refresh, native/private-body exclusion,
@@ -221,7 +245,8 @@ pointer ownership and foreign edits. See [VALIDATION.md](VALIDATION.md) for live
221
245
  proof and limits.
222
246
 
223
247
  Install with
224
- `openclaw plugins install npm:@unblocklabs/unblock-codex-prompt@0.1.0 --accept-capabilities`.
248
+ the packed archive above for an unreleased candidate. After publication, use
249
+ `openclaw plugins install npm:@unblocklabs/unblock-codex-prompt@0.2.0 --accept-capabilities`.
225
250
  The existing [GitHub Release workflow](RELEASING.md) publishes npm only when a
226
251
  GitHub Release is published. Normal pushes do not release the package.
227
252
 
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
@@ -27,11 +27,11 @@ export declare function installAdapter(agentDir: string, plan: Awaited<ReturnTyp
27
27
  export declare function adapterStatus(agentDir: string): Promise<{
28
28
  installed: boolean;
29
29
  path?: never;
30
- supportedVersion?: never;
30
+ minimumVersion?: never;
31
31
  } | {
32
32
  installed: boolean;
33
33
  path: string;
34
- supportedVersion: string;
34
+ minimumVersion: string;
35
35
  }>;
36
36
  export declare function restoreAdapter(agentDir: string): Promise<void>;
37
37
  export {};
package/dist/adapter.js CHANGED
@@ -1,12 +1,20 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { parse } from "acorn";
4
5
  import { readOptional, replaceFile, sha256 } from "./manager.js";
5
- import { SUPPORTED_OPENCLAW, openclawRoot } from "./compiler.js";
6
- const ORIGINAL_SHA256 = "344d4a6332d59b5eb1d7551d6d308563f339d5d2251809c8f25e8cec04ede342";
6
+ import { openclawRoot } from "./compiler.js";
7
+ import { assertSupportedVersion, MINIMUM_OPENCLAW } from "./compatibility.js";
7
8
  const MARKER = "// unblock-codex-prompt bridge v1";
8
- const developer = "function buildDeveloperInstructions(params, options = {}) {";
9
- const collaboration = "function buildTurnScopedCollaborationInstructions(params, options = {}) {";
9
+ const BUNDLE_NAME = /^thread-lifecycle-[\w-]+\.m?js$/u;
10
+ const HASH = /^[a-f0-9]{64}$/u;
11
+ // Fingerprint only the context-assembly seams, not release-specific bundle names
12
+ // or unrelated code. New releases can use either reviewed layout unchanged.
13
+ const CONTEXT_HASHES = {
14
+ legacy: "1fb45c46c50a63f68e8b6bdfbc5d7ae2d323470c00db69779d159592868940bb",
15
+ parent: "5ee80edf3f3887e841def5d64896a3d27c35063f6d4803dceeb8b8bf07b654f0",
16
+ collaboration: "aea7e95ca43f0997b0a3f616f327132bfdd057bb731e6eead132fba21de3f981",
17
+ };
10
18
  function receiptPath(agentDir) { return join(agentDir, "codex-home", ".unblock-codex-prompt-adapter.json"); }
11
19
  function backupPath(agentDir) { return join(agentDir, "codex-home", ".unblock-codex-prompt-adapter-original.js"); }
12
20
  export async function readReceipt(agentDir) {
@@ -17,15 +25,39 @@ export async function readReceipt(agentDir) {
17
25
  if (!value || typeof value !== "object" || !("schema" in value) || value.schema !== 1 ||
18
26
  !("packageDir" in value) || typeof value.packageDir !== "string" ||
19
27
  !("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"))
28
+ !isAbsolute(value.packageDir) || !BUNDLE_NAME.test(basename(value.path)) ||
29
+ !("originalSha256" in value) || typeof value.originalSha256 !== "string" || !HASH.test(value.originalSha256) ||
30
+ !("patchedSha256" in value) || typeof value.patchedSha256 !== "string" || !HASH.test(value.patchedSha256) ||
31
+ value.path !== join(value.packageDir, "dist", basename(value.path)))
23
32
  throw new Error("Invalid adapter receipt");
24
33
  return { schema: 1, packageDir: value.packageDir, path: value.path, originalSha256: value.originalSha256, patchedSha256: value.patchedSha256 };
25
34
  }
26
35
  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");
36
+ if (source.includes(MARKER))
37
+ throw new Error("Unsupported Codex adapter shape: already patched");
38
+ const functions = parse(source, { ecmaVersion: "latest", sourceType: "module" }).body.filter(node => node.type === "FunctionDeclaration");
39
+ const find = (name) => {
40
+ const matches = functions.filter(node => node.id?.name === name);
41
+ if (matches.length !== 1)
42
+ throw new Error(`Unsupported Codex adapter shape: ${name}`);
43
+ return matches[0];
44
+ };
45
+ const developer = find("buildDeveloperInstructions");
46
+ const collaboration = find("buildTurnScopedCollaborationInstructions");
47
+ const parent = functions.some(node => node.id?.name === "buildCodexParentLocalInstructions") ? find("buildCodexParentLocalInstructions") : null;
48
+ for (const fn of [developer, collaboration, ...(parent ? [parent] : [])]) {
49
+ if (fn.async || fn.generator || fn.params.length !== 2 || fn.params[0]?.type !== "Identifier" || fn.params[0].name !== "params" ||
50
+ !/^(options|options\s*=\s*\{\})$/u.test(source.slice(fn.params[1].start, fn.params[1].end)))
51
+ throw new Error("Unsupported Codex adapter shape: parameters");
52
+ }
53
+ for (const name of ["buildDefaultCollaborationInstructions", "buildCronCollaborationInstructions"]) {
54
+ if (find(name).params.length !== 0)
55
+ throw new Error("Unsupported Codex adapter shape: mode helpers");
56
+ }
57
+ const fingerprint = (fn) => sha256(source.slice(fn.start, fn.end));
58
+ if (fingerprint(collaboration) !== (parent ? CONTEXT_HASHES.collaboration : CONTEXT_HASHES.legacy) ||
59
+ (parent && fingerprint(parent) !== CONTEXT_HASHES.parent))
60
+ throw new Error("Unsupported Codex context assembly; review the changed upstream functions before syncing");
29
61
  const prefix = `${MARKER}
30
62
  import { createRequire as unblockPromptCreateRequire } from "node:module";
31
63
  const unblockPromptRequire = unblockPromptCreateRequire(import.meta.url);
@@ -40,11 +72,15 @@ function unblockFrozenPolicy(params) {
40
72
  return unblockPromptRequire(${JSON.stringify(runtimePath)}).readFrozenPolicy(${JSON.stringify(agentDir)});
41
73
  }
42
74
  `;
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();`);
75
+ const insertions = [
76
+ { at: developer.body.start + 1, 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");' },
77
+ { at: collaboration.body.start + 1, text: '\n\tif (unblockFrozenPolicy(params) !== null) return params.trigger === "cron" ? buildCronCollaborationInstructions() : buildDefaultCollaborationInstructions();' },
78
+ ...(parent ? [{ at: parent.body.start + 1, text: '\n\tif (unblockFrozenPolicy(params) !== null) return params.trigger === "cron" ? buildCronCollaborationInstructions() : null;' }] : []),
79
+ ];
80
+ let patched = source;
81
+ for (const { at, text } of insertions.sort((a, b) => b.at - a.at))
82
+ patched = patched.slice(0, at) + text + patched.slice(at);
83
+ return prefix + patched;
48
84
  }
49
85
  export async function prepareAdapter(agentDir, agentId, suppliedDir) {
50
86
  await openclawRoot();
@@ -55,22 +91,25 @@ export async function prepareAdapter(agentDir, agentId, suppliedDir) {
55
91
  if (receipt && receipt.packageDir !== packageDir)
56
92
  throw new Error("Restore the previous adapter before changing Codex installations");
57
93
  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")
94
+ if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== "@openclaw/codex")
95
+ throw new Error("Expected the official @openclaw/codex package");
96
+ assertSupportedVersion("version" in pkg ? pkg.version : undefined, "@openclaw/codex");
97
+ const files = (await readdir(join(packageDir, "dist"))).filter(name => BUNDLE_NAME.test(name));
98
+ if (files.length !== 1)
62
99
  throw new Error("Unsupported Codex bundle layout");
63
100
  const path = join(packageDir, "dist", files[0]);
64
101
  const current = await readOptional(path);
65
102
  if (current === null)
66
103
  throw new Error("Missing Codex adapter");
67
104
  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");
105
+ if (receipt && receipt.path !== path)
106
+ throw new Error("Codex adapter path changed; restore before changing installations");
107
+ if (original === null || (receipt && sha256(original) !== receipt.originalSha256))
108
+ throw new Error("Codex adapter backup invalid; refusing to patch");
70
109
  if (receipt && sha256(current) !== receipt.patchedSha256 && current !== original)
71
110
  throw new Error("Codex adapter changed by another writer; refusing to overwrite");
72
111
  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) } };
112
+ return { path, current, original, patched, receipt: { schema: 1, packageDir, path, originalSha256: sha256(original), patchedSha256: sha256(patched) } };
74
113
  }
75
114
  export async function installAdapter(agentDir, plan) {
76
115
  const backup = await readOptional(backupPath(agentDir));
@@ -86,7 +125,7 @@ export async function adapterStatus(agentDir) {
86
125
  if (!receipt)
87
126
  return { installed: false };
88
127
  const current = await readOptional(receipt.path);
89
- return { installed: current !== null && sha256(current) === receipt.patchedSha256, path: receipt.path, supportedVersion: SUPPORTED_OPENCLAW };
128
+ return { installed: current !== null && sha256(current) === receipt.patchedSha256, path: receipt.path, minimumVersion: MINIMUM_OPENCLAW };
90
129
  }
91
130
  export async function restoreAdapter(agentDir) {
92
131
  const receipt = await readReceipt(agentDir);
@@ -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
@@ -10,11 +10,13 @@ import { fetchPrompt, REMOTE_POLICY } from "./remote.js";
10
10
  import { readOptional } from "./manager.js";
11
11
  import { 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")
@@ -139,12 +157,14 @@ export default definePluginEntry({
139
157
  const result = await syncPrompt({ ...target, prompt, ...compiled }, { adopt: options.adopt === true });
140
158
  const adapter = plan ? await installAdapter(target.agentDir, plan) : undefined;
141
159
  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));
160
+ const codexSkills = await refreshCodexSkills(target.agentDir, target.settings.disabledCodexSkills);
161
+ console.log(JSON.stringify({ ...result, adapter, skillCount: compiled?.bundle.skillCount, remotePrompt: remote?.source, catalog, codexSkills }, null, 2));
143
162
  });
144
163
  root.command("restore").description("Restore the original pointer; pause automatic management and retain files")
145
164
  .action(async () => {
146
165
  const target = await resolveTarget(config);
147
166
  await restoreCatalog(target.agentDir);
167
+ await removeCodexSkills(target.agentDir);
148
168
  await restoreAdapter(target.agentDir);
149
169
  console.log(JSON.stringify(await restorePrompt({ ...target, prompt: "" }), null, 2));
150
170
  });
@@ -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.0",
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.0",
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",