@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 +37 -8
- package/RELEASING.md +6 -1
- package/dist/adapter.d.ts +30 -15
- package/dist/adapter.js +177 -60
- package/dist/compatibility.d.ts +2 -0
- package/dist/compatibility.js +9 -0
- package/dist/compiler.d.ts +0 -1
- package/dist/compiler.js +9 -8
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -2
- package/dist/index.js +35 -10
- package/dist/skills.d.ts +34 -0
- package/dist/skills.js +181 -0
- package/openclaw.plugin.json +6 -4
- package/package.json +2 -2
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
|
|
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
|
|
31
|
-
|
|
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
|
|
38
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
-
export declare function prepareAdapter(agentDir: string, agentId: string, suppliedDir
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
25
|
+
schema: 2;
|
|
17
26
|
packageDir: string;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
+
paths?: never;
|
|
44
|
+
minimumVersion?: never;
|
|
31
45
|
} | {
|
|
32
46
|
installed: boolean;
|
|
33
47
|
path: string;
|
|
34
|
-
|
|
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 {
|
|
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 {
|
|
6
|
-
|
|
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
|
|
9
|
-
const
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
\tconst unblockPolicy = unblockFrozenPolicy(params);
|
|
45
|
-
\tif (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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"
|
|
59
|
-
throw new Error(
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
throw new Error("
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
89
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,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
|
+
}
|
package/dist/compiler.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/skills.d.ts
ADDED
|
@@ -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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-codex-prompt",
|
|
3
3
|
"name": "Unblock Codex Prompt",
|
|
4
|
-
"version": "0.1
|
|
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
|
|
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
|
|
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",
|