@vibelet/cli 0.1.35 → 0.1.37

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 (323) hide show
  1. package/app.json +5 -0
  2. package/dist/advertised-hosts.d.ts +34 -0
  3. package/dist/advertised-hosts.d.ts.map +1 -0
  4. package/dist/advertised-hosts.js +176 -0
  5. package/dist/advertised-hosts.js.map +1 -0
  6. package/dist/advertised-hosts.test.d.ts +2 -0
  7. package/dist/advertised-hosts.test.d.ts.map +1 -0
  8. package/dist/advertised-hosts.test.js +96 -0
  9. package/dist/advertised-hosts.test.js.map +1 -0
  10. package/dist/audit.d.ts +30 -0
  11. package/dist/audit.d.ts.map +1 -0
  12. package/dist/audit.js +73 -0
  13. package/dist/audit.js.map +1 -0
  14. package/dist/audit.test.d.ts +2 -0
  15. package/dist/audit.test.d.ts.map +1 -0
  16. package/dist/audit.test.js +33 -0
  17. package/dist/audit.test.js.map +1 -0
  18. package/dist/auth.d.ts +6 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +27 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/claude-hooks.d.ts +58 -0
  23. package/dist/claude-hooks.d.ts.map +1 -0
  24. package/dist/claude-hooks.js +129 -0
  25. package/dist/claude-hooks.js.map +1 -0
  26. package/dist/cli-version.d.ts +3 -0
  27. package/dist/cli-version.d.ts.map +1 -0
  28. package/dist/cli-version.js +35 -0
  29. package/dist/cli-version.js.map +1 -0
  30. package/dist/cli-version.test.d.ts +2 -0
  31. package/dist/cli-version.test.d.ts.map +1 -0
  32. package/dist/cli-version.test.js +38 -0
  33. package/dist/cli-version.test.js.map +1 -0
  34. package/dist/config.d.ts +30 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +327 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/config.test.d.ts +2 -0
  39. package/dist/config.test.d.ts.map +1 -0
  40. package/dist/config.test.js +184 -0
  41. package/dist/config.test.js.map +1 -0
  42. package/dist/dev-auth.test.d.ts +2 -0
  43. package/dist/dev-auth.test.d.ts.map +1 -0
  44. package/dist/dev-auth.test.js +154 -0
  45. package/dist/dev-auth.test.js.map +1 -0
  46. package/dist/dev-script.test.d.ts +2 -0
  47. package/dist/dev-script.test.d.ts.map +1 -0
  48. package/dist/dev-script.test.js +412 -0
  49. package/dist/dev-script.test.js.map +1 -0
  50. package/dist/drivers/claude.d.ts +34 -0
  51. package/dist/drivers/claude.d.ts.map +1 -0
  52. package/dist/drivers/claude.js +413 -0
  53. package/dist/drivers/claude.js.map +1 -0
  54. package/dist/drivers/claude.test.d.ts +2 -0
  55. package/dist/drivers/claude.test.d.ts.map +1 -0
  56. package/dist/drivers/claude.test.js +951 -0
  57. package/dist/drivers/claude.test.js.map +1 -0
  58. package/dist/drivers/codex.d.ts +38 -0
  59. package/dist/drivers/codex.d.ts.map +1 -0
  60. package/dist/drivers/codex.js +771 -0
  61. package/dist/drivers/codex.js.map +1 -0
  62. package/dist/drivers/codex.test.d.ts +2 -0
  63. package/dist/drivers/codex.test.d.ts.map +1 -0
  64. package/dist/drivers/codex.test.js +939 -0
  65. package/dist/drivers/codex.test.js.map +1 -0
  66. package/dist/drivers/types.d.ts +14 -0
  67. package/dist/drivers/types.d.ts.map +1 -0
  68. package/dist/drivers/types.js +2 -0
  69. package/dist/drivers/types.js.map +1 -0
  70. package/dist/e2e.test.d.ts +2 -0
  71. package/dist/e2e.test.d.ts.map +1 -0
  72. package/dist/e2e.test.js +111 -0
  73. package/dist/e2e.test.js.map +1 -0
  74. package/dist/identity.d.ts +10 -0
  75. package/dist/identity.d.ts.map +1 -0
  76. package/dist/identity.js +66 -0
  77. package/dist/identity.js.map +1 -0
  78. package/dist/identity.test.d.ts +2 -0
  79. package/dist/identity.test.d.ts.map +1 -0
  80. package/dist/identity.test.js +25 -0
  81. package/dist/identity.test.js.map +1 -0
  82. package/dist/index-entry.test.d.ts +2 -0
  83. package/dist/index-entry.test.d.ts.map +1 -0
  84. package/dist/index-entry.test.js +272 -0
  85. package/dist/index-entry.test.js.map +1 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.d.ts.map +1 -0
  88. package/dist/index.js +707 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/logger.d.ts +31 -0
  91. package/dist/logger.d.ts.map +1 -0
  92. package/dist/logger.js +75 -0
  93. package/dist/logger.js.map +1 -0
  94. package/dist/metrics.d.ts +52 -0
  95. package/dist/metrics.d.ts.map +1 -0
  96. package/dist/metrics.js +89 -0
  97. package/dist/metrics.js.map +1 -0
  98. package/dist/pairing-store.d.ts +29 -0
  99. package/dist/pairing-store.d.ts.map +1 -0
  100. package/dist/pairing-store.js +131 -0
  101. package/dist/pairing-store.js.map +1 -0
  102. package/dist/pairing-store.test.d.ts +2 -0
  103. package/dist/pairing-store.test.d.ts.map +1 -0
  104. package/dist/pairing-store.test.js +47 -0
  105. package/dist/pairing-store.test.js.map +1 -0
  106. package/dist/paths.d.ts +16 -0
  107. package/dist/paths.d.ts.map +1 -0
  108. package/dist/paths.js +18 -0
  109. package/dist/paths.js.map +1 -0
  110. package/dist/perf-compare.d.ts +13 -0
  111. package/dist/perf-compare.d.ts.map +1 -0
  112. package/dist/perf-compare.js +125 -0
  113. package/dist/perf-compare.js.map +1 -0
  114. package/dist/port-conflict.d.ts +9 -0
  115. package/dist/port-conflict.d.ts.map +1 -0
  116. package/dist/port-conflict.js +33 -0
  117. package/dist/port-conflict.js.map +1 -0
  118. package/dist/port-conflict.test.d.ts +2 -0
  119. package/dist/port-conflict.test.d.ts.map +1 -0
  120. package/dist/port-conflict.test.js +38 -0
  121. package/dist/port-conflict.test.js.map +1 -0
  122. package/dist/process-scanner.d.ts +43 -0
  123. package/dist/process-scanner.d.ts.map +1 -0
  124. package/dist/process-scanner.js +453 -0
  125. package/dist/process-scanner.js.map +1 -0
  126. package/dist/process-scanner.perf.test.d.ts +2 -0
  127. package/dist/process-scanner.perf.test.d.ts.map +1 -0
  128. package/dist/process-scanner.perf.test.js +186 -0
  129. package/dist/process-scanner.perf.test.js.map +1 -0
  130. package/dist/process-scanner.test.d.ts +2 -0
  131. package/dist/process-scanner.test.d.ts.map +1 -0
  132. package/dist/process-scanner.test.js +399 -0
  133. package/dist/process-scanner.test.js.map +1 -0
  134. package/dist/push-protocol.d.ts +15 -0
  135. package/dist/push-protocol.d.ts.map +1 -0
  136. package/dist/push-protocol.js +23 -0
  137. package/dist/push-protocol.js.map +1 -0
  138. package/dist/push-protocol.test.d.ts +2 -0
  139. package/dist/push-protocol.test.d.ts.map +1 -0
  140. package/dist/push-protocol.test.js +57 -0
  141. package/dist/push-protocol.test.js.map +1 -0
  142. package/dist/push-store.d.ts +22 -0
  143. package/dist/push-store.d.ts.map +1 -0
  144. package/dist/push-store.js +103 -0
  145. package/dist/push-store.js.map +1 -0
  146. package/dist/push-store.test.d.ts +2 -0
  147. package/dist/push-store.test.d.ts.map +1 -0
  148. package/dist/push-store.test.js +79 -0
  149. package/dist/push-store.test.js.map +1 -0
  150. package/dist/push.d.ts +65 -0
  151. package/dist/push.d.ts.map +1 -0
  152. package/dist/push.js +202 -0
  153. package/dist/push.js.map +1 -0
  154. package/dist/push.test.d.ts +2 -0
  155. package/dist/push.test.d.ts.map +1 -0
  156. package/dist/push.test.js +199 -0
  157. package/dist/push.test.js.map +1 -0
  158. package/dist/safe-stdio.d.ts +3 -0
  159. package/dist/safe-stdio.d.ts.map +1 -0
  160. package/dist/safe-stdio.js +46 -0
  161. package/dist/safe-stdio.js.map +1 -0
  162. package/dist/scanner.d.ts +30 -0
  163. package/dist/scanner.d.ts.map +1 -0
  164. package/dist/scanner.js +859 -0
  165. package/dist/scanner.js.map +1 -0
  166. package/dist/scanner.perf.test.d.ts +2 -0
  167. package/dist/scanner.perf.test.d.ts.map +1 -0
  168. package/dist/scanner.perf.test.js +320 -0
  169. package/dist/scanner.perf.test.js.map +1 -0
  170. package/dist/scanner.test.d.ts +2 -0
  171. package/dist/scanner.test.d.ts.map +1 -0
  172. package/dist/scanner.test.js +948 -0
  173. package/dist/scanner.test.js.map +1 -0
  174. package/dist/session-inventory.d.ts +63 -0
  175. package/dist/session-inventory.d.ts.map +1 -0
  176. package/dist/session-inventory.js +525 -0
  177. package/dist/session-inventory.js.map +1 -0
  178. package/dist/session-inventory.perf.test.d.ts +2 -0
  179. package/dist/session-inventory.perf.test.d.ts.map +1 -0
  180. package/dist/session-inventory.perf.test.js +220 -0
  181. package/dist/session-inventory.perf.test.js.map +1 -0
  182. package/dist/session-inventory.test.d.ts +2 -0
  183. package/dist/session-inventory.test.d.ts.map +1 -0
  184. package/dist/session-inventory.test.js +712 -0
  185. package/dist/session-inventory.test.js.map +1 -0
  186. package/dist/session-manager.d.ts +75 -0
  187. package/dist/session-manager.d.ts.map +1 -0
  188. package/dist/session-manager.js +1515 -0
  189. package/dist/session-manager.js.map +1 -0
  190. package/dist/session-manager.test.d.ts +2 -0
  191. package/dist/session-manager.test.d.ts.map +1 -0
  192. package/dist/session-manager.test.js +2861 -0
  193. package/dist/session-manager.test.js.map +1 -0
  194. package/dist/session-store.d.ts +42 -0
  195. package/dist/session-store.d.ts.map +1 -0
  196. package/dist/session-store.js +163 -0
  197. package/dist/session-store.js.map +1 -0
  198. package/dist/session-store.test.d.ts +2 -0
  199. package/dist/session-store.test.d.ts.map +1 -0
  200. package/dist/session-store.test.js +236 -0
  201. package/dist/session-store.test.js.map +1 -0
  202. package/dist/session-title.d.ts +6 -0
  203. package/dist/session-title.d.ts.map +1 -0
  204. package/dist/session-title.js +105 -0
  205. package/dist/session-title.js.map +1 -0
  206. package/dist/session-title.perf.test.d.ts +2 -0
  207. package/dist/session-title.perf.test.d.ts.map +1 -0
  208. package/dist/session-title.perf.test.js +99 -0
  209. package/dist/session-title.perf.test.js.map +1 -0
  210. package/dist/session-title.test.d.ts +2 -0
  211. package/dist/session-title.test.d.ts.map +1 -0
  212. package/dist/session-title.test.js +199 -0
  213. package/dist/session-title.test.js.map +1 -0
  214. package/dist/shutdown-endpoint.test.d.ts +2 -0
  215. package/dist/shutdown-endpoint.test.d.ts.map +1 -0
  216. package/dist/shutdown-endpoint.test.js +93 -0
  217. package/dist/shutdown-endpoint.test.js.map +1 -0
  218. package/dist/storage-housekeeping.d.ts +28 -0
  219. package/dist/storage-housekeeping.d.ts.map +1 -0
  220. package/dist/storage-housekeeping.js +76 -0
  221. package/dist/storage-housekeeping.js.map +1 -0
  222. package/dist/storage-housekeeping.test.d.ts +2 -0
  223. package/dist/storage-housekeeping.test.d.ts.map +1 -0
  224. package/dist/storage-housekeeping.test.js +65 -0
  225. package/dist/storage-housekeeping.test.js.map +1 -0
  226. package/dist/test-daemon-harness.d.ts +31 -0
  227. package/dist/test-daemon-harness.d.ts.map +1 -0
  228. package/dist/test-daemon-harness.js +337 -0
  229. package/dist/test-daemon-harness.js.map +1 -0
  230. package/dist/token-auth.test.d.ts +2 -0
  231. package/dist/token-auth.test.d.ts.map +1 -0
  232. package/dist/token-auth.test.js +52 -0
  233. package/dist/token-auth.test.js.map +1 -0
  234. package/dist/utils.d.ts +4 -0
  235. package/dist/utils.d.ts.map +1 -0
  236. package/dist/utils.js +40 -0
  237. package/dist/utils.js.map +1 -0
  238. package/dist/utils.test.d.ts +2 -0
  239. package/dist/utils.test.d.ts.map +1 -0
  240. package/dist/utils.test.js +54 -0
  241. package/dist/utils.test.js.map +1 -0
  242. package/dist/ws-data.d.ts +4 -0
  243. package/dist/ws-data.d.ts.map +1 -0
  244. package/dist/ws-data.js +20 -0
  245. package/dist/ws-data.js.map +1 -0
  246. package/dist/ws-data.test.d.ts +2 -0
  247. package/dist/ws-data.test.d.ts.map +1 -0
  248. package/dist/ws-data.test.js +17 -0
  249. package/dist/ws-data.test.js.map +1 -0
  250. package/package.json +24 -24
  251. package/perf-reporter.mjs +138 -0
  252. package/scripts/build-release.mjs +41 -0
  253. package/scripts/dev.mjs +537 -0
  254. package/src/advertised-hosts.test.ts +125 -0
  255. package/src/advertised-hosts.ts +225 -0
  256. package/src/audit.test.ts +38 -0
  257. package/src/audit.ts +117 -0
  258. package/src/auth.ts +31 -0
  259. package/src/claude-hooks.ts +195 -0
  260. package/src/cli-version.test.ts +36 -0
  261. package/src/cli-version.ts +46 -0
  262. package/src/config.test.ts +254 -0
  263. package/src/config.ts +324 -0
  264. package/src/dev-auth.test.ts +183 -0
  265. package/src/dev-script.test.ts +511 -0
  266. package/src/drivers/claude.test.ts +1186 -0
  267. package/src/drivers/claude.ts +443 -0
  268. package/src/drivers/codex.test.ts +1096 -0
  269. package/src/drivers/codex.ts +879 -0
  270. package/src/drivers/types.ts +15 -0
  271. package/src/e2e.test.ts +139 -0
  272. package/src/identity.test.ts +26 -0
  273. package/src/identity.ts +82 -0
  274. package/src/index-entry.test.ts +336 -0
  275. package/src/index.ts +781 -0
  276. package/src/logger.ts +112 -0
  277. package/src/metrics.ts +117 -0
  278. package/src/pairing-store.test.ts +53 -0
  279. package/src/pairing-store.ts +154 -0
  280. package/src/paths.ts +19 -0
  281. package/src/perf-compare.ts +164 -0
  282. package/src/port-conflict.test.ts +45 -0
  283. package/src/port-conflict.ts +44 -0
  284. package/src/process-scanner.perf.test.ts +222 -0
  285. package/src/process-scanner.test.ts +575 -0
  286. package/src/process-scanner.ts +514 -0
  287. package/src/push-protocol.test.ts +74 -0
  288. package/src/push-protocol.ts +36 -0
  289. package/src/push-store.test.ts +89 -0
  290. package/src/push-store.ts +126 -0
  291. package/src/push.test.ts +234 -0
  292. package/src/push.ts +318 -0
  293. package/src/safe-stdio.ts +51 -0
  294. package/src/scanner.perf.test.ts +359 -0
  295. package/src/scanner.test.ts +1045 -0
  296. package/src/scanner.ts +924 -0
  297. package/src/session-inventory.perf.test.ts +250 -0
  298. package/src/session-inventory.test.ts +1002 -0
  299. package/src/session-inventory.ts +721 -0
  300. package/src/session-manager.test.ts +3430 -0
  301. package/src/session-manager.ts +1775 -0
  302. package/src/session-store.test.ts +276 -0
  303. package/src/session-store.ts +202 -0
  304. package/src/session-title.perf.test.ts +118 -0
  305. package/src/session-title.test.ts +286 -0
  306. package/src/session-title.ts +108 -0
  307. package/src/shutdown-endpoint.test.ts +95 -0
  308. package/src/storage-housekeeping.test.ts +78 -0
  309. package/src/storage-housekeeping.ts +111 -0
  310. package/src/test-daemon-harness.ts +410 -0
  311. package/src/token-auth.test.ts +67 -0
  312. package/src/utils.test.ts +65 -0
  313. package/src/utils.ts +47 -0
  314. package/src/ws-data.test.ts +20 -0
  315. package/src/ws-data.ts +26 -0
  316. package/tsconfig.json +12 -0
  317. package/README.md +0 -80
  318. package/bin/cloudflared-quick-tunnel.mjs +0 -11
  319. package/bin/cloudflared-resolver.mjs +0 -68
  320. package/bin/vibelet-runtime-policy.mjs +0 -36
  321. package/bin/vibelet.cjs +0 -12
  322. package/bin/vibelet.mjs +0 -1035
  323. package/dist/index.cjs +0 -125
@@ -0,0 +1,1096 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFile, rm, chmod, mkdtemp, readFile } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import type { ServerMessage } from '@vibelet/shared';
7
+ import { CodexDriver } from './codex.js';
8
+
9
+ // Helper: access private members via `any` cast
10
+ function getDriver() {
11
+ const driver = new CodexDriver();
12
+ const d = driver as any;
13
+ return { driver, d };
14
+ }
15
+
16
+ // ─── RPC response handling ───────────────────────────────────────────
17
+
18
+ test('handleRaw resolves pending promise on successful RPC response', async () => {
19
+ const { driver, d } = getDriver();
20
+
21
+ let resolved: unknown;
22
+ const promise = new Promise<unknown>((resolve, reject) => {
23
+ d.pending.set(1, { resolve, reject });
24
+ });
25
+ promise.then((v) => { resolved = v; });
26
+
27
+ d.handleRaw({ id: 1, result: { thread: { id: 'th-1' } } });
28
+
29
+ // Let microtask run
30
+ await new Promise((r) => setTimeout(r, 0));
31
+ assert.deepEqual(resolved, { thread: { id: 'th-1' } });
32
+ assert.equal(d.pending.size, 0);
33
+ });
34
+
35
+ test('handleRaw rejects pending promise on RPC error response', async () => {
36
+ const { driver, d } = getDriver();
37
+
38
+ let rejected: Error | undefined;
39
+ const promise = new Promise<unknown>((resolve, reject) => {
40
+ d.pending.set(2, { resolve, reject });
41
+ });
42
+ promise.catch((e: Error) => { rejected = e; });
43
+
44
+ d.handleRaw({ id: 2, error: { message: 'something went wrong' } });
45
+
46
+ await new Promise((r) => setTimeout(r, 0));
47
+ assert.ok(rejected);
48
+ assert.equal(rejected!.message, 'something went wrong');
49
+ assert.equal(d.pending.size, 0);
50
+ });
51
+
52
+ test('handleRaw rejects with default message when RPC error has no message', async () => {
53
+ const { driver, d } = getDriver();
54
+
55
+ let rejected: Error | undefined;
56
+ const promise = new Promise<unknown>((resolve, reject) => {
57
+ d.pending.set(3, { resolve, reject });
58
+ });
59
+ promise.catch((e: Error) => { rejected = e; });
60
+
61
+ d.handleRaw({ id: 3, error: {} });
62
+
63
+ await new Promise((r) => setTimeout(r, 0));
64
+ assert.ok(rejected);
65
+ assert.equal(rejected!.message, 'RPC error');
66
+ });
67
+
68
+ test('handleRaw ignores RPC response with unknown id', () => {
69
+ const { driver, d } = getDriver();
70
+ const messages: ServerMessage[] = [];
71
+ driver.onMessage((msg) => messages.push(msg));
72
+
73
+ // No pending entry for id=99
74
+ d.handleRaw({ id: 99, result: 'ok' });
75
+
76
+ assert.equal(messages.length, 0);
77
+ });
78
+
79
+ // ─── Approval request ────────────────────────────────────────────────
80
+
81
+ test('handleRaw emits approval.request for commandExecution/requestApproval', () => {
82
+ const { driver, d } = getDriver();
83
+ d.threadId = 'thread-42';
84
+ const messages: ServerMessage[] = [];
85
+ driver.onMessage((msg) => messages.push(msg));
86
+
87
+ d.handleRaw({
88
+ jsonrpc: '2.0',
89
+ method: 'item/commandExecution/requestApproval',
90
+ id: 10,
91
+ params: {
92
+ command: 'rm -rf /',
93
+ cwd: '/home',
94
+ reason: 'needs cleanup',
95
+ },
96
+ });
97
+
98
+ assert.equal(messages.length, 1);
99
+ assert.deepEqual(messages[0], {
100
+ type: 'approval.request',
101
+ sessionId: 'thread-42',
102
+ requestId: '10',
103
+ toolName: 'Bash',
104
+ input: { command: 'rm -rf /', cwd: '/home' },
105
+ description: 'needs cleanup',
106
+ approvalContext: {
107
+ provider: 'codex',
108
+ kind: 'command-execution',
109
+ rpcId: 10,
110
+ },
111
+ });
112
+ });
113
+
114
+ test('handleRaw approval.request uses empty defaults when params missing', () => {
115
+ const { driver, d } = getDriver();
116
+ d.threadId = 'th-1';
117
+ const messages: ServerMessage[] = [];
118
+ driver.onMessage((msg) => messages.push(msg));
119
+
120
+ d.handleRaw({
121
+ method: 'item/commandExecution/requestApproval',
122
+ id: 20,
123
+ params: {},
124
+ });
125
+
126
+ assert.equal(messages.length, 1);
127
+ const msg = messages[0] as any;
128
+ assert.deepEqual(msg.input, {});
129
+ assert.equal(msg.description, '');
130
+ });
131
+
132
+ test('handleRaw emits approval.request for fileChange/requestApproval using remembered tool context', () => {
133
+ const { driver, d } = getDriver();
134
+ d.threadId = 'thread-43';
135
+ const messages: ServerMessage[] = [];
136
+ driver.onMessage((msg) => messages.push(msg));
137
+
138
+ d.handleRaw({
139
+ method: 'item/started',
140
+ params: {
141
+ item: {
142
+ type: 'file_change',
143
+ id: 'patch-1',
144
+ path: 'world.md',
145
+ changes: [{ kind: 'create' }],
146
+ },
147
+ },
148
+ });
149
+
150
+ d.handleRaw({
151
+ method: 'item/fileChange/requestApproval',
152
+ id: 11,
153
+ params: {
154
+ itemId: 'patch-1',
155
+ reason: 'create a file',
156
+ },
157
+ });
158
+
159
+ assert.equal(messages.length, 2);
160
+ assert.deepEqual(messages[1], {
161
+ type: 'approval.request',
162
+ sessionId: 'thread-43',
163
+ requestId: '11',
164
+ toolName: 'Patch',
165
+ input: {
166
+ path: 'world.md',
167
+ changes: [{ kind: 'create' }],
168
+ },
169
+ description: 'create a file',
170
+ approvalContext: {
171
+ provider: 'codex',
172
+ kind: 'file-change',
173
+ rpcId: 11,
174
+ },
175
+ });
176
+ });
177
+
178
+ test('handleRaw emits approval.request for approval-shaped requestUserInput', () => {
179
+ const { driver, d } = getDriver();
180
+ d.threadId = 'thread-44';
181
+ const messages: ServerMessage[] = [];
182
+ driver.onMessage((msg) => messages.push(msg));
183
+
184
+ d.handleRaw({
185
+ method: 'item/started',
186
+ params: {
187
+ item: { type: 'command_execution', id: 'cmd-approval', command: 'touch world.md' },
188
+ },
189
+ });
190
+
191
+ d.handleRaw({
192
+ method: 'item/tool/requestUserInput',
193
+ id: 12,
194
+ params: {
195
+ itemId: 'cmd-approval',
196
+ questions: [
197
+ {
198
+ id: 'q1',
199
+ question: 'Allow this write?',
200
+ options: [{ label: 'Allow' }, { label: 'Deny' }],
201
+ },
202
+ ],
203
+ },
204
+ });
205
+
206
+ assert.equal(messages.length, 2);
207
+ assert.deepEqual(messages[1], {
208
+ type: 'approval.request',
209
+ sessionId: 'thread-44',
210
+ requestId: '12',
211
+ toolName: 'Bash',
212
+ input: { command: 'touch world.md' },
213
+ description: 'Allow this write?',
214
+ approvalContext: {
215
+ provider: 'codex',
216
+ kind: 'request-user-input-approval',
217
+ rpcId: 12,
218
+ questionId: 'q1',
219
+ approveLabel: 'Allow',
220
+ denyLabel: 'Deny',
221
+ },
222
+ });
223
+ });
224
+
225
+ test('handleRaw rejects multi-question approval requests when allow/deny labels belong to different questions', () => {
226
+ const { driver, d } = getDriver();
227
+ d.threadId = 'thread-45';
228
+ const messages: ServerMessage[] = [];
229
+ let written = '';
230
+ driver.onMessage((msg) => messages.push(msg));
231
+ d.proc = {
232
+ stdin: {
233
+ writable: true,
234
+ write(chunk: string) {
235
+ written += chunk;
236
+ return true;
237
+ },
238
+ },
239
+ };
240
+
241
+ d.handleRaw({
242
+ method: 'item/tool/requestUserInput',
243
+ id: 14,
244
+ params: {
245
+ questions: [
246
+ {
247
+ id: 'q-allow',
248
+ question: 'Allow this write?',
249
+ options: [{ label: 'Allow' }],
250
+ },
251
+ {
252
+ id: 'q-deny',
253
+ question: 'Block this write?',
254
+ options: [{ label: 'Deny' }],
255
+ },
256
+ ],
257
+ },
258
+ });
259
+
260
+ assert.equal(messages.length, 0);
261
+ const response = JSON.parse(written.trim());
262
+ assert.equal(response.id, 14);
263
+ assert.equal(response.error.code, -32601);
264
+ assert.match(response.error.message, /Malformed requestUserInput approval request/i);
265
+ });
266
+
267
+ test('handleRaw rejects unsupported permissions approval requests instead of hanging', () => {
268
+ const { d } = getDriver();
269
+ let written = '';
270
+ d.proc = {
271
+ stdin: {
272
+ writable: true,
273
+ write(chunk: string) {
274
+ written += chunk;
275
+ return true;
276
+ },
277
+ },
278
+ };
279
+
280
+ d.handleRaw({
281
+ method: 'item/permissions/requestApproval',
282
+ id: 13,
283
+ params: { itemId: 'perm-1' },
284
+ });
285
+
286
+ const response = JSON.parse(written.trim());
287
+ assert.equal(response.id, 13);
288
+ assert.equal(response.error.code, -32601);
289
+ assert.match(response.error.message, /Unsupported permissions approval request/i);
290
+ });
291
+
292
+ // ─── agentMessage/delta ──────────────────────────────────────────────
293
+
294
+ test('handleRaw emits text.delta from params.text', () => {
295
+ const { driver, d } = getDriver();
296
+ d.threadId = 'th-1';
297
+ const messages: ServerMessage[] = [];
298
+ driver.onMessage((msg) => messages.push(msg));
299
+
300
+ d.handleRaw({ method: 'item/agentMessage/delta', params: { text: 'hello' } });
301
+
302
+ assert.equal(messages.length, 1);
303
+ assert.deepEqual(messages[0], {
304
+ type: 'text.delta',
305
+ sessionId: 'th-1',
306
+ content: 'hello',
307
+ });
308
+ });
309
+
310
+ test('handleRaw emits text.delta from params.delta', () => {
311
+ const { driver, d } = getDriver();
312
+ d.threadId = 'th-1';
313
+ const messages: ServerMessage[] = [];
314
+ driver.onMessage((msg) => messages.push(msg));
315
+
316
+ d.handleRaw({ method: 'item/agentMessage/delta', params: { delta: 'world' } });
317
+
318
+ assert.equal(messages.length, 1);
319
+ assert.equal((messages[0] as any).content, 'world');
320
+ });
321
+
322
+ test('handleRaw emits text.delta from params.content', () => {
323
+ const { driver, d } = getDriver();
324
+ d.threadId = 'th-1';
325
+ const messages: ServerMessage[] = [];
326
+ driver.onMessage((msg) => messages.push(msg));
327
+
328
+ d.handleRaw({ method: 'item/agentMessage/delta', params: { content: 'foo' } });
329
+
330
+ assert.equal(messages.length, 1);
331
+ assert.equal((messages[0] as any).content, 'foo');
332
+ });
333
+
334
+ test('handleRaw ignores agentMessage/delta with empty text', () => {
335
+ const { driver, d } = getDriver();
336
+ d.threadId = 'th-1';
337
+ const messages: ServerMessage[] = [];
338
+ driver.onMessage((msg) => messages.push(msg));
339
+
340
+ d.handleRaw({ method: 'item/agentMessage/delta', params: {} });
341
+
342
+ assert.equal(messages.length, 0);
343
+ });
344
+
345
+ // ─── commandExecution/outputDelta ────────────────────────────────────
346
+
347
+ test('handleRaw emits tool.result for commandExecution/outputDelta', () => {
348
+ const { driver, d } = getDriver();
349
+ d.threadId = 'th-1';
350
+ const messages: ServerMessage[] = [];
351
+ driver.onMessage((msg) => messages.push(msg));
352
+
353
+ d.handleRaw({
354
+ method: 'item/commandExecution/outputDelta',
355
+ params: { output: 'line1\nline2', itemId: 'item-5' },
356
+ });
357
+
358
+ assert.equal(messages.length, 1);
359
+ assert.deepEqual(messages[0], {
360
+ type: 'tool.result',
361
+ sessionId: 'th-1',
362
+ toolCallId: 'item-5',
363
+ output: 'line1\nline2',
364
+ });
365
+ });
366
+
367
+ test('handleRaw ignores commandExecution/outputDelta without output', () => {
368
+ const { driver, d } = getDriver();
369
+ d.threadId = 'th-1';
370
+ const messages: ServerMessage[] = [];
371
+ driver.onMessage((msg) => messages.push(msg));
372
+
373
+ d.handleRaw({ method: 'item/commandExecution/outputDelta', params: {} });
374
+
375
+ assert.equal(messages.length, 0);
376
+ });
377
+
378
+ // ─── item/started ────────────────────────────────────────────────────
379
+
380
+ test('handleRaw emits tool.call for item/started with command_execution', () => {
381
+ const { driver, d } = getDriver();
382
+ d.threadId = 'th-1';
383
+ const messages: ServerMessage[] = [];
384
+ driver.onMessage((msg) => messages.push(msg));
385
+
386
+ d.handleRaw({
387
+ method: 'item/started',
388
+ params: {
389
+ item: { type: 'command_execution', command: 'ls -la', id: 'cmd-1' },
390
+ },
391
+ });
392
+
393
+ assert.equal(messages.length, 1);
394
+ assert.deepEqual(messages[0], {
395
+ type: 'tool.call',
396
+ sessionId: 'th-1',
397
+ toolName: 'Bash',
398
+ input: { command: 'ls -la' },
399
+ toolCallId: 'cmd-1',
400
+ });
401
+ });
402
+
403
+ test('handleRaw ignores item/started with non-command_execution type', () => {
404
+ const { driver, d } = getDriver();
405
+ d.threadId = 'th-1';
406
+ const messages: ServerMessage[] = [];
407
+ driver.onMessage((msg) => messages.push(msg));
408
+
409
+ d.handleRaw({
410
+ method: 'item/started',
411
+ params: { item: { type: 'agent_message', id: 'msg-1' } },
412
+ });
413
+
414
+ assert.equal(messages.length, 0);
415
+ });
416
+
417
+ test('handleRaw ignores item/started without item param', () => {
418
+ const { driver, d } = getDriver();
419
+ d.threadId = 'th-1';
420
+ const messages: ServerMessage[] = [];
421
+ driver.onMessage((msg) => messages.push(msg));
422
+
423
+ d.handleRaw({ method: 'item/started', params: {} });
424
+
425
+ assert.equal(messages.length, 0);
426
+ });
427
+
428
+ // ─── turn/completed ──────────────────────────────────────────────────
429
+
430
+ test('handleRaw emits session.done on turn/completed with usage', () => {
431
+ const { driver, d } = getDriver();
432
+ d.threadId = 'th-1';
433
+ const messages: ServerMessage[] = [];
434
+ driver.onMessage((msg) => messages.push(msg));
435
+
436
+ d.handleRaw({
437
+ method: 'turn/completed',
438
+ params: {
439
+ turn: { status: 'completed' },
440
+ usage: { input_tokens: 100, output_tokens: 50 },
441
+ },
442
+ });
443
+
444
+ assert.equal(messages.length, 1);
445
+ assert.deepEqual(messages[0], {
446
+ type: 'session.done',
447
+ sessionId: 'th-1',
448
+ usage: { inputTokens: 100, outputTokens: 50 },
449
+ });
450
+ });
451
+
452
+ test('handleRaw emits session.done on turn/completed without usage', () => {
453
+ const { driver, d } = getDriver();
454
+ d.threadId = 'th-1';
455
+ const messages: ServerMessage[] = [];
456
+ driver.onMessage((msg) => messages.push(msg));
457
+
458
+ d.handleRaw({ method: 'turn/completed', params: {} });
459
+
460
+ assert.equal(messages.length, 1);
461
+ assert.deepEqual(messages[0], {
462
+ type: 'session.done',
463
+ sessionId: 'th-1',
464
+ usage: undefined,
465
+ });
466
+ });
467
+
468
+ test('handleRaw emits session.interrupted on turn/completed with aborted status', () => {
469
+ const { driver, d } = getDriver();
470
+ d.threadId = 'th-1';
471
+ const messages: ServerMessage[] = [];
472
+ driver.onMessage((msg) => messages.push(msg));
473
+
474
+ d.handleRaw({
475
+ method: 'turn/completed',
476
+ params: {
477
+ turn: { status: 'aborted' },
478
+ usage: { input_tokens: 100, output_tokens: 50 },
479
+ },
480
+ });
481
+
482
+ assert.equal(messages.length, 1);
483
+ assert.deepEqual(messages[0], {
484
+ type: 'session.interrupted',
485
+ sessionId: 'th-1',
486
+ });
487
+ });
488
+
489
+ test('handleRaw emits session.interrupted on turn/aborted', () => {
490
+ const { driver, d } = getDriver();
491
+ d.threadId = 'th-1';
492
+ const messages: ServerMessage[] = [];
493
+ driver.onMessage((msg) => messages.push(msg));
494
+
495
+ d.handleRaw({
496
+ method: 'turn/aborted',
497
+ params: { reason: 'interrupted' },
498
+ });
499
+
500
+ assert.equal(messages.length, 1);
501
+ assert.deepEqual(messages[0], {
502
+ type: 'session.interrupted',
503
+ sessionId: 'th-1',
504
+ });
505
+ });
506
+
507
+ // ─── Unknown / default ──────────────────────────────────────────────
508
+
509
+ test('handleRaw does not emit message for unknown notification method', () => {
510
+ const { driver, d } = getDriver();
511
+ d.threadId = 'th-1';
512
+ const messages: ServerMessage[] = [];
513
+ driver.onMessage((msg) => messages.push(msg));
514
+
515
+ d.handleRaw({ method: 'some/unknown/method', params: {} });
516
+
517
+ assert.equal(messages.length, 0);
518
+ });
519
+
520
+ test('handleRaw does not emit message when method is absent and id is not pending', () => {
521
+ const { driver, d } = getDriver();
522
+ d.threadId = 'th-1';
523
+ const messages: ServerMessage[] = [];
524
+ driver.onMessage((msg) => messages.push(msg));
525
+
526
+ // A response-like message with no pending entry
527
+ d.handleRaw({ id: 999, result: 'whatever' });
528
+
529
+ assert.equal(messages.length, 0);
530
+ });
531
+
532
+ // ─── No handler ──────────────────────────────────────────────────────
533
+
534
+ test('handleRaw does not throw when handler is null', () => {
535
+ const { driver, d } = getDriver();
536
+ // Do NOT set handler
537
+
538
+ assert.doesNotThrow(() => {
539
+ d.handleRaw({ method: 'item/agentMessage/delta', params: { text: 'hi' } });
540
+ });
541
+ });
542
+
543
+ test('handleRaw does not throw for approval request when handler is null', () => {
544
+ const { driver, d } = getDriver();
545
+
546
+ assert.doesNotThrow(() => {
547
+ d.handleRaw({
548
+ method: 'item/commandExecution/requestApproval',
549
+ id: 1,
550
+ params: { command: 'ls' },
551
+ });
552
+ });
553
+ });
554
+
555
+ // ─── buildSpawnArgs ──────────────────────────────────────────────────
556
+
557
+ test('buildSpawnArgs always returns base app-server args', () => {
558
+ const { d } = getDriver();
559
+
560
+ const args = d.buildSpawnArgs();
561
+
562
+ assert.deepEqual(args, ['app-server', '--listen', 'stdio://']);
563
+ });
564
+
565
+ // ─── stop() ──────────────────────────────────────────────────────────
566
+
567
+ test('stop() does nothing when proc is null', () => {
568
+ const { driver } = getDriver();
569
+ assert.doesNotThrow(() => driver.stop());
570
+ });
571
+
572
+ test('stop() rejects all pending RPCs when proc is set', async () => {
573
+ const { driver, d } = getDriver();
574
+
575
+ // Fake proc so stop() doesn't bail out early
576
+ d.proc = { kill: () => {}, killed: false };
577
+
578
+ const errors: Error[] = [];
579
+ const p1 = new Promise<unknown>((resolve, reject) => {
580
+ d.pending.set(1, { resolve, reject });
581
+ }).catch((e: Error) => { errors.push(e); });
582
+ const p2 = new Promise<unknown>((resolve, reject) => {
583
+ d.pending.set(2, { resolve, reject });
584
+ }).catch((e: Error) => { errors.push(e); });
585
+
586
+ driver.stop();
587
+
588
+ await Promise.all([p1, p2]);
589
+ assert.equal(errors.length, 2);
590
+ assert.ok(errors.every((e) => e.message === 'Process stopped'));
591
+ assert.equal(d.pending.size, 0);
592
+ });
593
+
594
+ // ─── interrupt() ─────────────────────────────────────────────────────
595
+
596
+ test('interrupt() rejects with Process not available when proc is null', async () => {
597
+ const { d } = getDriver();
598
+ await assert.rejects(() => d.rpc('turn/interrupt', { threadId: '' }), {
599
+ message: 'Process not available',
600
+ });
601
+ });
602
+
603
+ // ─── sendPrompt() ────────────────────────────────────────────────────
604
+
605
+ test('sendPrompt() emits error when proc is null', async () => {
606
+ const { driver, d } = getDriver();
607
+ const messages: ServerMessage[] = [];
608
+ driver.onMessage((msg) => messages.push(msg));
609
+ d.threadId = 'th-1';
610
+
611
+ driver.sendPrompt('hello');
612
+
613
+ // Wait for the internal promise rejection to propagate
614
+ await new Promise((r) => setTimeout(r, 50));
615
+ assert.ok(messages.length >= 1);
616
+ const errMsg = messages.find((m) => m.type === 'error');
617
+ assert.ok(errMsg);
618
+ });
619
+
620
+ // ─── respondApproval() ───────────────────────────────────────────────
621
+
622
+ test('respondApproval() does nothing when proc is null', () => {
623
+ const { driver } = getDriver();
624
+ assert.doesNotThrow(() => driver.respondApproval('1', true));
625
+ assert.doesNotThrow(() => driver.respondApproval('2', false));
626
+ });
627
+
628
+ test('respondApproval() maps command approvals to accept/decline', () => {
629
+ const { driver, d } = getDriver();
630
+ let written = '';
631
+ d.proc = {
632
+ stdin: {
633
+ writable: true,
634
+ write(chunk: string) {
635
+ written += chunk;
636
+ return true;
637
+ },
638
+ },
639
+ };
640
+ d.approvalRequests.set('21', {
641
+ kind: 'command-execution',
642
+ responseKind: 'v2',
643
+ rpcId: 21,
644
+ toolName: 'Bash',
645
+ input: { command: 'ls' },
646
+ });
647
+
648
+ assert.equal(driver.respondApproval('21', true), true);
649
+ assert.deepEqual(JSON.parse(written.trim()), {
650
+ jsonrpc: '2.0',
651
+ id: 21,
652
+ result: { decision: 'accept' },
653
+ });
654
+ });
655
+
656
+ test('respondApproval() maps requestUserInput approvals to answers payload', () => {
657
+ const { driver, d } = getDriver();
658
+ let written = '';
659
+ d.proc = {
660
+ stdin: {
661
+ writable: true,
662
+ write(chunk: string) {
663
+ written += chunk;
664
+ return true;
665
+ },
666
+ },
667
+ };
668
+ d.approvalRequests.set('22', {
669
+ kind: 'request-user-input-approval',
670
+ rpcId: 22,
671
+ questionId: 'q1',
672
+ approveLabel: 'Allow',
673
+ denyLabel: 'Deny',
674
+ toolName: 'Patch',
675
+ input: { path: 'world.md' },
676
+ });
677
+
678
+ assert.equal(driver.respondApproval('22', false), true);
679
+ assert.deepEqual(JSON.parse(written.trim()), {
680
+ jsonrpc: '2.0',
681
+ id: 22,
682
+ result: {
683
+ answers: {
684
+ q1: {
685
+ answers: ['Deny'],
686
+ },
687
+ },
688
+ },
689
+ });
690
+ });
691
+
692
+ // ─── onMessage() ─────────────────────────────────────────────────────
693
+
694
+ test('onMessage() sets the handler', () => {
695
+ const { driver, d } = getDriver();
696
+ assert.equal(d.handler, null);
697
+ const handler = () => {};
698
+ driver.onMessage(handler);
699
+ assert.equal(d.handler, handler);
700
+ });
701
+
702
+ // ─── onExit() ────────────────────────────────────────────────────────
703
+
704
+ test('onExit() sets the exit handler', () => {
705
+ const { driver, d } = getDriver();
706
+ assert.equal(d.exitHandler, null);
707
+ const handler = () => {};
708
+ driver.onExit(handler);
709
+ assert.equal(d.exitHandler, handler);
710
+ });
711
+
712
+ // ─── notify() (private) ─────────────────────────────────────────────
713
+
714
+ test('notify() does nothing when proc is null', () => {
715
+ const { d } = getDriver();
716
+ assert.doesNotThrow(() => d.rpcNotify('some/method', { data: 1 }));
717
+ });
718
+
719
+ // ─── rpc() (private) ────────────────────────────────────────────────
720
+
721
+ test('rpc() rejects immediately when proc is null', async () => {
722
+ const { d } = getDriver();
723
+ await assert.rejects(() => d.rpc('some/method', {}), {
724
+ message: 'Process not available',
725
+ });
726
+ });
727
+
728
+ // ─── Fake codex binary helper ────────────────────────────────────────
729
+
730
+ async function createFakeCodex(tmpDir: string, behavior = 'normal'): Promise<string> {
731
+ const scriptPath = join(tmpDir, 'fake-codex');
732
+
733
+ let script: string;
734
+ if (behavior === 'normal') {
735
+ script = `#!/usr/bin/env node
736
+ const fs = require('fs');
737
+ const readline = require('readline');
738
+ const rl = readline.createInterface({ input: process.stdin });
739
+ let initializeCapabilities = null;
740
+ rl.on('line', (line) => {
741
+ try {
742
+ const msg = JSON.parse(line);
743
+ if (process.env.CODEX_TEST_LOG) {
744
+ fs.appendFileSync(process.env.CODEX_TEST_LOG, JSON.stringify(msg) + '\\n');
745
+ }
746
+ if (msg.method === 'initialize') {
747
+ initializeCapabilities = msg.params?.capabilities ?? null;
748
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { ok: true } }) + '\\n');
749
+ } else if (msg.method === 'initialized') {
750
+ // notification
751
+ } else if (msg.method === 'thread/start') {
752
+ if (msg.params?.experimentalRawEvents && !initializeCapabilities?.experimentalApi) {
753
+ process.stdout.write(JSON.stringify({
754
+ jsonrpc: '2.0',
755
+ id: msg.id,
756
+ error: { code: -32602, message: 'thread/start.experimentalRawEvents requires experimentalApi capability' },
757
+ }) + '\\n');
758
+ return;
759
+ }
760
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { thread: { id: 'test-thread-123' } } }) + '\\n');
761
+ } else if (msg.method === 'thread/resume') {
762
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { thread: { id: msg.params?.threadId || 'resumed' } } }) + '\\n');
763
+ } else if (msg.method === 'turn/start') {
764
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
765
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'item/agentMessage/delta', params: { text: 'Hello' } }) + '\\n');
766
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'turn/completed', params: { usage: { input_tokens: 10, output_tokens: 5 } } }) + '\\n');
767
+ } else if (msg.method === 'turn/interrupt') {
768
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
769
+ } else if (msg.method === 'thread/list') {
770
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { threads: [] } }) + '\\n');
771
+ } else if (msg.id != null) {
772
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
773
+ }
774
+ } catch {}
775
+ });
776
+ `;
777
+ } else if (behavior === 'interrupt-race') {
778
+ script = `#!/usr/bin/env node
779
+ const readline = require('readline');
780
+ const rl = readline.createInterface({ input: process.stdin });
781
+ let turnReady = false;
782
+ let turnInterrupted = false;
783
+ let turnCompleted = false;
784
+ rl.on('line', (line) => {
785
+ try {
786
+ const msg = JSON.parse(line);
787
+ if (msg.method === 'initialize') {
788
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { ok: true } }) + '\\n');
789
+ } else if (msg.method === 'initialized') {
790
+ // notification
791
+ } else if (msg.method === 'thread/start') {
792
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { thread: { id: 'race-thread-123' } } }) + '\\n');
793
+ } else if (msg.method === 'turn/start') {
794
+ setTimeout(() => {
795
+ turnReady = true;
796
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
797
+ setTimeout(() => {
798
+ if (turnInterrupted || turnCompleted) return;
799
+ turnCompleted = true;
800
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'item/agentMessage/delta', params: { text: 'Late reply' } }) + '\\n');
801
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'turn/completed', params: { usage: { input_tokens: 4, output_tokens: 2 } } }) + '\\n');
802
+ }, 80);
803
+ }, 120);
804
+ } else if (msg.method === 'turn/interrupt') {
805
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
806
+ if (turnReady && !turnInterrupted && !turnCompleted) {
807
+ turnInterrupted = true;
808
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'turn/aborted', params: { reason: 'interrupted' } }) + '\\n');
809
+ }
810
+ } else if (msg.id != null) {
811
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
812
+ }
813
+ } catch {}
814
+ });
815
+ `;
816
+ } else if (behavior === 'exit-immediately') {
817
+ script = `#!/usr/bin/env node
818
+ process.exit(1);
819
+ `;
820
+ } else {
821
+ script = `#!/usr/bin/env node
822
+ setTimeout(() => {}, 60000);
823
+ const readline = require('readline');
824
+ const rl = readline.createInterface({ input: process.stdin });
825
+ rl.on('line', () => {});
826
+ `;
827
+ }
828
+
829
+ await writeFile(scriptPath, script, 'utf-8');
830
+ await chmod(scriptPath, 0o755);
831
+ return scriptPath;
832
+ }
833
+
834
+ async function withFakeCodex(
835
+ behavior: string,
836
+ fn: (driver: CodexDriver) => Promise<void>,
837
+ ): Promise<void> {
838
+ const tmpDir = await mkdtemp(join(tmpdir(), 'codex-test-'));
839
+ const scriptPath = await createFakeCodex(tmpDir, behavior);
840
+ const originalCodexPath = process.env.CODEX_PATH;
841
+ process.env.CODEX_PATH = scriptPath;
842
+ const driver = new CodexDriver();
843
+ try {
844
+ await fn(driver);
845
+ } finally {
846
+ driver.stop();
847
+ process.env.CODEX_PATH = originalCodexPath;
848
+ if (!originalCodexPath) delete process.env.CODEX_PATH;
849
+ await rm(tmpDir, { recursive: true, force: true });
850
+ }
851
+ }
852
+
853
+ // ─── Integration tests: start() ─────────────────────────────────────
854
+
855
+ test('start: creates a new thread and returns threadId', async () => {
856
+ await withFakeCodex('normal', async (driver) => {
857
+ const threadId = await driver.start('/tmp');
858
+ assert.equal(threadId, 'test-thread-123');
859
+ });
860
+ });
861
+
862
+ test('start: resumes existing thread when resumeSessionId provided', async () => {
863
+ await withFakeCodex('normal', async (driver) => {
864
+ const threadId = await driver.start('/tmp', 'my-existing-thread');
865
+ assert.equal(threadId, 'my-existing-thread');
866
+ });
867
+ });
868
+
869
+ test('start: sends correct args for autoApprove mode', async () => {
870
+ await withFakeCodex('normal', async (driver) => {
871
+ const threadId = await driver.start('/tmp', undefined, 'autoApprove');
872
+ assert.equal(threadId, 'test-thread-123');
873
+ });
874
+ });
875
+
876
+ test('start: sends explicit on-request workspace-write policy for normal mode', async () => {
877
+ const tmpDir = await mkdtemp(join(tmpdir(), 'codex-policy-'));
878
+ const logPath = join(tmpDir, 'requests.jsonl');
879
+ const originalLogPath = process.env.CODEX_TEST_LOG;
880
+ process.env.CODEX_TEST_LOG = logPath;
881
+ try {
882
+ await withFakeCodex('normal', async (driver) => {
883
+ await driver.start('/tmp', undefined, 'normal');
884
+ });
885
+
886
+ const entries = (await readFile(logPath, 'utf-8'))
887
+ .trim()
888
+ .split('\n')
889
+ .filter(Boolean)
890
+ .map((line) => JSON.parse(line));
891
+ const initialize = entries.find((entry) => entry.method === 'initialize');
892
+ const threadStart = entries.find((entry) => entry.method === 'thread/start');
893
+ assert.ok(initialize);
894
+ assert.ok(threadStart);
895
+ assert.equal(initialize.params.capabilities.experimentalApi, true);
896
+ assert.equal(threadStart.params.approvalPolicy, 'on-request');
897
+ assert.equal(threadStart.params.sandbox, 'workspace-write');
898
+ assert.equal(threadStart.params.experimentalRawEvents, true);
899
+ } finally {
900
+ if (originalLogPath === undefined) delete process.env.CODEX_TEST_LOG;
901
+ else process.env.CODEX_TEST_LOG = originalLogPath;
902
+ await rm(tmpDir, { recursive: true, force: true });
903
+ }
904
+ });
905
+
906
+ // ─── Integration tests: sendPrompt() ────────────────────────────────
907
+
908
+ test('sendPrompt: sends turn/start and receives response', async () => {
909
+ await withFakeCodex('normal', async (driver) => {
910
+ await driver.start('/tmp');
911
+
912
+ const messages: ServerMessage[] = [];
913
+ driver.onMessage((msg) => messages.push(msg));
914
+
915
+ driver.sendPrompt('test message');
916
+
917
+ await new Promise((resolve) => setTimeout(resolve, 1000));
918
+
919
+ const types = messages.map((m) => m.type);
920
+ assert.ok(types.includes('text.delta'), `Expected text.delta in ${JSON.stringify(types)}`);
921
+ assert.ok(types.includes('session.done'), `Expected session.done in ${JSON.stringify(types)}`);
922
+ });
923
+ });
924
+
925
+ test('sendPrompt: sends explicit turn approvalPolicy and sandboxPolicy', async () => {
926
+ const tmpDir = await mkdtemp(join(tmpdir(), 'codex-turn-policy-'));
927
+ const logPath = join(tmpDir, 'requests.jsonl');
928
+ const originalLogPath = process.env.CODEX_TEST_LOG;
929
+ process.env.CODEX_TEST_LOG = logPath;
930
+ try {
931
+ await withFakeCodex('normal', async (driver) => {
932
+ await driver.start('/tmp', undefined, 'normal');
933
+ driver.onMessage(() => {});
934
+ driver.sendPrompt('create world.md');
935
+ await new Promise((resolve) => setTimeout(resolve, 500));
936
+ });
937
+
938
+ const entries = (await readFile(logPath, 'utf-8'))
939
+ .trim()
940
+ .split('\n')
941
+ .filter(Boolean)
942
+ .map((line) => JSON.parse(line));
943
+ const turnStart = entries.find((entry) => entry.method === 'turn/start');
944
+ assert.ok(turnStart);
945
+ assert.equal(turnStart.params.approvalPolicy, 'on-request');
946
+ assert.equal(turnStart.params.sandboxPolicy.type, 'workspaceWrite');
947
+ assert.deepEqual(turnStart.params.sandboxPolicy.writableRoots, ['/tmp']);
948
+ } finally {
949
+ if (originalLogPath === undefined) delete process.env.CODEX_TEST_LOG;
950
+ else process.env.CODEX_TEST_LOG = originalLogPath;
951
+ await rm(tmpDir, { recursive: true, force: true });
952
+ }
953
+ });
954
+
955
+ test('interrupt: retries after turn/start resolves when interrupt arrives too early', async () => {
956
+ await withFakeCodex('interrupt-race', async (driver) => {
957
+ await driver.start('/tmp');
958
+
959
+ const messages: ServerMessage[] = [];
960
+ driver.onMessage((msg) => messages.push(msg));
961
+
962
+ driver.sendPrompt('interrupt me quickly');
963
+ driver.interrupt();
964
+
965
+ await new Promise((resolve) => setTimeout(resolve, 500));
966
+
967
+ const types = messages.map((m) => m.type);
968
+ assert.ok(types.includes('session.interrupted'), `Expected session.interrupted in ${JSON.stringify(types)}`);
969
+ assert.ok(!types.includes('session.done'), `Did not expect session.done in ${JSON.stringify(types)}`);
970
+ assert.ok(!types.includes('text.delta'), `Did not expect text.delta in ${JSON.stringify(types)}`);
971
+ });
972
+ });
973
+
974
+ // ─── Integration tests: stop() ──────────────────────────────────────
975
+
976
+ test('stop: kills process and rejects pending RPCs', async () => {
977
+ await withFakeCodex('normal', async (driver) => {
978
+ await driver.start('/tmp');
979
+
980
+ driver.stop();
981
+ assert.equal((driver as any).proc, null);
982
+ assert.equal((driver as any).pending.size, 0);
983
+ });
984
+ });
985
+
986
+ // ─── Integration tests: exit handler ─────────────────────────────────
987
+
988
+ test('start: exit handler rejects pending RPCs and emits error on non-zero exit', async () => {
989
+ const tmpDir = await mkdtemp(join(tmpdir(), 'codex-exit-'));
990
+ const scriptPath = join(tmpDir, 'fake-codex');
991
+ await writeFile(scriptPath, `#!/usr/bin/env node
992
+ const readline = require('readline');
993
+ const rl = readline.createInterface({ input: process.stdin });
994
+ rl.on('line', (line) => {
995
+ try {
996
+ const msg = JSON.parse(line);
997
+ if (msg.method === 'initialize') {
998
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
999
+ } else if (msg.method === 'thread/start') {
1000
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { thread: { id: 'exit-thread' } } }) + '\\n');
1001
+ setTimeout(() => process.exit(1), 50);
1002
+ } else if (msg.id != null) {
1003
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
1004
+ }
1005
+ } catch {}
1006
+ });
1007
+ `, 'utf-8');
1008
+ await chmod(scriptPath, 0o755);
1009
+
1010
+ const original = process.env.CODEX_PATH;
1011
+ process.env.CODEX_PATH = scriptPath;
1012
+ const driver = new CodexDriver();
1013
+ try {
1014
+ await driver.start('/tmp');
1015
+ const messages: ServerMessage[] = [];
1016
+ driver.onMessage((msg) => messages.push(msg));
1017
+
1018
+ // Wait for the process to exit
1019
+ await new Promise(resolve => setTimeout(resolve, 500));
1020
+
1021
+ const errors = messages.filter(m => m.type === 'error');
1022
+ assert.ok(errors.length > 0, 'Should emit error on non-zero exit');
1023
+ } finally {
1024
+ driver.stop();
1025
+ process.env.CODEX_PATH = original;
1026
+ if (!original) delete process.env.CODEX_PATH;
1027
+ await rm(tmpDir, { recursive: true, force: true });
1028
+ }
1029
+ });
1030
+
1031
+ test('start: exit handler calls exitHandler callback', async () => {
1032
+ const tmpDir = await mkdtemp(join(tmpdir(), 'codex-exitcb-'));
1033
+ const scriptPath = join(tmpDir, 'fake-codex');
1034
+ await writeFile(scriptPath, `#!/usr/bin/env node
1035
+ const readline = require('readline');
1036
+ const rl = readline.createInterface({ input: process.stdin });
1037
+ rl.on('line', (line) => {
1038
+ try {
1039
+ const msg = JSON.parse(line);
1040
+ if (msg.method === 'initialize') {
1041
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
1042
+ } else if (msg.method === 'thread/start') {
1043
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { thread: { id: 'exit-thread' } } }) + '\\n');
1044
+ setTimeout(() => process.exit(1), 50);
1045
+ } else if (msg.id != null) {
1046
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} }) + '\\n');
1047
+ }
1048
+ } catch {}
1049
+ });
1050
+ `, 'utf-8');
1051
+ await chmod(scriptPath, 0o755);
1052
+
1053
+ const original = process.env.CODEX_PATH;
1054
+ process.env.CODEX_PATH = scriptPath;
1055
+ const driver = new CodexDriver();
1056
+ try {
1057
+ await driver.start('/tmp');
1058
+ let exitCode: number | null = null;
1059
+ driver.onExit((code) => { exitCode = code; });
1060
+
1061
+ await new Promise(resolve => setTimeout(resolve, 500));
1062
+
1063
+ assert.equal(exitCode, 1);
1064
+ } finally {
1065
+ driver.stop();
1066
+ process.env.CODEX_PATH = original;
1067
+ if (!original) delete process.env.CODEX_PATH;
1068
+ await rm(tmpDir, { recursive: true, force: true });
1069
+ }
1070
+ });
1071
+
1072
+ // ─── Integration tests: spawn error ──────────────────────────────────
1073
+
1074
+ test('start: spawn error with exit-immediately behavior rejects start', async () => {
1075
+ await assert.rejects(
1076
+ () => withFakeCodex('exit-immediately', async (driver) => {
1077
+ await driver.start('/tmp');
1078
+ }),
1079
+ (err: Error) => {
1080
+ return err.message.includes('exited with code 1');
1081
+ },
1082
+ );
1083
+ });
1084
+
1085
+ // ─── setApprovalMode ─────────────────────────────────────────────────
1086
+
1087
+ test('setApprovalMode: updates approvalMode field', () => {
1088
+ const driver = new CodexDriver();
1089
+ (driver as any).approvalMode = 'normal';
1090
+
1091
+ driver.setApprovalMode('autoApprove');
1092
+ assert.equal((driver as any).approvalMode, 'autoApprove');
1093
+
1094
+ driver.setApprovalMode('normal');
1095
+ assert.equal((driver as any).approvalMode, 'normal');
1096
+ });