@sabaiway/agent-workflow-kit 1.6.0 → 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.
@@ -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
+ });