@canonmsg/codex-plugin 0.29.4 → 0.31.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.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ // Real shared Codex protocol with a localhost fake model and Canon publisher.
3
+ // No real model/Canon calls; no HOME/CODEX_HOME or existing-session changes.
4
+ import assert from 'node:assert/strict';
5
+ import { spawn } from 'node:child_process';
6
+ import { mkdtemp, rm } from 'node:fs/promises';
7
+ import { createServer as httpServer } from 'node:http';
8
+ import { createServer as netServer } from 'node:net';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import WebSocket from 'ws';
12
+ import { createAttachedNativeSession, createFileAttachedSessionStore } from '@canonmsg/core';
13
+ import { createCanonAttachedSessionPublisher } from '@canonmsg/agent-sdk';
14
+ import { CodexWorkSessionProvider } from '../dist/work-session-provider.js';
15
+
16
+ const directory = await mkdtemp(join(tmpdir(), 'canon-work-session-smoke-'));
17
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ async function until(predicate, label) {
19
+ for (let attempt = 0; attempt < 300; attempt += 1) {
20
+ if (await predicate()) return;
21
+ await sleep(50);
22
+ }
23
+ throw new Error(`Timed out: ${label}`);
24
+ }
25
+ async function listen(server) {
26
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
27
+ return server.address().port;
28
+ }
29
+
30
+ let modelCalls = 0;
31
+ let catalogCalls = 0;
32
+ let askApproval = false;
33
+ const model = httpServer((request, response) => {
34
+ request.resume();
35
+ if (request.method === 'GET' && request.url?.startsWith('/v1/models')) {
36
+ catalogCalls += 1;
37
+ // Exercise the native server's built-in model catalog fallback.
38
+ response.writeHead(404, { 'content-type': 'application/json' });
39
+ response.end(JSON.stringify({ error: 'This disposable provider does not implement model discovery.' }));
40
+ return;
41
+ }
42
+ assert.equal(request.method, 'POST');
43
+ assert.equal(request.url, '/v1/responses');
44
+ modelCalls += 1;
45
+ const item = askApproval ? {
46
+ id: `function_${modelCalls}`, type: 'function_call', call_id: `approval_${modelCalls}`, name: 'exec_command',
47
+ arguments: JSON.stringify({ cmd: 'printf canon-work-session-smoke', sandbox_permissions: 'require_escalated', justification: 'Disposable test; the command will be declined.' }),
48
+ } : { id: `message_${modelCalls}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: `Mock answer ${modelCalls}.`, annotations: [] }] };
49
+ askApproval = false;
50
+ const result = { id: `response_${modelCalls}`, object: 'response', model: 'probe', status: 'completed', output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } };
51
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
52
+ for (const event of [
53
+ { type: 'response.created', response: { ...result, status: 'in_progress', output: [] } },
54
+ { type: 'response.output_item.added', output_index: 0, item },
55
+ { type: 'response.output_item.done', output_index: 0, item },
56
+ { type: 'response.completed', response: result },
57
+ ]) response.write(`data: ${JSON.stringify(event)}\n\n`);
58
+ response.end();
59
+ });
60
+ const modelPort = await listen(model);
61
+ const reservation = netServer();
62
+ const port = await listen(reservation);
63
+ await new Promise((resolve) => reservation.close(resolve));
64
+ const endpoint = `ws://127.0.0.1:${port}`;
65
+ const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _originatorOverride, ...serverEnv } = process.env;
66
+ const server = spawn(process.argv[2] ?? 'codex', [
67
+ 'app-server', '--disable', 'plugins', '--disable', 'apps', '--listen', endpoint,
68
+ '-c', 'model_provider="canon_smoke"',
69
+ '-c', `model_providers.canon_smoke={name="Canon local mock provider",base_url="http://127.0.0.1:${modelPort}/v1",wire_api="responses",requires_openai_auth=false}`,
70
+ ], { cwd: directory, env: serverEnv, stdio: ['ignore', 'pipe', 'pipe'] });
71
+ server.stdout.resume();
72
+ let serverError = '';
73
+ server.stderr.on('data', (data) => { serverError = `${serverError}${data}`.slice(-1_000); });
74
+
75
+ let provider;
76
+ let controller;
77
+ let native;
78
+ let threadId;
79
+ const bridgeRequests = [];
80
+ const nativeResponses = [];
81
+ const published = new Map();
82
+ const errors = [];
83
+ const bridge = { request(request, { signal }) {
84
+ return new Promise((resolve) => { bridgeRequests.push({ request, signal, resolve }); });
85
+ } };
86
+ function makeProvider() {
87
+ return new CodexWorkSessionProvider({ endpoint, cwd: directory, defaultExecutionMode: 'locked',
88
+ createSocket(url) {
89
+ const socket = new WebSocket(url);
90
+ const send = socket.send.bind(socket);
91
+ socket.send = (data, ...args) => {
92
+ const message = JSON.parse(data.toString());
93
+ if ('id' in message && !message.method) nativeResponses.push(message);
94
+ return send(data, ...args);
95
+ };
96
+ return socket;
97
+ },
98
+ onError: (error) => errors.push(error.message),
99
+ });
100
+ }
101
+ async function connectNative() {
102
+ const socket = new WebSocket(endpoint);
103
+ await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
104
+ let sequence = 0;
105
+ const pending = new Map();
106
+ const events = [];
107
+ const requests = [];
108
+ socket.on('message', (data) => {
109
+ const message = JSON.parse(data.toString());
110
+ if (message.method) { ('id' in message ? requests : events).push(message); return; }
111
+ const waiter = pending.get(message.id);
112
+ if (!waiter) return;
113
+ pending.delete(message.id);
114
+ clearTimeout(waiter.timer);
115
+ message.error ? waiter.reject(new Error(JSON.stringify(message.error))) : waiter.resolve(message.result);
116
+ });
117
+ const rpc = (method, params = {}) => new Promise((resolve, reject) => {
118
+ const id = ++sequence;
119
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 10_000);
120
+ pending.set(id, { resolve, reject, timer });
121
+ socket.send(JSON.stringify({ id, method, params }));
122
+ });
123
+ await rpc('initialize', { clientInfo: { name: 'canon_work_session_smoke', version: '0.0.0' }, capabilities: { experimentalApi: true } });
124
+ socket.send(JSON.stringify({ method: 'initialized' }));
125
+ return { socket, rpc, events, requests };
126
+ }
127
+
128
+ try {
129
+ await until(async () => {
130
+ if (server.exitCode !== null) throw new Error(serverError);
131
+ return fetch(`http://127.0.0.1:${port}/readyz`).then((result) => result.ok).catch(() => false);
132
+ }, 'native server readiness');
133
+ provider = makeProvider();
134
+ const catalog = await provider.catalog();
135
+ assert.ok(catalog.models.length > 0);
136
+ assert.ok(!JSON.stringify(catalog).includes(directory), 'Private catalog must not expose raw cwd');
137
+ const created = await provider.create({ mode: 'create', projectId: catalog.projects[0].id,
138
+ modelId: catalog.defaults.modelId, executionMode: 'locked', permissionMode: 'workspace' },
139
+ { requestId: 'create-smoke', conversationId: 'room', agentId: 'agent' });
140
+ threadId = created.nativeSessionId;
141
+ const binding = { environmentId: 'smoke', agentId: 'agent', conversationId: 'room', provider: 'codex', nativeSessionId: threadId };
142
+ const publisher = createCanonAttachedSessionPublisher({ async sendProactiveMessage(conversationId, text, options) {
143
+ const payload = JSON.stringify({ text, options });
144
+ if (published.has(options.messageId)) assert.equal(published.get(options.messageId), payload);
145
+ published.set(options.messageId, payload);
146
+ return { messageId: options.messageId, conversationId };
147
+ } }, binding);
148
+ async function attach() {
149
+ const value = createAttachedNativeSession({ binding, adapter: await provider.open(threadId, bridge),
150
+ store: createFileAttachedSessionStore(join(directory, 'journal.json')), publisher,
151
+ onError: (error, operation) => errors.push(`${operation}: ${error.message}`),
152
+ });
153
+ await value.start();
154
+ return value;
155
+ }
156
+ controller = await attach();
157
+ assert.equal(modelCalls, 0, 'Creating/attaching an empty session must not inject a warmup turn');
158
+ assert.equal(published.size, 0);
159
+ askApproval = true;
160
+ const first = await controller.submit({ messageId: 'canon-first', text: 'First real input from Canon.' });
161
+ assert.equal(first.status, 'accepted');
162
+ await until(() => bridgeRequests.length === 1, 'Canon receives approval without any native client');
163
+ assert.equal(bridgeRequests[0].request.kind, 'approval');
164
+ bridgeRequests[0].resolve({ kind: 'approval', decision: 'deny' });
165
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 1; }, 'first Canon turn completion');
166
+ assert.equal(nativeResponses.length, 1);
167
+ assert.deepEqual(nativeResponses[0].result, { decision: 'decline' });
168
+
169
+ native = await connectNative();
170
+ const resumed = await native.rpc('thread/resume', { threadId, excludeTurns: true });
171
+ assert.equal(resumed.thread.id, threadId, 'Native client resumes the exact created session');
172
+ const local = await native.rpc('turn/start', { threadId, input: [{ type: 'text', text: 'Input from the native surface.' }] });
173
+ await until(() => native.events.some((event) => event.method === 'turn/completed' && event.params.turn.id === local.turn.id), 'native turn');
174
+ await controller.reconcile();
175
+ assert.equal(published.size, 3, 'Native user and assistant text should mirror once');
176
+
177
+ askApproval = true;
178
+ const second = await controller.submit({ messageId: 'canon-second', text: 'Native client will answer this approval.' });
179
+ await until(() => bridgeRequests.length === 2 && native.requests.some((request) => request.params.turnId === second.turnId), 'shared approval delivery');
180
+ const approval = native.requests.find((request) => request.params.turnId === second.turnId);
181
+ native.socket.send(JSON.stringify({ id: approval.id, result: { decision: 'decline' } }));
182
+ await until(() => bridgeRequests[1].signal.aborted, 'native answer cancels Canon interaction');
183
+ bridgeRequests[1].resolve({ kind: 'approval', decision: 'allow' });
184
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 4; }, 'native approval resolution');
185
+ assert.equal(nativeResponses.length, 1, 'A late Canon answer must not override the native decision');
186
+
187
+ askApproval = true;
188
+ await controller.submit({ messageId: 'canon-third', text: 'Recover this pending approval after the Canon host restarts.' });
189
+ await until(() => bridgeRequests.length === 3, 'approval before host restart');
190
+ await controller.stop();
191
+ await provider.close();
192
+ assert.equal(bridgeRequests[2].signal.aborted, true);
193
+ provider = makeProvider();
194
+ controller = await attach();
195
+ await until(() => bridgeRequests.length === 4, 'pending native approval replay after reconnect');
196
+ assert.equal(bridgeRequests[3].request.nativeRequestId, bridgeRequests[2].request.nativeRequestId);
197
+ bridgeRequests[3].resolve({ kind: 'approval', decision: 'deny' });
198
+ await until(async () => { await controller.reconcile(); return controller.getState().activeTurnId === null && published.size === 5; }, 'recovered approval turn');
199
+ assert.equal(errors.length, 0, errors.join('\n'));
200
+ await controller.stop();
201
+ await provider.close();
202
+ assert.equal((await native.rpc('thread/read', { threadId, includeTurns: false })).thread.id, threadId);
203
+ console.log(JSON.stringify({ passed: true, emptySessionWithoutWarmup: true, canonApprovalWithoutNativeUi: true, nativeAndCanonInput: true, nativeAnswerCancelsCanon: true, pendingApprovalRecovery: true, publishedMessages: published.size, localMockModelCalls: modelCalls, localCatalogCalls: catalogCalls }));
204
+ } finally {
205
+ await controller?.stop().catch(() => {});
206
+ await provider?.close().catch(() => {});
207
+ if (threadId && server.exitCode === null) {
208
+ native ??= await connectNative().catch(() => null);
209
+ if (native) await native.rpc('thread/delete', { threadId }).catch((error) => console.error(`Could not delete disposable native thread: ${error.message}`));
210
+ }
211
+ native?.socket.terminate();
212
+ server.kill('SIGTERM');
213
+ await sleep(400);
214
+ if (server.exitCode === null) server.kill('SIGKILL');
215
+ model.closeAllConnections();
216
+ await new Promise((resolve) => model.close(resolve));
217
+ await rm(directory, { recursive: true, force: true });
218
+ }