@dharma-ai-labs/agent-fabric 0.2.106 → 0.2.108

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 (46) hide show
  1. package/README.md +15 -0
  2. package/dist/codexBoundSession.d.ts +42 -0
  3. package/dist/codexBoundSession.d.ts.map +1 -0
  4. package/dist/codexBoundSession.js +106 -0
  5. package/dist/codexBoundSession.js.map +1 -0
  6. package/dist/codexBoundSession.test.d.ts +2 -0
  7. package/dist/codexBoundSession.test.d.ts.map +1 -0
  8. package/dist/codexBoundSession.test.js +619 -0
  9. package/dist/codexBoundSession.test.js.map +1 -0
  10. package/dist/codexInboxSession.d.ts +75 -0
  11. package/dist/codexInboxSession.d.ts.map +1 -0
  12. package/dist/codexInboxSession.js +157 -0
  13. package/dist/codexInboxSession.js.map +1 -0
  14. package/dist/cooperativeInboxSession.d.ts +89 -0
  15. package/dist/cooperativeInboxSession.d.ts.map +1 -0
  16. package/dist/cooperativeInboxSession.js +195 -0
  17. package/dist/cooperativeInboxSession.js.map +1 -0
  18. package/dist/cooperativeInboxSession.test.d.ts +2 -0
  19. package/dist/cooperativeInboxSession.test.d.ts.map +1 -0
  20. package/dist/cooperativeInboxSession.test.js +354 -0
  21. package/dist/cooperativeInboxSession.test.js.map +1 -0
  22. package/dist/demoPackage.d.ts +1 -0
  23. package/dist/demoPackage.d.ts.map +1 -1
  24. package/dist/demoPackage.js +17 -11
  25. package/dist/demoPackage.js.map +1 -1
  26. package/dist/demoPackage.test.js +58 -0
  27. package/dist/demoPackage.test.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/index.test.js +2 -2
  33. package/dist/providerSessionChannel.d.ts +129 -0
  34. package/dist/providerSessionChannel.d.ts.map +1 -0
  35. package/dist/providerSessionChannel.js +249 -0
  36. package/dist/providerSessionChannel.js.map +1 -0
  37. package/dist/providerSessionChannel.test.d.ts +2 -0
  38. package/dist/providerSessionChannel.test.d.ts.map +1 -0
  39. package/dist/providerSessionChannel.test.js +246 -0
  40. package/dist/providerSessionChannel.test.js.map +1 -0
  41. package/dist/providerSessionReplyRecovery.d.ts +33 -0
  42. package/dist/providerSessionReplyRecovery.d.ts.map +1 -0
  43. package/dist/providerSessionReplyRecovery.js +58 -0
  44. package/dist/providerSessionReplyRecovery.js.map +1 -0
  45. package/dist/schemas/provider-session-completion.schema.json +22 -0
  46. package/package.json +7 -7
@@ -0,0 +1,619 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import test from 'node:test';
7
+ import { canonicalize, signCanonicalObject } from '@dharma-ai-labs/agent-fabric-contracts';
8
+ import { LocalVault } from '@dharma-ai-labs/agent-fabric-local-vault';
9
+ import { openCodexBoundSession, runCodexBoundSessionQuestion } from './codexBoundSession.js';
10
+ import { openCodexInboxSession } from './codexInboxSession.js';
11
+ import { reconcileProviderSessionReply } from './providerSessionReplyRecovery.js';
12
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
13
+ const ids = {
14
+ repositoryBindingId: '40000000-0000-4000-8000-000000000001',
15
+ workspaceId: '40000000-0000-4000-8000-000000000002',
16
+ endpointId: '40000000-0000-4000-8000-000000000003',
17
+ membershipId: '40000000-0000-4000-8000-000000000004',
18
+ deviceId: '40000000-0000-4000-8000-000000000005',
19
+ bindingId: '40000000-0000-4000-8000-000000000006',
20
+ threadId: '40000000-0000-4000-8000-000000000007',
21
+ };
22
+ function signedQuestion(binding, now, questionId = '40000000-0000-4000-8000-000000000010') {
23
+ const unsigned = {
24
+ schema: 'dharma.session-question/v1', questionId,
25
+ taskId: '40000000-0000-4000-8000-000000000011',
26
+ organizationId: binding.organizationId, repositoryBindingId: binding.repositoryBindingId,
27
+ source: { workspaceId: '40000000-0000-4000-8000-000000000012',
28
+ endpointId: '40000000-0000-4000-8000-000000000013',
29
+ membershipId: '40000000-0000-4000-8000-000000000014',
30
+ deviceId: '40000000-0000-4000-8000-000000000015' },
31
+ target: { workspaceId: binding.workspaceId, endpointId: binding.endpointId,
32
+ membershipId: binding.membershipId, deviceId: binding.deviceId,
33
+ bindingId: binding.bindingId, provider: binding.provider },
34
+ category: 'code-review', question: 'Which catalog applies?',
35
+ authority: { mode: 'read_only', readPaths: ['.'], network: 'deny', maximumProviderCostCents: 25 },
36
+ createdAt: new Date(now.getTime() - 1_000).toISOString(),
37
+ expiresAt: new Date(now.getTime() + 60_000).toISOString(),
38
+ nonce: '40000000-0000-4000-8000-000000000016', signerKeyVersion: 'test-v1',
39
+ };
40
+ return { ...unsigned, signature: signCanonicalObject(unsigned, privateKey) };
41
+ }
42
+ function fakeTransport(binding, options = {}) {
43
+ const calls = [];
44
+ const listeners = new Set();
45
+ let closed = false;
46
+ const transport = {
47
+ onNotification(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
48
+ async close() {
49
+ calls.push('close');
50
+ if (options.closeFails)
51
+ throw new Error('process_still_running');
52
+ closed = true;
53
+ },
54
+ async request(method) {
55
+ calls.push(method);
56
+ if (method === 'permissionProfile/list')
57
+ return { data: [{ id: 'dharma_bridge', allowed: true }] };
58
+ if (method === 'config/read')
59
+ return { config: { permissions: { dharma_bridge: {
60
+ extends: null, workspace_roots: null,
61
+ filesystem: { ':minimal': 'read', ':workspace_roots': { '.': 'read' } },
62
+ network: { enabled: false },
63
+ } } } };
64
+ if (method === 'thread/read')
65
+ return { thread: { id: binding.sessionId,
66
+ cwd: binding.workspaceRoot, status: { type: 'idle' } } };
67
+ if (method === 'thread/resume')
68
+ return { thread: { id: binding.sessionId,
69
+ cwd: binding.workspaceRoot, status: { type: 'idle' } } };
70
+ if (method === 'turn/start') {
71
+ queueMicrotask(() => {
72
+ for (const listener of listeners)
73
+ listener({ method: 'turn/completed', params: {
74
+ threadId: binding.sessionId, turn: { id: 'turn-1',
75
+ status: options.failedTurn ? 'failed' : 'completed',
76
+ items: [{ type: 'agentMessage', phase: 'final_answer', text: 'Catalog generation 13.' }],
77
+ },
78
+ } });
79
+ });
80
+ return { turn: { id: 'turn-1' } };
81
+ }
82
+ throw new Error(`unexpected:${method}`);
83
+ },
84
+ };
85
+ return { transport, calls, isClosed: () => closed };
86
+ }
87
+ async function fixture() {
88
+ const root = await mkdtemp(join(tmpdir(), 'dharma-bound-session-'));
89
+ const now = new Date();
90
+ const binding = {
91
+ schema: 'dharma.local-provider-session-binding/v1', owner: 'dharma_bridge',
92
+ organizationId: 'org_test', repositoryBindingId: ids.repositoryBindingId,
93
+ workspaceId: ids.workspaceId, endpointId: ids.endpointId,
94
+ membershipId: ids.membershipId, deviceId: ids.deviceId,
95
+ bindingId: ids.bindingId, provider: 'codex', sessionId: ids.threadId,
96
+ workspaceRoot: resolve(root, 'repo'), createdAt: now.toISOString(),
97
+ expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
98
+ maximumProviderCostCents: 25,
99
+ };
100
+ const identity = {
101
+ organizationId: binding.organizationId, repositoryBindingId: binding.repositoryBindingId,
102
+ workspaceId: binding.workspaceId, endpointId: binding.endpointId,
103
+ membershipId: binding.membershipId, deviceId: binding.deviceId, provider: binding.provider,
104
+ };
105
+ const masterKey = randomBytes(32);
106
+ const vault = await LocalVault.open({ root, masterKey });
107
+ vault.saveProviderSessionBinding(binding);
108
+ const used = new Set();
109
+ const verifier = {
110
+ resolvePublicKey: (version) => version === 'test-v1' ? publicKey : null,
111
+ consume: async (id) => { if (used.has(id))
112
+ return false; used.add(id); return true; },
113
+ };
114
+ return { vault, binding, identity, verifier, now, root, masterKey, budget: { reserve: async () => true } };
115
+ }
116
+ test('default inbox startup recovers the server revision with its retained local owner and no provider turn', async () => {
117
+ const f = await fixture(), remote = fakeTransport(f.binding), actions = [];
118
+ let reserves = 0;
119
+ const transport = { async signedPost(_route, input) {
120
+ const body = input;
121
+ actions.push(String(body.action));
122
+ if (body.action === 'attach')
123
+ assert.equal(body.expectedRevision, 12);
124
+ return { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId, registration: {
125
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
126
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
127
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
128
+ revision: body.action === 'inspect' ? 12 : 13, state: 'attached',
129
+ leaseUntil: new Date(Date.now() + (body.action === 'inspect' ? -60_000 : 60_000)).toISOString(), replay: false
130
+ } };
131
+ } };
132
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId,
133
+ channelTransport: transport, authorizeContent: async () => true,
134
+ budget: { reserve: async () => { reserves += 1; return true; } }, openTransport: async () => remote.transport });
135
+ try {
136
+ assert.deepEqual(actions, ['inspect', 'attach']);
137
+ assert.equal(reserves, 0);
138
+ assert.equal(remote.calls.includes('turn/start'), false);
139
+ }
140
+ finally {
141
+ await inbox.close();
142
+ f.vault.close();
143
+ }
144
+ });
145
+ test('reopened inbox reconciles retained answers without executing or reserving another provider turn', async (t) => {
146
+ for (const remoteState of ['accepted', 'answered', 'conflicting', 'expired', 'lost_reply', 'denied_policy']) {
147
+ await t.test(remoteState, async () => {
148
+ const f = await fixture(), remote = fakeTransport(f.binding);
149
+ const question = signedQuestion(f.binding, f.now);
150
+ const answer = 'Catalog generation 13.';
151
+ const completion = {
152
+ schema: 'dharma.provider-session-completion/v1', organizationId: f.binding.organizationId,
153
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
154
+ deviceId: f.binding.deviceId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
155
+ questionId: question.questionId, taskId: question.taskId, bindingId: f.binding.bindingId,
156
+ targetEndpointId: f.binding.endpointId, answer, answerHash: `sha256:${createHash('sha256').update(answer).digest('hex')}`,
157
+ };
158
+ const hash = await f.vault.stageProviderSessionReply(f.binding.bindingId, f.identity, question.questionId, Buffer.from(canonicalize(completion)));
159
+ f.vault.close();
160
+ f.vault = await LocalVault.open({ root: f.root, masterKey: f.masterKey });
161
+ let replies = 0, reads = 0, reserves = 0;
162
+ const transport = { async signedPost(route, input) {
163
+ const body = input;
164
+ const envelope = { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId };
165
+ if (route.endsWith('provider-sessions'))
166
+ return { ...envelope, registration: {
167
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
168
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
169
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
170
+ revision: Number(body.expectedRevision) + 1, state: body.action === 'detach' ? 'detached' : 'attached',
171
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false
172
+ } };
173
+ if (body.action === 'reply') {
174
+ replies += 1;
175
+ assert.equal(body.answer, answer);
176
+ if (remoteState === 'lost_reply')
177
+ throw new Error('synthetic lost acknowledgement');
178
+ return { ...envelope, result: { questionId: question.questionId, taskId: question.taskId,
179
+ targetBindingId: f.binding.bindingId, state: 'answered', replay: false } };
180
+ }
181
+ assert.equal(body.action, 'read');
182
+ reads += 1;
183
+ const state = remoteState === 'expired' ? 'expired'
184
+ : ['accepted', 'lost_reply', 'denied_policy'].includes(remoteState) && replies === 0 ? 'accepted' : 'answered';
185
+ return { ...envelope, result: { questionId: question.questionId, taskId: question.taskId,
186
+ targetBindingId: f.binding.bindingId, state, failureCode: null,
187
+ answer: state === 'answered' ? remoteState === 'conflicting' ? 'Different answer.' : answer : null,
188
+ replyReceiptHash: state === 'answered' ? `sha256:${'a'.repeat(64)}` : null } };
189
+ } };
190
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
191
+ channelTransport: transport, authorizeContent: async () => remoteState !== 'denied_policy',
192
+ budget: { reserve: async () => { reserves += 1; return true; } }, openTransport: async () => remote.transport });
193
+ try {
194
+ const result = await inbox.runNext();
195
+ const remainsPending = ['conflicting', 'expired', 'lost_reply', 'denied_policy'].includes(remoteState);
196
+ assert.equal(result.state, remainsPending ? 'reply_pending' : 'reply_reconciled');
197
+ assert.equal(replies, ['accepted', 'lost_reply'].includes(remoteState) ? 1 : 0);
198
+ assert.equal(reads, remoteState === 'accepted' ? 2 : 1);
199
+ assert.equal(reserves, 0);
200
+ assert.equal(remote.calls.includes('turn/start'), false);
201
+ assert.equal(f.vault.listProviderSessionReplies(f.binding.bindingId, f.identity).length, remainsPending ? 1 : 0);
202
+ assert.deepEqual(await f.vault.getBlob(hash), Buffer.from(canonicalize(completion)));
203
+ }
204
+ finally {
205
+ await inbox.close();
206
+ f.vault.close();
207
+ }
208
+ });
209
+ }
210
+ });
211
+ test('signed inbox dispatch retains the selected thread and publishes answers without reopening a worker', async (t) => {
212
+ t.mock.timers.enable({ apis: ['setInterval'] });
213
+ const f = await fixture(), remote = fakeTransport(f.binding);
214
+ let opens = 0, accepted = false, replyCalls = 0, failReply = false, heartbeats = 0, detaches = 0;
215
+ let offered = signedQuestion(f.binding, f.now);
216
+ const transport = { async signedPost(route, input) {
217
+ const body = input;
218
+ if (body.action === 'heartbeat')
219
+ heartbeats += 1;
220
+ if (body.action === 'detach')
221
+ detaches += 1;
222
+ if (route.endsWith('provider-sessions'))
223
+ return { ok: true, organizationId: f.binding.organizationId,
224
+ correlationId: ids.threadId, registration: { bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId,
225
+ endpointId: f.binding.endpointId, repositoryBindingId: f.binding.repositoryBindingId,
226
+ membershipId: f.binding.membershipId, deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
227
+ revision: Number(body.expectedRevision) + 1, state: body.action === 'detach' ? 'detached' : 'attached',
228
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false } };
229
+ if (body.action === 'inbox')
230
+ return { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId,
231
+ result: { offers: accepted ? [] : [offered] } };
232
+ if (body.action === 'accept')
233
+ accepted = true;
234
+ if (body.action === 'reply') {
235
+ replyCalls += 1;
236
+ if (failReply)
237
+ throw new Error('synthetic-lost-reply');
238
+ }
239
+ return { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId,
240
+ result: { questionId: offered.questionId, taskId: offered.taskId, targetBindingId: f.binding.bindingId,
241
+ state: body.action === 'accept' ? 'accepted' : body.outcome, replay: false } };
242
+ } };
243
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
244
+ channelTransport: transport, authorizeContent: async () => true,
245
+ openTransport: async () => { opens += 1; return remote.transport; } });
246
+ try {
247
+ assert.equal((await inbox.runNext()).state, 'answered');
248
+ assert.equal((await inbox.runNext()).state, 'idle');
249
+ assert.equal(opens, 1);
250
+ assert.equal(replyCalls, 1);
251
+ assert.equal(remote.calls.filter(method => method === 'turn/start').length, 1);
252
+ assert.equal(remote.isClosed(), false);
253
+ t.mock.timers.tick(20_000);
254
+ await new Promise(resolve => setImmediate(resolve));
255
+ assert.equal(heartbeats, 1);
256
+ assert.equal(opens, 1);
257
+ offered = signedQuestion(f.binding, f.now, '40000000-0000-4000-8000-000000000020');
258
+ accepted = false;
259
+ failReply = true;
260
+ const pending = await inbox.runNext();
261
+ assert.equal(pending.state, 'reply_pending');
262
+ assert.ok('providerShutdownConfirmed' in pending && pending.providerShutdownConfirmed);
263
+ assert.ok('reasonCode' in pending && pending.reasonCode === 'delivery_unconfirmed');
264
+ assert.ok('completionHash' in pending);
265
+ const checkpoint = JSON.parse((await f.vault.getBlob(pending.completionHash)).toString('utf8'));
266
+ assert.equal(checkpoint.questionId, offered.questionId);
267
+ assert.equal(checkpoint.bindingId, f.binding.bindingId);
268
+ assert.equal(checkpoint.answer, 'Catalog generation 13.');
269
+ assert.equal(checkpoint.organizationId, f.binding.organizationId);
270
+ assert.equal(opens, 1);
271
+ assert.equal(replyCalls, 2);
272
+ await assert.rejects(inbox.runNext(), /codex_inbox_session_unavailable/);
273
+ assert.equal(remote.calls.filter(method => method === 'turn/start').length, 2);
274
+ t.mock.timers.tick(40_000);
275
+ await new Promise(resolve => setImmediate(resolve));
276
+ assert.equal(heartbeats, 1);
277
+ }
278
+ finally {
279
+ await inbox.close();
280
+ f.vault.close();
281
+ }
282
+ assert.equal(remote.isClosed(), true);
283
+ assert.equal(detaches, 0);
284
+ });
285
+ test('completion recovery rejects corrupted contracts and foreign scope before requesting or executing work', async (t) => {
286
+ for (const mutation of ['foreign', 'hash', 'extra', 'revoked']) {
287
+ await t.test(mutation, async () => {
288
+ const f = await fixture(), question = signedQuestion(f.binding, f.now), answer = 'Saved answer.';
289
+ const completion = { schema: 'dharma.provider-session-completion/v1', organizationId: f.binding.organizationId,
290
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
291
+ deviceId: f.binding.deviceId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
292
+ questionId: question.questionId, taskId: question.taskId, bindingId: f.binding.bindingId,
293
+ targetEndpointId: f.binding.endpointId, answer, answerHash: `sha256:${createHash('sha256').update(answer).digest('hex')}`,
294
+ ...(mutation === 'extra' ? { providerThread: 'must-not-be-shared' } : {}) };
295
+ if (mutation === 'foreign')
296
+ completion.organizationId = 'org_foreign';
297
+ if (mutation === 'hash')
298
+ completion.answerHash = `sha256:${'0'.repeat(64)}`;
299
+ const hash = await f.vault.stageProviderSessionReply(f.binding.bindingId, f.identity, question.questionId, Buffer.from(canonicalize(completion)));
300
+ if (mutation === 'revoked')
301
+ f.vault.revokeProviderSessionBinding(f.binding.bindingId, f.identity);
302
+ let requests = 0;
303
+ try {
304
+ await assert.rejects(reconcileProviderSessionReply({ vault: f.vault, bindingId: f.binding.bindingId,
305
+ identity: f.identity, channel: {
306
+ read: async () => { requests += 1; throw new Error('unexpected read'); },
307
+ reply: async () => { requests += 1; throw new Error('unexpected reply'); },
308
+ } }), /provider_session_(?:completion_(?:invalid|scope_mismatch)|binding_unavailable)/);
309
+ assert.equal(requests, 0);
310
+ assert.ok((await f.vault.getBlob(hash)).length > 0);
311
+ }
312
+ finally {
313
+ f.vault.close();
314
+ }
315
+ });
316
+ }
317
+ });
318
+ test('signed inbox budget denial does not accept, execute, or replace the selected session', async () => {
319
+ const f = await fixture(), remote = fakeTransport(f.binding);
320
+ let accepts = 0;
321
+ const transport = { async signedPost(route, input) {
322
+ const body = input;
323
+ if (route.endsWith('provider-sessions'))
324
+ return { ok: true, organizationId: f.binding.organizationId,
325
+ correlationId: ids.threadId, registration: { bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId,
326
+ endpointId: f.binding.endpointId, repositoryBindingId: f.binding.repositoryBindingId,
327
+ membershipId: f.binding.membershipId, deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
328
+ revision: Number(body.expectedRevision) + 1, state: body.action === 'detach' ? 'detached' : 'attached',
329
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false } };
330
+ if (body.action === 'accept')
331
+ accepts += 1;
332
+ return { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId,
333
+ result: { offers: [signedQuestion(f.binding, f.now)] } };
334
+ } };
335
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
336
+ budget: { reserve: async () => false }, channelTransport: transport, authorizeContent: async () => true,
337
+ openTransport: async () => remote.transport });
338
+ try {
339
+ assert.equal((await inbox.runNext()).state, 'budget_denied');
340
+ assert.equal((await inbox.runNext()).state, 'budget_denied');
341
+ assert.equal(accepts, 0);
342
+ assert.equal(remote.calls.includes('turn/start'), false);
343
+ assert.equal(remote.isClosed(), false);
344
+ const retired = await inbox.retire();
345
+ assert.equal(retired.serverDetached, true);
346
+ assert.equal(retired.providerClosed, true);
347
+ }
348
+ finally {
349
+ await inbox.close();
350
+ f.vault.close();
351
+ }
352
+ });
353
+ test('bound dispatch resolves one encrypted binding and releases a completed turn', async () => {
354
+ const f = await fixture();
355
+ const remote = fakeTransport(f.binding);
356
+ try {
357
+ const result = await runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
358
+ openTransport: async () => remote.transport, question: signedQuestion(f.binding, f.now) });
359
+ assert.equal(result.answer, 'Catalog generation 13.');
360
+ assert.equal(result.bindingId, f.binding.bindingId);
361
+ assert.equal(remote.calls.includes('turn/start'), true);
362
+ assert.equal(remote.isClosed(), true);
363
+ const next = f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity);
364
+ assert.ok(next);
365
+ next.release();
366
+ const second = fakeTransport(f.binding);
367
+ const followup = await runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
368
+ openTransport: async () => second.transport,
369
+ question: signedQuestion(f.binding, f.now, '40000000-0000-4000-8000-000000000020') });
370
+ assert.equal(followup.bindingId, result.bindingId);
371
+ assert.equal(second.isClosed(), true);
372
+ }
373
+ finally {
374
+ f.vault.close();
375
+ }
376
+ });
377
+ test('bound dispatch rejects a foreign identity before touching the provider', async () => {
378
+ const f = await fixture();
379
+ const remote = fakeTransport(f.binding);
380
+ let opened = false;
381
+ try {
382
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
383
+ identity: { ...f.identity, organizationId: 'org_foreign' },
384
+ openTransport: async () => { opened = true; return remote.transport; },
385
+ question: signedQuestion(f.binding, f.now) }), /scope_mismatch/);
386
+ assert.equal(opened, false);
387
+ assert.deepEqual(remote.calls, []);
388
+ }
389
+ finally {
390
+ f.vault.close();
391
+ }
392
+ });
393
+ test('bound dispatch rejects an invalid signature before opening the provider', async () => {
394
+ const f = await fixture();
395
+ let opened = false;
396
+ try {
397
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
398
+ openTransport: async () => { opened = true; return fakeTransport(f.binding).transport; },
399
+ question: { ...signedQuestion(f.binding, f.now), question: 'Changed after signing' } }), /signature_invalid/);
400
+ assert.equal(opened, false);
401
+ const next = f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity);
402
+ assert.ok(next);
403
+ next.release();
404
+ }
405
+ finally {
406
+ f.vault.close();
407
+ }
408
+ });
409
+ test('bound dispatch releases ownership after a safely cleaned-up transport startup failure', async () => {
410
+ const f = await fixture();
411
+ try {
412
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
413
+ openTransport: async () => { throw new Error('transport_start_failed_after_cleanup'); },
414
+ question: signedQuestion(f.binding, f.now) }), /transport_start_failed_after_cleanup/);
415
+ const next = f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity);
416
+ assert.ok(next);
417
+ next.release();
418
+ }
419
+ finally {
420
+ f.vault.close();
421
+ }
422
+ });
423
+ test('bound dispatch closes a failed provider before releasing ownership', async () => {
424
+ const f = await fixture();
425
+ const remote = fakeTransport(f.binding, { failedTurn: true });
426
+ try {
427
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
428
+ openTransport: async () => remote.transport, question: signedQuestion(f.binding, f.now) }), /turn_failed/);
429
+ assert.equal(remote.isClosed(), true);
430
+ const next = f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity);
431
+ assert.ok(next);
432
+ next.release();
433
+ }
434
+ finally {
435
+ f.vault.close();
436
+ }
437
+ });
438
+ test('bound dispatch retains ownership if provider termination is unconfirmed', async () => {
439
+ const f = await fixture();
440
+ const remote = fakeTransport(f.binding, { failedTurn: true, closeFails: true });
441
+ try {
442
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
443
+ openTransport: async () => remote.transport, question: signedQuestion(f.binding, f.now) }), /turn_failed/);
444
+ assert.equal(f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity), null);
445
+ }
446
+ finally {
447
+ f.vault.close();
448
+ }
449
+ });
450
+ test('bound dispatch does not report success when provider shutdown is unconfirmed', async () => {
451
+ const f = await fixture();
452
+ const remote = fakeTransport(f.binding, { closeFails: true });
453
+ try {
454
+ await assert.rejects(runCodexBoundSessionQuestion({ ...f, bindingId: f.binding.bindingId,
455
+ openTransport: async () => remote.transport, question: signedQuestion(f.binding, f.now) }), /process_still_running/);
456
+ assert.equal(f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity), null);
457
+ }
458
+ finally {
459
+ f.vault.close();
460
+ }
461
+ });
462
+ test('live owner keeps the same loaded thread and lease across two questions', async () => {
463
+ const f = await fixture();
464
+ const remote = fakeTransport(f.binding);
465
+ let opens = 0;
466
+ try {
467
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
468
+ openTransport: async () => { opens++; return remote.transport; } });
469
+ const first = await owner.runQuestion({ question: signedQuestion(f.binding, f.now) });
470
+ assert.equal(remote.isClosed(), false);
471
+ assert.equal(f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity), null);
472
+ const second = await owner.runQuestion({
473
+ question: signedQuestion(f.binding, f.now, '40000000-0000-4000-8000-000000000020'),
474
+ });
475
+ assert.equal(second.bindingId, first.bindingId);
476
+ assert.equal(opens, 1);
477
+ assert.equal(remote.calls.filter(method => method === 'turn/start').length, 2);
478
+ assert.equal(remote.calls.includes('thread/resume'), false);
479
+ await owner.close();
480
+ await owner.close();
481
+ assert.equal(remote.calls.filter(method => method === 'close').length, 1);
482
+ const next = f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity);
483
+ assert.ok(next);
484
+ next.release();
485
+ await assert.rejects(owner.runQuestion({ question: signedQuestion(f.binding, f.now) }), /session_closed/);
486
+ }
487
+ finally {
488
+ f.vault.close();
489
+ }
490
+ });
491
+ test('live owner retains an empty session after budget denial without consuming the question', async () => {
492
+ const f = await fixture();
493
+ const remote = fakeTransport(f.binding);
494
+ let permitted = false;
495
+ try {
496
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
497
+ budget: { reserve: async () => permitted }, openTransport: async () => remote.transport });
498
+ const question = signedQuestion(f.binding, f.now);
499
+ await assert.rejects(owner.runQuestion({ question }), /budget_unavailable/);
500
+ assert.equal(remote.isClosed(), false);
501
+ assert.equal(remote.calls.includes('turn/start'), false);
502
+ permitted = true;
503
+ assert.equal((await owner.runQuestion({ question })).answer, 'Catalog generation 13.');
504
+ await owner.close();
505
+ }
506
+ finally {
507
+ f.vault.close();
508
+ }
509
+ });
510
+ test('live owner refuses overlapping questions without dispatching the second turn', async () => {
511
+ const f = await fixture();
512
+ const remote = fakeTransport(f.binding);
513
+ let allowBudget;
514
+ const budget = new Promise(resolveBudget => { allowBudget = resolveBudget; });
515
+ try {
516
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
517
+ budget: { reserve: async () => budget }, openTransport: async () => remote.transport });
518
+ const first = owner.runQuestion({ question: signedQuestion(f.binding, f.now) });
519
+ await assert.rejects(owner.runQuestion({
520
+ question: signedQuestion(f.binding, f.now, '40000000-0000-4000-8000-000000000020'),
521
+ }), /session_busy/);
522
+ allowBudget(true);
523
+ await first;
524
+ assert.equal(remote.calls.filter(method => method === 'turn/start').length, 1);
525
+ await owner.close();
526
+ }
527
+ finally {
528
+ f.vault.close();
529
+ }
530
+ });
531
+ test('live owner rejects replay and tampering without discarding a healthy conversation', async () => {
532
+ const f = await fixture();
533
+ const remote = fakeTransport(f.binding);
534
+ try {
535
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
536
+ openTransport: async () => remote.transport });
537
+ const question = signedQuestion(f.binding, f.now);
538
+ await owner.runQuestion({ question });
539
+ await assert.rejects(owner.runQuestion({ question }), /replay/);
540
+ await assert.rejects(owner.runQuestion({ question: { ...question, question: 'Tampered' } }), /signature_invalid/);
541
+ assert.equal(remote.isClosed(), false);
542
+ assert.equal(remote.calls.filter(method => method === 'turn/start').length, 1);
543
+ await owner.close();
544
+ }
545
+ finally {
546
+ f.vault.close();
547
+ }
548
+ });
549
+ test('live owner stops after local revocation before another provider turn', async () => {
550
+ const f = await fixture();
551
+ const remote = fakeTransport(f.binding);
552
+ try {
553
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
554
+ openTransport: async () => remote.transport });
555
+ f.vault.revokeProviderSessionBinding(f.binding.bindingId, f.identity);
556
+ await assert.rejects(owner.runQuestion({ question: signedQuestion(f.binding, f.now) }), /lease_unavailable/);
557
+ assert.equal(remote.isClosed(), true);
558
+ assert.equal(remote.calls.includes('turn/start'), false);
559
+ }
560
+ finally {
561
+ f.vault.close();
562
+ }
563
+ });
564
+ test('live owner closes a failed turn and retains fencing if shutdown is unconfirmed', async () => {
565
+ const f = await fixture();
566
+ const remote = fakeTransport(f.binding, { failedTurn: true, closeFails: true });
567
+ try {
568
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
569
+ openTransport: async () => remote.transport });
570
+ await assert.rejects(owner.runQuestion({ question: signedQuestion(f.binding, f.now) }), /turn_failed/);
571
+ assert.equal(f.vault.tryAcquireProviderSessionLease(f.binding.bindingId, f.identity), null);
572
+ await assert.rejects(owner.runQuestion({ question: signedQuestion(f.binding, f.now) }), /session_closed/);
573
+ await assert.rejects(owner.close(), /process_still_running/);
574
+ }
575
+ finally {
576
+ f.vault.close();
577
+ }
578
+ });
579
+ test('live owner refuses a foreign enrollment before opening the provider', async () => {
580
+ const f = await fixture();
581
+ let opened = false;
582
+ try {
583
+ await assert.rejects(openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
584
+ identity: { ...f.identity, deviceId: '40000000-0000-4000-8000-000000000099' },
585
+ openTransport: async () => { opened = true; return fakeTransport(f.binding).transport; } }), /scope_mismatch/);
586
+ assert.equal(opened, false);
587
+ }
588
+ finally {
589
+ f.vault.close();
590
+ }
591
+ });
592
+ test('closing a live owner during budget wait prevents consumption and a subsequent turn', async () => {
593
+ const f = await fixture();
594
+ const remote = fakeTransport(f.binding);
595
+ let waiting;
596
+ let releaseBudget;
597
+ const entered = new Promise(resolveEntered => { waiting = resolveEntered; });
598
+ const budget = new Promise(resolveBudget => { releaseBudget = resolveBudget; });
599
+ let consumed = false;
600
+ try {
601
+ const owner = await openCodexBoundSession({ ...f, bindingId: f.binding.bindingId,
602
+ verifier: { ...f.verifier, consume: async () => { consumed = true; return true; } },
603
+ budget: { reserve: async () => { waiting(); return budget; } },
604
+ openTransport: async () => remote.transport });
605
+ const first = owner.runQuestion({ question: signedQuestion(f.binding, f.now) });
606
+ const rejection = assert.rejects(first, /lease_unavailable/);
607
+ await entered;
608
+ await owner.close();
609
+ releaseBudget(true);
610
+ await rejection;
611
+ assert.equal(consumed, false);
612
+ assert.equal(remote.calls.includes('turn/start'), false);
613
+ assert.equal(remote.isClosed(), true);
614
+ }
615
+ finally {
616
+ f.vault.close();
617
+ }
618
+ });
619
+ //# sourceMappingURL=codexBoundSession.test.js.map