@sabaiway/agent-workflow-kit 1.5.2 → 1.7.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/CHANGELOG.md +67 -0
- package/README.md +12 -5
- package/SKILL.md +48 -20
- package/bin/install.mjs +33 -50
- package/bin/install.test.mjs +30 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +178 -0
- package/bridges/antigravity-cli-bridge/bin/agy.sh +133 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -0
- package/bridges/antigravity-cli-bridge/capability.json +22 -0
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +108 -0
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +93 -0
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +51 -0
- package/bridges/antigravity-cli-bridge/setup/README.md +65 -0
- package/bridges/codex-cli-bridge/SKILL.md +148 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +84 -0
- package/bridges/codex-cli-bridge/capability.json +22 -0
- package/bridges/codex-cli-bridge/references/driving-codex.md +97 -0
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +105 -0
- package/bridges/codex-cli-bridge/setup/README.md +78 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/package.json +3 -2
- package/references/templates/AGENTS.md +2 -1
- package/tools/delegation.mjs +4 -4
- package/tools/delegation.test.mjs +4 -3
- package/tools/detect-backends.mjs +36 -0
- package/tools/detect-backends.test.mjs +102 -0
- package/tools/fs-safe.mjs +129 -0
- package/tools/fs-safe.test.mjs +200 -0
- package/tools/inject-methodology.mjs +131 -23
- package/tools/inject-methodology.test.mjs +232 -1
- package/tools/setup-backends.mjs +468 -0
- package/tools/setup-backends.test.mjs +500 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Setting up OpenAI Codex CLI (`codex`) on a clean machine
|
|
2
|
+
|
|
3
|
+
This setup is **secret-free**. `codex` itself is **not** bundled — it requires a binary install and a
|
|
4
|
+
one-time interactive sign-in with your own ChatGPT subscription. Do this once per machine, then the
|
|
5
|
+
skill works in any git repository that has a root `AGENTS.md`.
|
|
6
|
+
|
|
7
|
+
## 1. Install the binary
|
|
8
|
+
|
|
9
|
+
Install the official OpenAI Codex CLI using the current official channel for your platform, then
|
|
10
|
+
confirm it is on `PATH`:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @openai/codex # or: brew install codex (use the current official channel)
|
|
14
|
+
codex --version # this skill was verified with codex-cli 0.140.0 or newer
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The binary is **`codex`**. If `codex --version` works but the wrappers can't find it, fix your
|
|
18
|
+
`PATH`. If the installed binary's help disagrees with this skill's references, the live binary wins.
|
|
19
|
+
|
|
20
|
+
## 2. Sign in once (subscription only)
|
|
21
|
+
|
|
22
|
+
Run `codex login` once and complete the **ChatGPT** sign-in:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
codex login
|
|
26
|
+
codex login status # expect: Logged in using ChatGPT
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This caches credentials under `CODEX_HOME` (`~/.codex`, e.g. `~/.codex/auth.json`). That directory is
|
|
30
|
+
**personal** — never copy, commit, package, print, or share it. This skill needs **no API keys** and
|
|
31
|
+
must not be configured with api-key billing; both wrappers unset every `*_API_KEY` (and
|
|
32
|
+
`OPENAI_BASE_URL`) and pass `--ignore-user-config`, so billing can never silently fall back to
|
|
33
|
+
pay-as-you-go and a personal `~/.codex/config.toml` can never change behaviour.
|
|
34
|
+
|
|
35
|
+
## 3. Put the wrappers on `PATH`
|
|
36
|
+
|
|
37
|
+
The skill ships two wrappers: `bin/codex-exec.sh` and `bin/codex-review.sh`. Expose them on `PATH`
|
|
38
|
+
under the stable names `codex-exec` / `codex-review` via idempotent managed symlinks (refuse to
|
|
39
|
+
clobber a non-symlink):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
mkdir -p "$HOME/.local/bin"
|
|
43
|
+
skill_dir="$HOME/.claude/skills/codex-cli-bridge" # adjust if installed elsewhere
|
|
44
|
+
for w in codex-exec codex-review; do
|
|
45
|
+
src="$skill_dir/bin/$w.sh"
|
|
46
|
+
dst="$HOME/.local/bin/$w"
|
|
47
|
+
if [ -e "$dst" ] && [ ! -L "$dst" ]; then
|
|
48
|
+
echo "STOP: $dst exists and is not a symlink"; exit 1
|
|
49
|
+
fi
|
|
50
|
+
chmod +x "$src"
|
|
51
|
+
ln -sfn "$src" "$dst"
|
|
52
|
+
done
|
|
53
|
+
export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc / ~/.zshrc to persist
|
|
54
|
+
command -v codex-exec && command -v codex-review
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 4. Smoke test
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
codex --version # version prints
|
|
61
|
+
env -u OPENAI_API_KEY -u CODEX_API_KEY -u OPENAI_BASE_URL codex login status
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Expected: the version prints, and login status includes exactly `Logged in using ChatGPT` (the
|
|
65
|
+
`env -u …` mirrors the wrappers, so stray keys can't mask the real auth mode). If the status does not
|
|
66
|
+
include that text, redo step 2. If a wrapper reports `'codex' not found`, fix your `PATH` (step 1);
|
|
67
|
+
if it reports a missing git work tree or root `AGENTS.md`, run it from a project root that has them.
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
- The wrappers are **subscription-only** by design and will not use api-key billing.
|
|
72
|
+
- `codex-exec` runs a **workspace-write** sandbox with **network OFF**; `codex-review` runs
|
|
73
|
+
**read-only**. See [`../references/sandbox-and-flags.md`](../references/sandbox-and-flags.md).
|
|
74
|
+
- `codex exec` requires a git repository, and the wrappers also require a root `AGENTS.md`. The
|
|
75
|
+
orchestrator commits, not codex. Re-run `codex login` only when the cached login expires or the
|
|
76
|
+
account changes.
|
|
77
|
+
- On Linux, install `bubblewrap` (`sudo apt install bubblewrap` or equivalent) to silence the
|
|
78
|
+
"could not find bubblewrap" warning; codex otherwise uses a bundled copy.
|
package/capability.json
CHANGED
package/migrations/README.md
CHANGED
|
@@ -11,7 +11,7 @@ releases add files/templates, which `upgrade` reconciles without a migration.
|
|
|
11
11
|
2. Select every migration whose `<version>` is **strictly newer** than the stamp.
|
|
12
12
|
3. Apply them in **ascending semver order**.
|
|
13
13
|
4. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0` today — the
|
|
14
|
-
shared lineage, **not** this skill's package version
|
|
14
|
+
shared lineage, **not** this skill's npm package version). A stamp greater than the head → STOP.
|
|
15
15
|
|
|
16
16
|
## Authoring rules
|
|
17
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"references/",
|
|
50
50
|
"launchers/",
|
|
51
51
|
"migrations/",
|
|
52
|
-
"tools/"
|
|
52
|
+
"tools/",
|
|
53
|
+
"bridges/"
|
|
53
54
|
],
|
|
54
55
|
"engines": {
|
|
55
56
|
"node": ">=18"
|
|
@@ -53,7 +53,8 @@ All project knowledge lives in `docs/ai/`. Layered, lazy-loaded context:
|
|
|
53
53
|
|
|
54
54
|
Start-of-session, during-work, and task-completion procedures live in [`docs/ai/agent_rules.md`](./docs/ai/agent_rules.md) §1. **Read it before any code change.**
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
<!-- workflow:methodology:start -->
|
|
57
|
+
<!-- workflow:methodology:end -->
|
|
57
58
|
|
|
58
59
|
---
|
|
59
60
|
|
package/tools/delegation.mjs
CHANGED
|
@@ -83,10 +83,10 @@ export const handoffPlan = (delegate) =>
|
|
|
83
83
|
: {
|
|
84
84
|
mode: 'fallback',
|
|
85
85
|
memoryWrites: [],
|
|
86
|
-
// Fallback ships the kit's OWN AGENTS.md
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology
|
|
86
|
+
// Fallback now ships the kit's OWN AGENTS.md carrying the EMPTY methodology slot (Plan 2);
|
|
87
|
+
// the kit reconciles it (ensure-slot + inject-because-empty) exactly like the delegate
|
|
88
|
+
// path — so both paths end with a FILLED slot, not inline methodology.
|
|
89
|
+
kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
|
|
90
90
|
stampsPresent: ['.workflow-version'],
|
|
91
91
|
memoryRaisesCommitGate: false,
|
|
92
92
|
commitGate: 'kit-only-after-injection',
|
|
@@ -106,10 +106,11 @@ describe('handoffPlan — stamp sets + single commit gate', () => {
|
|
|
106
106
|
assert.deepEqual(p.memoryWrites, []);
|
|
107
107
|
assert.equal(p.memoryRaisesCommitGate, false);
|
|
108
108
|
assert.equal(p.commitGate, 'kit-only-after-injection');
|
|
109
|
-
// Fallback ships the kit's own AGENTS.md with
|
|
109
|
+
// Fallback now ships the kit's own AGENTS.md with the EMPTY methodology slot, which the kit
|
|
110
|
+
// reconciles + fills — the same slot mechanism as the delegate path (Plan 2).
|
|
110
111
|
assert.ok(
|
|
111
|
-
p.kitWrites.some((w) => w.includes('
|
|
112
|
-
'fallback kitWrites should describe
|
|
112
|
+
p.kitWrites.some((w) => w.includes('slot')) && !p.kitWrites.some((w) => w.includes('inline')),
|
|
113
|
+
'fallback kitWrites should describe the methodology slot, not inline methodology',
|
|
113
114
|
);
|
|
114
115
|
});
|
|
115
116
|
});
|
|
@@ -58,6 +58,9 @@ export const KNOWN_BACKENDS = [
|
|
|
58
58
|
credential: { env: 'CODEX_HOME', default: '~/.codex', file: 'auth.json' },
|
|
59
59
|
setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/codex-cli-bridge/setup/README.md',
|
|
60
60
|
setupPathLocal: 'setup/README.md',
|
|
61
|
+
// The short canonical guided commands. Binary-install is platform-variant and longer, so it is
|
|
62
|
+
// REFERENCED via setupRef (§1 of that README), never duplicated here (would drift with the README).
|
|
63
|
+
guide: { setupRef: 'codex-cli-bridge/setup/README.md', loginCmd: 'codex login', verifyCmd: 'codex login status' },
|
|
61
64
|
},
|
|
62
65
|
{
|
|
63
66
|
name: 'antigravity-cli-bridge',
|
|
@@ -66,6 +69,7 @@ export const KNOWN_BACKENDS = [
|
|
|
66
69
|
credential: { env: null, default: '~/.gemini/antigravity-cli', file: 'antigravity-oauth-token' },
|
|
67
70
|
setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/antigravity-cli-bridge/setup/README.md',
|
|
68
71
|
setupPathLocal: 'setup/README.md',
|
|
72
|
+
guide: { setupRef: 'antigravity-cli-bridge/setup/README.md', loginCmd: 'agy', verifyCmd: 'echo "say OK" | agy-run -' },
|
|
69
73
|
},
|
|
70
74
|
];
|
|
71
75
|
|
|
@@ -257,6 +261,38 @@ export const detectBackend = (entry, deps = {}) => {
|
|
|
257
261
|
|
|
258
262
|
export const detectBackends = (deps = {}) => KNOWN_BACKENDS.map((entry) => detectBackend(entry, deps));
|
|
259
263
|
|
|
264
|
+
// ── guidance (axis-aware, for the `setup` flow) ───────────────────────────────
|
|
265
|
+
|
|
266
|
+
const registryEntry = (name) => KNOWN_BACKENDS.find((b) => b.name === name);
|
|
267
|
+
|
|
268
|
+
// The skill axis can't be auto-fixed in every state: an absent dir IS placeable from the bundled
|
|
269
|
+
// kit; any other non-ok state (stub/foreign/invalid/unsupported, or an `unknown` marker fs error)
|
|
270
|
+
// is a STOP — never overwrite a dir we don't provably own.
|
|
271
|
+
const skillHint = (status, guide) =>
|
|
272
|
+
status.manifestState === NOT_INSTALLED
|
|
273
|
+
? `place the bundled bridge skill — run \`/agent-workflow-kit setup ${status.name}\``
|
|
274
|
+
: `bridge skill dir is "${status.manifestState}" — STOP and inspect ${status.skillDir ?? 'the skill dir'} (see ${guide?.setupRef ?? status.setupHint?.url})`;
|
|
275
|
+
|
|
276
|
+
// guideFor inspects the manifest/cli/credentials axes INDEPENDENTLY (never the collapsed readiness)
|
|
277
|
+
// and returns an ORDERED list of the manual steps still owed — possibly several at once (e.g. a
|
|
278
|
+
// fresh machine needs both the CLI and a login). `[]` ⇒ nothing manual left (the linker handles the
|
|
279
|
+
// wrappers). Each step is `{ need: 'skill'|'cli'|'credentials', hint }`. Pure; no fs, no side effects.
|
|
280
|
+
export const guideFor = (status) => {
|
|
281
|
+
const guide = registryEntry(status.name)?.guide;
|
|
282
|
+
const out = [];
|
|
283
|
+
if (status.manifestState !== OK) out.push({ need: 'skill', hint: skillHint(status, guide) });
|
|
284
|
+
if (status.cli.state !== PRESENT) {
|
|
285
|
+
out.push({ need: 'cli', hint: `install the "${status.cli.bin}" CLI — see ${guide?.setupRef ?? status.setupHint?.url} §1` });
|
|
286
|
+
}
|
|
287
|
+
if (status.credentials.state !== PRESENT) {
|
|
288
|
+
out.push({
|
|
289
|
+
need: 'credentials',
|
|
290
|
+
hint: `sign in once (subscription): ${guide?.loginCmd ?? 'see the setup README'} (verify: ${guide?.verifyCmd ?? 'see the setup README'})`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
};
|
|
295
|
+
|
|
260
296
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
261
297
|
|
|
262
298
|
const MARK = { [PRESENT]: '✓', [MISSING]: '✗', [UNKNOWN]: '?' };
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
detectBackend,
|
|
13
13
|
detectBackends,
|
|
14
14
|
formatReport,
|
|
15
|
+
guideFor,
|
|
15
16
|
KNOWN_BACKENDS,
|
|
16
17
|
} from './detect-backends.mjs';
|
|
17
18
|
|
|
@@ -340,3 +341,104 @@ describe('detectBackends — live shape on this machine', () => {
|
|
|
340
341
|
}
|
|
341
342
|
});
|
|
342
343
|
});
|
|
344
|
+
|
|
345
|
+
// ── guideFor (axis-aware manual steps) ────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
// A status with all axes satisfied; override per-case to drive each axis independently.
|
|
348
|
+
const okStatus = (over = {}) => ({
|
|
349
|
+
name: 'codex-cli-bridge',
|
|
350
|
+
manifestState: 'ok',
|
|
351
|
+
manifestReason: 'ok',
|
|
352
|
+
skillDir: '/skills/codex-cli-bridge',
|
|
353
|
+
cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
|
|
354
|
+
credentials: { state: 'present', path: '/home/u/.codex/auth.json' },
|
|
355
|
+
wrappers: [{ name: 'codex-exec', state: 'present' }, { name: 'codex-review', state: 'present' }],
|
|
356
|
+
readiness: 'ready',
|
|
357
|
+
setupHint: { local: 'setup/README.md', url: 'https://example.test/codex' },
|
|
358
|
+
...over,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('guideFor — axis-aware manual steps', () => {
|
|
362
|
+
it('returns [] when the backend is ready', () => {
|
|
363
|
+
assert.deepEqual(guideFor(okStatus()), []);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('returns [] for a degraded backend (wrappers are the linker\'s job, not a manual step)', () => {
|
|
367
|
+
const s = okStatus({ readiness: 'degraded', wrappers: [{ name: 'codex-exec', state: 'missing' }] });
|
|
368
|
+
assert.deepEqual(guideFor(s), []);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('returns BOTH cli and credentials steps when both are missing (multiple simultaneous)', () => {
|
|
372
|
+
const s = okStatus({
|
|
373
|
+
cli: { bin: 'codex', state: 'missing', path: null },
|
|
374
|
+
credentials: { state: 'missing', path: '/c/auth.json' },
|
|
375
|
+
});
|
|
376
|
+
const needs = guideFor(s).map((g) => g.need);
|
|
377
|
+
assert.deepEqual(needs, ['cli', 'credentials']);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('credentials step carries the canonical loginCmd + verifyCmd from the registry', () => {
|
|
381
|
+
const s = okStatus({ credentials: { state: 'missing', path: '/c/auth.json' } });
|
|
382
|
+
const step = guideFor(s).find((g) => g.need === 'credentials');
|
|
383
|
+
assert.match(step.hint, /codex login/);
|
|
384
|
+
assert.match(step.hint, /codex login status/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('cli step references the setup README (no duplicated install channels)', () => {
|
|
388
|
+
const s = okStatus({ cli: { bin: 'codex', state: 'missing', path: null } });
|
|
389
|
+
const step = guideFor(s).find((g) => g.need === 'cli');
|
|
390
|
+
assert.match(step.hint, /codex-cli-bridge\/setup\/README\.md/);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('manifestState not-installed → a placeable bundled-skill hint (+ any cli/creds owed)', () => {
|
|
394
|
+
const s = okStatus({ manifestState: 'not-installed', skillDir: null });
|
|
395
|
+
const skill = guideFor(s).find((g) => g.need === 'skill');
|
|
396
|
+
assert.ok(skill, 'expected a skill step');
|
|
397
|
+
assert.match(skill.hint, /bundled bridge skill/);
|
|
398
|
+
assert.match(skill.hint, /setup codex-cli-bridge/);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('manifestState foreign/stub → a STOP hint (never auto-overwritten)', () => {
|
|
402
|
+
for (const state of ['foreign', 'stub', 'invalid-manifest', 'unsupported-schema', 'unknown']) {
|
|
403
|
+
const skill = guideFor(okStatus({ manifestState: state })).find((g) => g.need === 'skill');
|
|
404
|
+
assert.ok(skill, `expected a skill step for ${state}`);
|
|
405
|
+
assert.match(skill.hint, /STOP/);
|
|
406
|
+
assert.match(skill.hint, new RegExp(state.replace(/[-]/g, '\\$&')));
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('every step is {need, hint} with a non-empty hint', () => {
|
|
411
|
+
const s = okStatus({
|
|
412
|
+
manifestState: 'not-installed',
|
|
413
|
+
skillDir: null,
|
|
414
|
+
cli: { bin: 'codex', state: 'missing', path: null },
|
|
415
|
+
credentials: { state: 'missing', path: '/c/auth.json' },
|
|
416
|
+
});
|
|
417
|
+
const steps = guideFor(s);
|
|
418
|
+
assert.equal(steps.length, 3);
|
|
419
|
+
for (const step of steps) {
|
|
420
|
+
assert.ok(typeof step.need === 'string' && step.need.length > 0);
|
|
421
|
+
assert.ok(typeof step.hint === 'string' && step.hint.length > 0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('KNOWN_BACKENDS — each entry exposes a guide', () => {
|
|
427
|
+
it('guide carries setupRef + loginCmd + verifyCmd (non-empty strings)', () => {
|
|
428
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
429
|
+
assert.ok(entry.guide, `${entry.name} missing guide`);
|
|
430
|
+
for (const key of ['setupRef', 'loginCmd', 'verifyCmd']) {
|
|
431
|
+
assert.ok(
|
|
432
|
+
typeof entry.guide[key] === 'string' && entry.guide[key].length > 0,
|
|
433
|
+
`${entry.name}.guide.${key} must be a non-empty string`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('guide.setupRef points at a real file in the repo', () => {
|
|
440
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
441
|
+
assert.ok(existsSync(join(REPO, entry.guide.setupRef)), `${entry.guide.setupRef} (${entry.name})`);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// fs-safe.mjs — pure, dependency-injectable filesystem-safety primitives shared by the kit installer
|
|
2
|
+
// (bin/install.mjs) and the backend linker (tools/setup-backends.mjs). Importing this module has NO
|
|
3
|
+
// side effects: it runs nothing. Every fs primitive is injectable via `deps.*` so the guards are
|
|
4
|
+
// unit-testable without touching the real filesystem; the defaults are Node's SYNC fs (matching the
|
|
5
|
+
// tools/ detector style). Dependency-free, Node >= 18.
|
|
6
|
+
//
|
|
7
|
+
// Three primitives:
|
|
8
|
+
// assertContainedRealPath — refuse to write through/into a symlink, or to a dest outside a root.
|
|
9
|
+
// copyTreeRefresh — recursive copy that OVERWRITES regular files (refresh), SKIPS a symlink
|
|
10
|
+
// whose dest already exists (additive), and guards every dest component.
|
|
11
|
+
// linkManaged — create/keep ONLY a symlink we own; STOP (typed ManagedLinkConflict) on
|
|
12
|
+
// a foreign symlink or a non-symlink dest; refuse a symlinked source.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
lstatSync, existsSync, mkdirSync, readdirSync, copyFileSync, readlinkSync, symlinkSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// A managed-link conflict is a distinct, expected outcome (a foreign/non-symlink dest we refuse to
|
|
20
|
+
// clobber) — callers branch on `.code`. Modelled as a tagged Error (no classes — §agent_rules 2.3),
|
|
21
|
+
// the same `Object.assign(new Error(), { code })` idiom the codebase already uses for typed errors.
|
|
22
|
+
export const MANAGED_LINK_CONFLICT = 'MANAGED_LINK_CONFLICT';
|
|
23
|
+
const managedLinkConflict = (message, fields = {}) =>
|
|
24
|
+
Object.assign(new Error(`[agent-workflow-kit] ${message}`), {
|
|
25
|
+
name: 'ManagedLinkConflict',
|
|
26
|
+
code: MANAGED_LINK_CONFLICT,
|
|
27
|
+
...fields,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// lstat without following symlinks; null when absent. A non-ENOENT fs error (EACCES/EIO) must NOT
|
|
31
|
+
// fail open (be read as "not a symlink") — it propagates so the guard can never be bypassed.
|
|
32
|
+
const lstatNoFollow = (path, lstat) => {
|
|
33
|
+
try {
|
|
34
|
+
return lstat(path);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err && err.code === 'ENOENT') return null;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Symlink-traversal guard: refuse to write *through* any symlink at or above `dest` within `root`
|
|
42
|
+
// (root / intermediate dir / leaf, including a dangling one), or to a dest outside `root`.
|
|
43
|
+
export const assertContainedRealPath = (root, dest, deps = {}) => {
|
|
44
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
45
|
+
const ln = (p) => lstatNoFollow(p, lstat);
|
|
46
|
+
const rel = relative(root, dest);
|
|
47
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
48
|
+
throw new Error(`[agent-workflow-kit] refusing to write outside the target dir: ${dest}`);
|
|
49
|
+
}
|
|
50
|
+
if (ln(root)?.isSymbolicLink()) {
|
|
51
|
+
throw new Error(`[agent-workflow-kit] refusing to install into a symlinked target dir: ${root}`);
|
|
52
|
+
}
|
|
53
|
+
const walk = (acc, part) => {
|
|
54
|
+
const cur = join(acc, part);
|
|
55
|
+
if (ln(cur)?.isSymbolicLink()) {
|
|
56
|
+
throw new Error(`[agent-workflow-kit] refusing to write through a symlink at ${cur} (would escape ${root}).`);
|
|
57
|
+
}
|
|
58
|
+
return cur;
|
|
59
|
+
};
|
|
60
|
+
rel.split(sep).filter(Boolean).reduce(walk, root);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Recursive refresh copy. Guards every dest via assertContainedRealPath first, then:
|
|
64
|
+
// symlink src → additive: skip if dest exists, else mirror the link target.
|
|
65
|
+
// directory src → mkdir -p dest, recurse.
|
|
66
|
+
// regular file → mkdir -p parent, copyFile (OVERWRITE = refresh to the bundled version).
|
|
67
|
+
export const copyTreeRefresh = (src, dest, root, deps = {}) => {
|
|
68
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
69
|
+
const exists = deps.exists ?? existsSync;
|
|
70
|
+
const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
|
|
71
|
+
const readdir = deps.readdir ?? readdirSync;
|
|
72
|
+
const copyFile = deps.copyFile ?? copyFileSync;
|
|
73
|
+
const readlink = deps.readlink ?? readlinkSync;
|
|
74
|
+
const symlink = deps.symlink ?? symlinkSync;
|
|
75
|
+
|
|
76
|
+
assertContainedRealPath(root, dest, deps);
|
|
77
|
+
const stat = lstat(src);
|
|
78
|
+
if (stat.isSymbolicLink()) {
|
|
79
|
+
if (exists(dest)) return;
|
|
80
|
+
symlink(readlink(src), dest);
|
|
81
|
+
} else if (stat.isDirectory()) {
|
|
82
|
+
mkdir(dest);
|
|
83
|
+
for (const entry of readdir(src)) {
|
|
84
|
+
copyTreeRefresh(join(src, entry), join(dest, entry), root, deps);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
mkdir(dirname(dest));
|
|
88
|
+
copyFile(src, dest);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Create/keep ONLY a symlink we own. `src` must be a real regular file (never a symlink); `dest`
|
|
93
|
+
// must stay within `root`. Outcomes: 'linked' (created), 'noop' (already points at our src), or a
|
|
94
|
+
// thrown ManagedLinkConflict (a non-symlink, or a symlink pointing elsewhere — never clobbered).
|
|
95
|
+
export const linkManaged = (src, dest, root, deps = {}) => {
|
|
96
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
97
|
+
const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
|
|
98
|
+
const readlink = deps.readlink ?? readlinkSync;
|
|
99
|
+
const symlink = deps.symlink ?? symlinkSync;
|
|
100
|
+
|
|
101
|
+
const srcStat = lstat(src);
|
|
102
|
+
if (srcStat.isSymbolicLink()) {
|
|
103
|
+
throw new Error(`[agent-workflow-kit] refusing to link a symlinked source (would escape our ownership): ${src}`);
|
|
104
|
+
}
|
|
105
|
+
if (!srcStat.isFile()) {
|
|
106
|
+
throw new Error(`[agent-workflow-kit] link source is not a regular file: ${src}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Guard the PARENT chain (root + intermediate dirs), not the leaf: managing the leaf symlink is
|
|
110
|
+
// exactly this function's job, so it inspects the leaf itself rather than letting the traversal
|
|
111
|
+
// guard reject every symlinked dest. `dirname(dest)` within `root` ⇒ `dest` within `root` too.
|
|
112
|
+
assertContainedRealPath(root, dirname(dest), deps);
|
|
113
|
+
const existing = lstatNoFollow(dest, lstat);
|
|
114
|
+
if (existing === null) {
|
|
115
|
+
mkdir(dirname(dest));
|
|
116
|
+
symlink(src, dest);
|
|
117
|
+
return 'linked';
|
|
118
|
+
}
|
|
119
|
+
if (!existing.isSymbolicLink()) {
|
|
120
|
+
throw managedLinkConflict(`refusing to replace a non-symlink at ${dest}`, { dest, found: 'file' });
|
|
121
|
+
}
|
|
122
|
+
const target = readlink(dest);
|
|
123
|
+
const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(dest), target);
|
|
124
|
+
if (resolvedTarget === resolve(src)) return 'noop';
|
|
125
|
+
throw managedLinkConflict(
|
|
126
|
+
`refusing to replace a foreign symlink at ${dest} (points at ${target}, not our ${src})`,
|
|
127
|
+
{ dest, expected: src, found: target },
|
|
128
|
+
);
|
|
129
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, readFileSync, existsSync, lstatSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
assertContainedRealPath,
|
|
10
|
+
copyTreeRefresh,
|
|
11
|
+
linkManaged,
|
|
12
|
+
MANAGED_LINK_CONFLICT,
|
|
13
|
+
} from './fs-safe.mjs';
|
|
14
|
+
|
|
15
|
+
// All three primitives are SYNC and operate on real tmp dirs here (the symlink behaviours are
|
|
16
|
+
// fiddly enough that real fs is the honest test). The out-of-root case needs no fs at all.
|
|
17
|
+
let dir;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), 'awf-fs-safe-'));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ── assertContainedRealPath ───────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('assertContainedRealPath', () => {
|
|
28
|
+
it('rejects a dest outside the root (no fs needed — the relative check fires first)', () => {
|
|
29
|
+
assert.throws(() => assertContainedRealPath('/root', '/root/../etc/passwd'), /outside/);
|
|
30
|
+
assert.throws(() => assertContainedRealPath('/root', '/etc/passwd'), /outside/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects writing INTO a symlinked root', () => {
|
|
34
|
+
const real = join(dir, 'real');
|
|
35
|
+
const root = join(dir, 'root');
|
|
36
|
+
mkdirSync(real);
|
|
37
|
+
symlinkSync(real, root);
|
|
38
|
+
assert.throws(() => assertContainedRealPath(root, join(root, 'x')), /symlink/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects writing THROUGH a symlinked intermediate component', () => {
|
|
42
|
+
const root = join(dir, 'root');
|
|
43
|
+
const elsewhere = join(dir, 'elsewhere');
|
|
44
|
+
mkdirSync(root);
|
|
45
|
+
mkdirSync(elsewhere);
|
|
46
|
+
symlinkSync(elsewhere, join(root, 'sub'));
|
|
47
|
+
assert.throws(() => assertContainedRealPath(root, join(root, 'sub', 'file')), /symlink/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects a symlinked leaf dest', () => {
|
|
51
|
+
const root = join(dir, 'root');
|
|
52
|
+
mkdirSync(root);
|
|
53
|
+
symlinkSync(join(dir, 'target'), join(root, 'leaf'));
|
|
54
|
+
assert.throws(() => assertContainedRealPath(root, join(root, 'leaf')), /symlink/i);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts a clean dest within the root', () => {
|
|
58
|
+
const root = join(dir, 'root');
|
|
59
|
+
mkdirSync(root);
|
|
60
|
+
assert.doesNotThrow(() => assertContainedRealPath(root, join(root, 'a', 'b', 'c')));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('lstat is injectable (closes the install.mjs injectability gap)', () => {
|
|
64
|
+
let seen = 0;
|
|
65
|
+
const lstat = (p) => { seen += 1; return { isSymbolicLink: () => false }; };
|
|
66
|
+
assert.doesNotThrow(() => assertContainedRealPath('/root', '/root/a/b', { lstat }));
|
|
67
|
+
assert.ok(seen > 0, 'the injected lstat was used');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ── copyTreeRefresh ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('copyTreeRefresh', () => {
|
|
74
|
+
it('overwrites an existing regular file (refresh)', () => {
|
|
75
|
+
const root = join(dir, 'dest');
|
|
76
|
+
mkdirSync(root);
|
|
77
|
+
const src = join(dir, 'src.txt');
|
|
78
|
+
const dest = join(root, 'f.txt');
|
|
79
|
+
writeFileSync(src, 'new');
|
|
80
|
+
writeFileSync(dest, 'old');
|
|
81
|
+
copyTreeRefresh(src, dest, root);
|
|
82
|
+
assert.equal(readFileSync(dest, 'utf8'), 'new');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('copies a nested directory tree', () => {
|
|
86
|
+
const src = join(dir, 'src');
|
|
87
|
+
const root = join(dir, 'dest');
|
|
88
|
+
mkdirSync(join(src, 'a'), { recursive: true });
|
|
89
|
+
writeFileSync(join(src, 'top.txt'), 'T');
|
|
90
|
+
writeFileSync(join(src, 'a', 'deep.txt'), 'D');
|
|
91
|
+
mkdirSync(root);
|
|
92
|
+
copyTreeRefresh(src, join(root, 'src'), root);
|
|
93
|
+
assert.equal(readFileSync(join(root, 'src', 'top.txt'), 'utf8'), 'T');
|
|
94
|
+
assert.equal(readFileSync(join(root, 'src', 'a', 'deep.txt'), 'utf8'), 'D');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('skips a symlink whose dest already exists (additive — never replace)', () => {
|
|
98
|
+
const root = join(dir, 'dest');
|
|
99
|
+
mkdirSync(root);
|
|
100
|
+
const linkSrc = join(dir, 'link');
|
|
101
|
+
symlinkSync(join(dir, 'whatever'), linkSrc); // src IS a symlink
|
|
102
|
+
const dest = join(root, 'f');
|
|
103
|
+
writeFileSync(dest, 'keep');
|
|
104
|
+
copyTreeRefresh(linkSrc, dest, root);
|
|
105
|
+
assert.equal(readFileSync(dest, 'utf8'), 'keep'); // untouched
|
|
106
|
+
assert.equal(lstatSync(dest).isSymbolicLink(), false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('STOPs on a symlinked dest component (never writes through it)', () => {
|
|
110
|
+
const root = join(dir, 'root');
|
|
111
|
+
const elsewhere = join(dir, 'elsewhere');
|
|
112
|
+
mkdirSync(root);
|
|
113
|
+
mkdirSync(elsewhere);
|
|
114
|
+
symlinkSync(elsewhere, join(root, 'sub'));
|
|
115
|
+
const src = join(dir, 's.txt');
|
|
116
|
+
writeFileSync(src, 'x');
|
|
117
|
+
assert.throws(() => copyTreeRefresh(src, join(root, 'sub', 'f.txt'), root), /symlink/i);
|
|
118
|
+
assert.equal(existsSync(join(elsewhere, 'f.txt')), false); // no leak
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── linkManaged ───────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('linkManaged', () => {
|
|
125
|
+
const makeSrc = () => {
|
|
126
|
+
const src = join(dir, 'src.sh');
|
|
127
|
+
writeFileSync(src, '#!/bin/sh\n');
|
|
128
|
+
return src;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('creates a symlink when the dest is absent', () => {
|
|
132
|
+
const src = makeSrc();
|
|
133
|
+
const root = join(dir, 'bin');
|
|
134
|
+
mkdirSync(root);
|
|
135
|
+
const dest = join(root, 'cmd');
|
|
136
|
+
const result = linkManaged(src, dest, root);
|
|
137
|
+
assert.equal(result, 'linked');
|
|
138
|
+
assert.equal(lstatSync(dest).isSymbolicLink(), true);
|
|
139
|
+
assert.equal(readlinkSync(dest), src);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('creates the parent bindir if absent (mkdir -p)', () => {
|
|
143
|
+
const src = makeSrc();
|
|
144
|
+
const root = join(dir, 'base');
|
|
145
|
+
mkdirSync(root);
|
|
146
|
+
const dest = join(root, 'newbin', 'cmd');
|
|
147
|
+
linkManaged(src, dest, root);
|
|
148
|
+
assert.equal(readlinkSync(dest), src);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('is idempotent — a second call is a no-op', () => {
|
|
152
|
+
const src = makeSrc();
|
|
153
|
+
const root = join(dir, 'bin');
|
|
154
|
+
mkdirSync(root);
|
|
155
|
+
const dest = join(root, 'cmd');
|
|
156
|
+
linkManaged(src, dest, root);
|
|
157
|
+
const again = linkManaged(src, dest, root);
|
|
158
|
+
assert.equal(again, 'noop');
|
|
159
|
+
assert.equal(readlinkSync(dest), src);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('STOPs on a non-symlink dest (typed ManagedLinkConflict)', () => {
|
|
163
|
+
const src = makeSrc();
|
|
164
|
+
const root = join(dir, 'bin');
|
|
165
|
+
mkdirSync(root);
|
|
166
|
+
const dest = join(root, 'cmd');
|
|
167
|
+
writeFileSync(dest, 'someone-elses-file');
|
|
168
|
+
assert.throws(() => linkManaged(src, dest, root), (err) => err.code === MANAGED_LINK_CONFLICT);
|
|
169
|
+
assert.equal(readFileSync(dest, 'utf8'), 'someone-elses-file'); // untouched
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('STOPs on a foreign symlink (points elsewhere)', () => {
|
|
173
|
+
const src = makeSrc();
|
|
174
|
+
const root = join(dir, 'bin');
|
|
175
|
+
mkdirSync(root);
|
|
176
|
+
const dest = join(root, 'cmd');
|
|
177
|
+
const foreign = join(dir, 'foreign.sh');
|
|
178
|
+
writeFileSync(foreign, '#!/bin/sh\n');
|
|
179
|
+
symlinkSync(foreign, dest);
|
|
180
|
+
assert.throws(() => linkManaged(src, dest, root), (err) => err.code === MANAGED_LINK_CONFLICT);
|
|
181
|
+
assert.equal(readlinkSync(dest), foreign); // untouched
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('refuses a symlinked source (never links through a symlink)', () => {
|
|
185
|
+
const realSrc = makeSrc();
|
|
186
|
+
const linkSrc = join(dir, 'link.sh');
|
|
187
|
+
symlinkSync(realSrc, linkSrc);
|
|
188
|
+
const root = join(dir, 'bin');
|
|
189
|
+
mkdirSync(root);
|
|
190
|
+
assert.throws(() => linkManaged(linkSrc, join(root, 'cmd'), root), /symlink/i);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('refuses a non-regular-file source (e.g. a directory)', () => {
|
|
194
|
+
const srcDir = join(dir, 'src-dir');
|
|
195
|
+
mkdirSync(srcDir);
|
|
196
|
+
const root = join(dir, 'bin');
|
|
197
|
+
mkdirSync(root);
|
|
198
|
+
assert.throws(() => linkManaged(srcDir, join(root, 'cmd'), root), /regular file/i);
|
|
199
|
+
});
|
|
200
|
+
});
|