create-harness-vibe-coding 0.8.17 → 0.8.19

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.
Files changed (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,268 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+
7
+ import {
8
+ createPeerCapsule,
9
+ writePeerEvent,
10
+ writePeerResult,
11
+ blockPeer,
12
+ getPeerCapsule,
13
+ } from '../peer-capsule.mjs';
14
+
15
+ /**
16
+ * Create a temporary project root with a Harness/tasks structure for testing.
17
+ */
18
+ function makeTempProject() {
19
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'peer-capsule-test-'));
20
+ // Create the expected structure
21
+ fs.mkdirSync(path.join(tmpDir, 'Harness', 'tasks'), { recursive: true });
22
+ return tmpDir;
23
+ }
24
+
25
+ test('createPeerCapsule writes REQUEST.json with correct taskId/peerId/runtime', () => {
26
+ const projectRoot = makeTempProject();
27
+ try {
28
+ const request = {
29
+ runtime: 'claude',
30
+ taskId: 'task-test-123',
31
+ peerId: 'worker-001',
32
+ command: 'say hello',
33
+ };
34
+
35
+ const result = createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', request);
36
+
37
+ // Check returned structure — request gets createdAt added by createPeerCapsule
38
+ assert.ok(result.capsuleDir);
39
+ assert.ok(result.capsuleDir.endsWith(path.join('task-test-123', 'peers', 'worker-001')));
40
+ assert.equal(result.request.taskId, request.taskId);
41
+ assert.equal(result.request.peerId, request.peerId);
42
+ assert.equal(result.request.runtime, request.runtime);
43
+ assert.equal(result.request.command, request.command);
44
+ assert.ok(result.request.createdAt);
45
+
46
+ // Check file on disk
47
+ const requestPath = path.join(result.capsuleDir, 'REQUEST.json');
48
+ assert.ok(fs.existsSync(requestPath));
49
+ const parsed = JSON.parse(fs.readFileSync(requestPath, 'utf-8'));
50
+ assert.equal(parsed.taskId, 'task-test-123');
51
+ assert.equal(parsed.peerId, 'worker-001');
52
+ assert.equal(parsed.runtime, 'claude');
53
+ assert.equal(parsed.command, 'say hello');
54
+ // createdAt is added by createPeerCapsule
55
+ assert.ok(parsed.createdAt);
56
+ } finally {
57
+ fs.rmSync(projectRoot, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test('createPeerCapsule writes STATE.json with status starting', () => {
62
+ const projectRoot = makeTempProject();
63
+ try {
64
+ const result = createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
65
+ runtime: 'claude',
66
+ taskId: 'task-test-123',
67
+ peerId: 'worker-001',
68
+ });
69
+
70
+ const statePath = path.join(result.capsuleDir, 'STATE.json');
71
+ assert.ok(fs.existsSync(statePath));
72
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
73
+ assert.equal(state.status, 'starting');
74
+ } finally {
75
+ fs.rmSync(projectRoot, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('writePeerEvent appends line to events.jsonl', () => {
80
+ const projectRoot = makeTempProject();
81
+ try {
82
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
83
+ runtime: 'claude',
84
+ taskId: 'task-test-123',
85
+ peerId: 'worker-001',
86
+ });
87
+
88
+ writePeerEvent(projectRoot, 'task-test-123', 'worker-001', { type: 'data', text: 'hello' });
89
+ writePeerEvent(projectRoot, 'task-test-123', 'worker-001', { type: 'data', text: 'world' });
90
+
91
+ const eventsPath = path.join(projectRoot, 'Harness', 'tasks', 'task-test-123', 'peers', 'worker-001', 'events.jsonl');
92
+ assert.ok(fs.existsSync(eventsPath));
93
+ const lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n');
94
+ assert.equal(lines.length, 2);
95
+ assert.equal(JSON.parse(lines[0]).text, 'hello');
96
+ assert.equal(JSON.parse(lines[1]).text, 'world');
97
+ } finally {
98
+ fs.rmSync(projectRoot, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('writePeerResult writes RESULT.json', () => {
103
+ const projectRoot = makeTempProject();
104
+ try {
105
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
106
+ runtime: 'claude',
107
+ taskId: 'task-test-123',
108
+ peerId: 'worker-001',
109
+ });
110
+
111
+ const result = { status: 'completed', output: 'done' };
112
+ writePeerResult(projectRoot, 'task-test-123', 'worker-001', result);
113
+
114
+ const resultPath = path.join(projectRoot, 'Harness', 'tasks', 'task-test-123', 'peers', 'worker-001', 'RESULT.json');
115
+ assert.ok(fs.existsSync(resultPath));
116
+ const parsed = JSON.parse(fs.readFileSync(resultPath, 'utf-8'));
117
+ assert.equal(parsed.status, result.status);
118
+ assert.equal(parsed.output, result.output);
119
+ assert.ok(parsed.completedAt);
120
+ } finally {
121
+ fs.rmSync(projectRoot, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test('blockPeer updates STATE.status to blocked', () => {
126
+ const projectRoot = makeTempProject();
127
+ try {
128
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
129
+ runtime: 'claude',
130
+ taskId: 'task-test-123',
131
+ peerId: 'worker-001',
132
+ });
133
+
134
+ blockPeer(projectRoot, 'task-test-123', 'worker-001', 'node-pty not installed');
135
+
136
+ const statePath = path.join(projectRoot, 'Harness', 'tasks', 'task-test-123', 'peers', 'worker-001', 'STATE.json');
137
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
138
+ assert.equal(state.status, 'blocked');
139
+ assert.equal(state.blockedReason, 'node-pty not installed');
140
+ } finally {
141
+ fs.rmSync(projectRoot, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test('blockPeer writes PROGRESS.md with reason', () => {
146
+ const projectRoot = makeTempProject();
147
+ try {
148
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
149
+ runtime: 'claude',
150
+ taskId: 'task-test-123',
151
+ peerId: 'worker-001',
152
+ });
153
+
154
+ blockPeer(projectRoot, 'task-test-123', 'worker-001', 'node-pty not installed');
155
+
156
+ const progressPath = path.join(projectRoot, 'Harness', 'tasks', 'task-test-123', 'peers', 'worker-001', 'PROGRESS.md');
157
+ assert.ok(fs.existsSync(progressPath));
158
+ const content = fs.readFileSync(progressPath, 'utf-8');
159
+ assert.ok(content.includes('node-pty not installed'));
160
+ assert.ok(content.includes('BLOCKED'));
161
+ } finally {
162
+ fs.rmSync(projectRoot, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ test('getPeerCapsule reads all capsule files', () => {
167
+ const projectRoot = makeTempProject();
168
+ try {
169
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
170
+ runtime: 'claude',
171
+ taskId: 'task-test-123',
172
+ peerId: 'worker-001',
173
+ });
174
+
175
+ writePeerEvent(projectRoot, 'task-test-123', 'worker-001', { type: 'data', text: 'hello' });
176
+ writePeerResult(projectRoot, 'task-test-123', 'worker-001', { status: 'completed', output: 'done' });
177
+
178
+ const capsule = getPeerCapsule(projectRoot, 'task-test-123', 'worker-001');
179
+
180
+ assert.ok(capsule.request);
181
+ assert.equal(capsule.request.taskId, 'task-test-123');
182
+ assert.equal(capsule.request.peerId, 'worker-001');
183
+
184
+ assert.ok(capsule.state);
185
+ assert.equal(capsule.state.status, 'starting');
186
+
187
+ assert.ok(Array.isArray(capsule.events));
188
+ assert.equal(capsule.events.length, 1);
189
+ assert.equal(capsule.events[0].text, 'hello');
190
+
191
+ assert.ok(capsule.result);
192
+ assert.equal(capsule.result.status, 'completed');
193
+
194
+ assert.equal(capsule.progress, null); // no PROGRESS.md written
195
+ } finally {
196
+ fs.rmSync(projectRoot, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ test('getPeerCapsule returns progress content when PROGRESS.md exists', () => {
201
+ const projectRoot = makeTempProject();
202
+ try {
203
+ createPeerCapsule(projectRoot, 'task-test-123', 'worker-001', {
204
+ runtime: 'claude',
205
+ taskId: 'task-test-123',
206
+ peerId: 'worker-001',
207
+ });
208
+
209
+ blockPeer(projectRoot, 'task-test-123', 'worker-001', 'block reason');
210
+ const capsule = getPeerCapsule(projectRoot, 'task-test-123', 'worker-001');
211
+
212
+ assert.ok(capsule.progress);
213
+ assert.ok(capsule.progress.includes('BLOCKED'));
214
+ } finally {
215
+ fs.rmSync(projectRoot, { recursive: true, force: true });
216
+ }
217
+ });
218
+
219
+ test('getPeerCapsule for non-existent capsule returns null', () => {
220
+ const projectRoot = makeTempProject();
221
+ try {
222
+ const capsule = getPeerCapsule(projectRoot, 'task-nonexistent', 'no-one');
223
+ assert.equal(capsule, null);
224
+ } finally {
225
+ fs.rmSync(projectRoot, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('path traversal in taskId is rejected', () => {
230
+ const projectRoot = makeTempProject();
231
+ try {
232
+ assert.throws(() => createPeerCapsule(projectRoot, '../evil', 'worker-001', {}), {
233
+ name: 'Error',
234
+ });
235
+ assert.throws(() => writePeerEvent(projectRoot, '../evil', 'worker-001', {}), {
236
+ name: 'Error',
237
+ });
238
+ assert.throws(() => getPeerCapsule(projectRoot, '../evil', 'worker-001'), {
239
+ name: 'Error',
240
+ });
241
+ } finally {
242
+ fs.rmSync(projectRoot, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test('path traversal in peerId is rejected', () => {
247
+ const projectRoot = makeTempProject();
248
+ try {
249
+ assert.throws(() => createPeerCapsule(projectRoot, 'task-valid', '../evil', {}), {
250
+ name: 'Error',
251
+ });
252
+ assert.throws(() => writePeerEvent(projectRoot, 'task-valid', '../evil', {}), {
253
+ name: 'Error',
254
+ });
255
+ assert.throws(() => getPeerCapsule(projectRoot, 'task-valid', '../evil'), {
256
+ name: 'Error',
257
+ });
258
+ } finally {
259
+ fs.rmSync(projectRoot, { recursive: true, force: true });
260
+ }
261
+ });
262
+
263
+ test('validatePeerId rejects path traversal', async () => {
264
+ const { validatePeerId } = await import('../peer-capsule.mjs');
265
+ assert.equal(validatePeerId('../evil'), false);
266
+ assert.equal(validatePeerId('valid-peer-001'), true);
267
+ assert.equal(validatePeerId('peer/with/slash'), false);
268
+ });
@@ -0,0 +1,70 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import { resolvePtyCommand, spawnPty } from '../pty-adapter.mjs';
5
+
6
+ test('spawnPty validates runtime before any PTY operation', async () => {
7
+ await assert.rejects(
8
+ () => spawnPty({
9
+ runtime: 'unknown-runtime',
10
+ taskId: 'task-test',
11
+ peerId: 'unknown-001',
12
+ projectRoot: process.cwd(),
13
+ cols: 120,
14
+ rows: 32,
15
+ onData: () => {},
16
+ onExit: () => {},
17
+ }),
18
+ { name: 'Error' }
19
+ );
20
+ });
21
+
22
+ test('spawnPty with runtime fake-agent throws', async () => {
23
+ await assert.rejects(
24
+ () => spawnPty({
25
+ runtime: 'fake-agent',
26
+ taskId: 'task-test',
27
+ peerId: 'fake-001',
28
+ projectRoot: process.cwd(),
29
+ cols: 120,
30
+ rows: 32,
31
+ onData: () => {},
32
+ onExit: () => {},
33
+ }),
34
+ { name: 'Error', message: /Invalid runtime.*fake-agent/ }
35
+ );
36
+ });
37
+
38
+ test('spawnPty reports blocked when command path cannot be spawned', async () => {
39
+ const missingCommand = path.join(process.cwd(), 'missing-runtime-command.exe');
40
+ const result = await spawnPty({
41
+ runtime: 'claude',
42
+ taskId: 'task-test',
43
+ peerId: 'claude-001',
44
+ projectRoot: process.cwd(),
45
+ command: missingCommand,
46
+ cols: 120,
47
+ rows: 32,
48
+ onData: () => {},
49
+ onExit: () => {},
50
+ });
51
+
52
+ assert.equal(result.blocked, true);
53
+ assert.ok(result.reason === 'runtime-spawn-failed' || result.reason === 'pty-adapter-missing');
54
+ assert.ok(result.hint);
55
+ });
56
+
57
+ test('resolvePtyCommand uses split cmd call for Windows command wrappers', () => {
58
+ const commandPath = 'C:\\Program Files\\Harness Agents\\agent.cmd';
59
+ const args = ['--model', 'two words'];
60
+ const resolved = resolvePtyCommand(commandPath, args);
61
+
62
+ if (process.platform !== 'win32') {
63
+ assert.deepEqual(resolved, { executable: commandPath, args });
64
+ return;
65
+ }
66
+
67
+ assert.equal(resolved.executable, 'cmd.exe');
68
+ assert.deepEqual(resolved.args, ['/d', '/c', 'call', commandPath, ...args]);
69
+ assert.doesNotMatch(resolved.args.join('\n'), /\\"/);
70
+ });
@@ -0,0 +1,43 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { readRuntimeConfig, writeRuntimeConfig } from '../runtime-config.mjs';
7
+
8
+ test('writeRuntimeConfig writes project JSON model fields', () => {
9
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-config-'));
10
+ try {
11
+ const result = writeRuntimeConfig(projectRoot, 'opencode', {
12
+ scope: 'project',
13
+ values: { model: 'anthropic/claude-sonnet-4' },
14
+ });
15
+ assert.equal(result.path, 'opencode.json');
16
+ const configPath = path.join(projectRoot, 'opencode.json');
17
+ assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf8')).model, 'anthropic/claude-sonnet-4');
18
+ const readback = readRuntimeConfig(projectRoot, 'opencode');
19
+ assert.equal(readback.files.find(file => file.scope === 'project').values.model, 'anthropic/claude-sonnet-4');
20
+ } finally {
21
+ fs.rmSync(projectRoot, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ test('writeRuntimeConfig writes project TOML fields and backs up existing file', () => {
26
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-config-'));
27
+ try {
28
+ const configPath = path.join(projectRoot, '.codex', 'config.toml');
29
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
30
+ fs.writeFileSync(configPath, 'model = "old-model"\n', 'utf8');
31
+ const result = writeRuntimeConfig(projectRoot, 'codex', {
32
+ scope: 'project',
33
+ values: { model: 'gpt-5', provider: 'openai' },
34
+ });
35
+ assert.ok(result.backupPath);
36
+ assert.ok(fs.existsSync(result.backupPath));
37
+ const text = fs.readFileSync(configPath, 'utf8');
38
+ assert.match(text, /model = "gpt-5"/);
39
+ assert.match(text, /model_provider = "openai"/);
40
+ } finally {
41
+ fs.rmSync(projectRoot, { recursive: true, force: true });
42
+ }
43
+ });
@@ -0,0 +1,90 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ detectRuntimes,
5
+ resolveRuntimeCommand,
6
+ resolveRuntimeLaunchArgs,
7
+ resolveRuntimeResumeArgs,
8
+ isRuntimeLaunchable,
9
+ RUNTIME_DEFINITIONS,
10
+ } from '../runtime-detector.mjs';
11
+
12
+ function fakeRunner(command, args) {
13
+ if (command === 'where.exe' || command === 'which') {
14
+ const found = new Set(['claude', 'cc', 'codex', 'opencode', 'openclaw', 'pi', 'deepseek-tui']);
15
+ const name = args[0];
16
+ return found.has(name)
17
+ ? { status: 0, stdout: `/bin/${name}\n`, stderr: '' }
18
+ : { status: 1, stdout: '', stderr: '' };
19
+ }
20
+ return { status: 0, stdout: `${command} 1.2.3\n`, stderr: '' };
21
+ }
22
+
23
+ test('detectRuntimes returns detected CLI runtime entries by default', () => {
24
+ const runtimes = detectRuntimes({ runner: fakeRunner });
25
+ assert.deepEqual(runtimes.map(r => r.id), ['claude', 'cc', 'codex', 'opencode', 'deepseek', 'pi', 'openclaw']);
26
+ });
27
+
28
+ test('detectRuntimes includeMissing returns the full candidate registry', () => {
29
+ const runtimes = detectRuntimes({ runner: () => ({ status: 1, stdout: '', stderr: '' }), includeMissing: true });
30
+ assert.deepEqual(runtimes.map(r => r.id), RUNTIME_DEFINITIONS.map(r => r.id));
31
+ });
32
+
33
+ test('detected terminal agents are launchable when executable exists', () => {
34
+ const byId = new Map(detectRuntimes({ runner: fakeRunner }).map(r => [r.id, r]));
35
+ for (const id of ['claude', 'cc', 'codex', 'opencode', 'openclaw', 'pi', 'deepseek']) {
36
+ assert.equal(byId.get(id).status, 'available');
37
+ assert.equal(byId.get(id).launchable, true);
38
+ assert.ok(byId.get(id).path);
39
+ }
40
+ });
41
+
42
+ test('multi-command runtime keeps the detected command', () => {
43
+ const byId = new Map(detectRuntimes({ runner: fakeRunner }).map(r => [r.id, r]));
44
+ assert.equal(byId.get('deepseek').command, 'deepseek-tui');
45
+ });
46
+
47
+ test('detectRuntimes skips version commands unless requested', () => {
48
+ const versionCommands = [];
49
+ const runner = (command, args) => {
50
+ if (command === 'where.exe' || command === 'which') {
51
+ return args[0] === 'claude'
52
+ ? { status: 0, stdout: '/bin/claude\n', stderr: '' }
53
+ : { status: 1, stdout: '', stderr: '' };
54
+ }
55
+ versionCommands.push([command, ...args].join(' '));
56
+ return { status: 0, stdout: `${command} 1.2.3\n`, stderr: '' };
57
+ };
58
+
59
+ const withoutVersion = detectRuntimes({ runner });
60
+ assert.equal(withoutVersion.find(runtime => runtime.id === 'claude').version, null);
61
+ assert.deepEqual(versionCommands, []);
62
+
63
+ const withVersion = detectRuntimes({ runner, readVersion: true });
64
+ assert.equal(withVersion.find(runtime => runtime.id === 'claude').version, 'claude 1.2.3');
65
+ assert.deepEqual(versionCommands, ['claude --version']);
66
+ });
67
+
68
+ test('missing executable returns missing status', () => {
69
+ const missingRunner = () => ({ status: 1, stdout: '', stderr: '' });
70
+ const byId = new Map(detectRuntimes({ runner: missingRunner, includeMissing: true }).map(r => [r.id, r]));
71
+ assert.equal(byId.get('codex').status, 'missing');
72
+ assert.equal(byId.get('codex').blockedReason, 'executable-not-found');
73
+ });
74
+
75
+ test('runtime launch policy is separate from command detection', () => {
76
+ assert.equal(isRuntimeLaunchable('codex'), true);
77
+ assert.equal(isRuntimeLaunchable('openclaw'), true);
78
+ assert.equal(resolveRuntimeCommand('cc'), 'cc');
79
+ assert.deepEqual(resolveRuntimeLaunchArgs('codex', { model: 'gpt-5' }), ['--model', 'gpt-5']);
80
+ });
81
+
82
+ test('resume args match installed CLI resume command shapes', () => {
83
+ assert.deepEqual(resolveRuntimeResumeArgs('claude', { agentSessionId: 'abc-123' }), ['--resume', 'abc-123']);
84
+ assert.deepEqual(resolveRuntimeResumeArgs('claude'), ['--continue']);
85
+ assert.deepEqual(resolveRuntimeResumeArgs('codex', { agentSessionId: 'abc-123' }), ['resume', 'abc-123']);
86
+ assert.deepEqual(resolveRuntimeResumeArgs('codex'), ['resume', '--last']);
87
+ assert.deepEqual(resolveRuntimeResumeArgs('opencode', { agentSessionId: 'abc-123' }), ['--session', 'abc-123']);
88
+ assert.deepEqual(resolveRuntimeResumeArgs('opencode'), ['--continue']);
89
+ assert.deepEqual(resolveRuntimeResumeArgs('openclaw'), []);
90
+ });
@@ -0,0 +1,59 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { canonicalizeProjectPath, safeResolve, validateTaskId } from '../security.mjs';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ test('canonicalizeProjectPath resolves a relative path to absolute', () => {
10
+ const abs = canonicalizeProjectPath('.');
11
+ assert.equal(typeof abs, 'string');
12
+ assert.ok(path.isAbsolute(abs));
13
+ });
14
+
15
+ test('canonicalizeProjectPath throws on null, undefined, and empty string', () => {
16
+ assert.throws(() => canonicalizeProjectPath(null), { name: 'TypeError' });
17
+ assert.throws(() => canonicalizeProjectPath(undefined), { name: 'TypeError' });
18
+ assert.throws(() => canonicalizeProjectPath(''), { name: 'Error' });
19
+ });
20
+
21
+ test('safeResolve resolves a valid relative path within base', () => {
22
+ const base = path.resolve('/tmp/test-base');
23
+ const resolved = safeResolve(base, 'sub/dir');
24
+ assert.equal(resolved, path.resolve(base, 'sub/dir'));
25
+ });
26
+
27
+ test('safeResolve throws on path traversal attempting to escape base', () => {
28
+ const base = path.resolve('/tmp/test-base');
29
+ assert.throws(() => safeResolve(base, '../etc/passwd'), { name: 'Error' });
30
+ assert.throws(() => safeResolve(base, 'sub/../../etc'), { name: 'Error' });
31
+ });
32
+
33
+ test('safeResolve throws on absolute path argument outside base', () => {
34
+ const base = path.resolve('/tmp/test-base');
35
+ assert.throws(() => safeResolve(base, '/etc/passwd'), { name: 'Error' });
36
+ });
37
+
38
+ test('safeResolve throws on null byte in path', () => {
39
+ const base = path.resolve('/tmp/test-base');
40
+ assert.throws(() => safeResolve(base, 'sub\0dir'), { name: 'Error' });
41
+ });
42
+
43
+ test('validateTaskId accepts a valid task ID', () => {
44
+ assert.equal(validateTaskId('task-wf-ui-control-0729'), true);
45
+ });
46
+
47
+ test('validateTaskId rejects path traversal sequences', () => {
48
+ assert.equal(validateTaskId('../'), false);
49
+ assert.equal(validateTaskId('..'), false);
50
+ assert.equal(validateTaskId('a/../b'), false);
51
+ });
52
+
53
+ test('validateTaskId rejects null byte in task ID', () => {
54
+ assert.equal(validateTaskId('task\0id'), false);
55
+ });
56
+
57
+ test('validateTaskId rejects empty string', () => {
58
+ assert.equal(validateTaskId(''), false);
59
+ });