@dharma-ai-labs/agent-fabric 0.2.202 → 0.2.204

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.
@@ -67,6 +67,8 @@ On a **source-authorized** Linux Codex client, complete bootstrap also starts on
67
67
 
68
68
  For local coding requests, use `dharma sessions work --name <name> --workspace-id <workspace-id> --work-id <unique-work-id> --prompt "<bounded coding request>"`. This runs in the same retained conversation under the signed policy's approved writable folders. Each peer question uses a separate read-only turn profile with network disabled; it cannot inherit the previous local turn's write permissions. Work and questions are serialized, and signed package activation waits for the task boundary. Keep requests bounded by the session allowance; reserved costs remain liabilities until actual usage is reconciled.
69
69
 
70
+ A reserved peer cost is not proof that the peer executed. If transport fails before server acceptance, the named session can reuse that exact-cost peer reservation only for the still-queued signed question and a fresh server acceptance. It never clears the reservation, reuses local-work or legacy accounting, accepts a replayed acknowledgement as new execution authority, or automatically reruns an expired question or an uncertain already-accepted turn. Report an explicit unresolved result rather than repeatedly asking the same question or increasing its allowance.
71
+
70
72
  New named Codex conversations expose `dharma_peer_ask` and `dharma_peer_reply` tools during local coding turns. Supply the exact peer binding and task IDs, category and bounded question; inspect the returned state rather than treating `queued` as an answer. These tools delegate through the enrolled owner, not model-readable credentials or network access. They are unavailable in incoming read-only question turns, cannot approve devices or change policy, and stop on revocation or uncertain delivery. Obtain peer binding IDs from their attributed readiness/status receipts; role discovery alone does not prove a live conversation. Existing conversations require verification of retained tool availability after restart; do not create a replacement enrollment to hide a failure.
71
73
 
72
74
  For bounded named questions and answers in local-analysis mode, current enrolled workspace/repository source authorization is checked independently of automatic-learning consent. The current whole-repository peer sandbox requires whole-repository source approval; partial paths, revoked/expired/unavailable policy, metadata-only mode, excluded paths and secret-bearing text stay blocked. Do not enable organization-wide native-history ingestion to work around a peer failure. Peer messages do not become accepted learning observations. The task ID is local correlation, not execution authority; the signed read-only offer, exact target/session, expiry, owner and budget still govern execution.
package/README.md CHANGED
@@ -336,6 +336,14 @@ they do not establish full context, the executed model, or actual skill use, and
336
336
  local coding work. Queue failures preserve the actual coding result; a failed or
337
337
  interrupted provider turn retains its reservation and is not replayed on restart.
338
338
 
339
+ For named-session peer questions, a transport failure before server acceptance
340
+ does not require clearing the reserved cost. The same exact-cost peer reservation
341
+ can be reused only while its signed offer remains queued and the server confirms
342
+ a fresh acceptance. A replayed acceptance cannot authorize another provider turn.
343
+ Peer reservations have a separate internal namespace; local-work and legacy
344
+ reservations remain distinct liabilities and retain their replay guards. An
345
+ expired question or an uncertain already-accepted turn is not automatically rerun.
346
+
339
347
  ## Manual enrollment
340
348
 
341
349
  Manual browser-confirmed enrollment remains available when no one-time grant is
@@ -9,6 +9,7 @@ interface CodexBoundSessionInput {
9
9
  openTransport(): Promise<CodexStdioTransport>;
10
10
  verifier: SessionQuestionVerifier;
11
11
  budget: CodexSessionBudget;
12
+ questionBudget?: CodexSessionBudget;
12
13
  localWriteRoots?: string[];
13
14
  additionalFilesystemRules?: Readonly<Record<string, 'read' | 'deny'>>;
14
15
  localToolHandler?: CodexToolHandler;
@@ -37,6 +38,7 @@ export declare function openCodexBoundSession(input: CodexBoundSessionInput): Pr
37
38
  question: unknown;
38
39
  now?: Date;
39
40
  timeoutMs?: number;
41
+ repositoryContext?: string;
40
42
  }): Promise<{
41
43
  answer: string;
42
44
  answerHash: string;
@@ -52,6 +54,7 @@ export declare function openCodexBoundSession(input: CodexBoundSessionInput): Pr
52
54
  }>;
53
55
  export declare function runCodexBoundSessionQuestion(input: CodexBoundSessionInput & {
54
56
  question: unknown;
57
+ repositoryContext?: string;
55
58
  now?: Date;
56
59
  timeoutMs?: number;
57
60
  }): Promise<{
@@ -1 +1 @@
1
- {"version":3,"file":"codexBoundSession.d.ts","sourceRoot":"","sources":["../src/codexBoundSession.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoC,KAAK,uBAAuB,EAAE,MAAM,wCAAwC,CAAC;AACxH,OAAO,KAAK,EAAE,4BAA4B,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC3H,OAAO,EAA8E,KAAK,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,2EAA2E,CAAC;AACnP,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6EAA6E,CAAC;AAEvH,UAAU,sBAAsB;IAC9B,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAAC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,4BAA4B,CAAC;IAEvC,aAAa,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC9C,QAAQ,EAAE,uBAAuB,CAAC;IAClC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,yBAAyB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;IACtE,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB;;;qBA2C9C;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,wBAAwB,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAC5G,cAAc,CAAC,EAAE,qBAAqB,CAAA;KAAE;;;;;;;;;;yBAiBf;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,IAAI,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;;;;;;GAkCnF;AAED,wBAAsB,4BAA4B,CAAC,KAAK,EAAE,sBAAsB,GAAG;IACjF,QAAQ,EAAE,OAAO,CAAC;IAClB,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;;;;;;;;;GAmBA"}
1
+ {"version":3,"file":"codexBoundSession.d.ts","sourceRoot":"","sources":["../src/codexBoundSession.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoC,KAAK,uBAAuB,EAAE,MAAM,wCAAwC,CAAC;AACxH,OAAO,KAAK,EAAE,4BAA4B,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC3H,OAAO,EAA8E,KAAK,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,2EAA2E,CAAC;AACnP,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6EAA6E,CAAC;AAEvH,UAAU,sBAAsB;IAC9B,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAAC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,4BAA4B,CAAC;IAEvC,aAAa,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAC9C,QAAQ,EAAE,uBAAuB,CAAC;IAClC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,cAAc,CAAC,EAAE,kBAAkB,CAAC;IACpC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,yBAAyB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC;IACtE,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB;;;qBA2C9C;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,wBAAwB,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAC5G,cAAc,CAAC,EAAE,qBAAqB,CAAA;KAAE;;;;;;;;;;yBAiBf;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,IAAI,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;;;;;;GAmC/G;AAED,wBAAsB,4BAA4B,CAAC,KAAK,EAAE,sBAAsB,GAAG;IACjF,QAAQ,EAAE,OAAO,CAAC;IAClB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;;;;;;;;;;;GAmBA"}
@@ -111,8 +111,9 @@ export async function openCodexBoundSession(input) {
111
111
  try {
112
112
  const result = await runCodexBridgeQuestion({
113
113
  transport, binding: codexBinding, question: request.question,
114
- verifier: input.verifier, exclusiveLease: lease, budget: input.budget,
114
+ verifier: input.verifier, exclusiveLease: lease, budget: input.questionBudget ?? input.budget,
115
115
  now: request.now, timeoutMs: request.timeoutMs,
116
+ repositoryContext: request.repositoryContext,
116
117
  additionalFilesystemRules: input.additionalFilesystemRules,
117
118
  });
118
119
  if (closing)
@@ -1 +1 @@
1
- {"version":3,"file":"codexBoundSession.js","sourceRoot":"","sources":["../src/codexBoundSession.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"codexBoundSession.js","sourceRoot":"","sources":["../src/codexBoundSession.ts"],"names":[],"mappings":"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"}
@@ -3,6 +3,7 @@ import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto';
3
3
  import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
+ import { createServer } from 'node:http';
6
7
  import test, { describe } from 'node:test';
7
8
  import { canonicalize, signCanonicalObject } from '@dharma-ai-labs/agent-fabric-contracts';
8
9
  import { LocalVault } from '@dharma-ai-labs/agent-fabric-local-vault';
@@ -10,6 +11,7 @@ import { openCodexBoundSession, runCodexBoundSessionQuestion } from './codexBoun
10
11
  import { openCodexInboxSession } from './codexInboxSession.js';
11
12
  import { reconcileProviderSessionReply } from './providerSessionReplyRecovery.js';
12
13
  import { NamedPeerContentAuthorizationError } from './namedPeerContentAuthorization.js';
14
+ import { NamedSessionBudget } from './namedSessionBudget.js';
13
15
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
14
16
  const ids = {
15
17
  repositoryBindingId: '40000000-0000-4000-8000-000000000001',
@@ -216,6 +218,90 @@ describe('qualified Linux retained Codex consumer', { skip: process.platform !==
216
218
  f.vault.close();
217
219
  }
218
220
  });
221
+ for (const scenario of ['current', 'denied', 'forged', 'expired'])
222
+ test('peer context is loaded lazily per offered question before acceptance: '
223
+ + scenario, async () => {
224
+ const deniedContext = scenario === 'denied';
225
+ const f = await fixture(), remote = fakeTransport(f.binding);
226
+ let offered = false, accepted = false, loads = 0, reserves = 0;
227
+ let q = signedQuestion(f.binding, f.now), context = 'CURRENT_SIGNED_V8';
228
+ if (scenario === 'forged')
229
+ q = { ...q, signature: 'invalid-fixture-signature' };
230
+ if (scenario === 'expired') {
231
+ const { signature: _signature, ...unsigned } = q;
232
+ const expired = { ...unsigned, expiresAt: new Date(f.now.getTime() - 1).toISOString() };
233
+ q = { ...expired, signature: signCanonicalObject(expired, privateKey) };
234
+ }
235
+ const prompts = [];
236
+ const request = remote.transport.request.bind(remote.transport);
237
+ remote.transport.request = async (method, params) => {
238
+ if (method === 'turn/start')
239
+ prompts.push(JSON.stringify(params.input));
240
+ return request(method, params);
241
+ };
242
+ const channelTransport = { async signedPost(route, input) {
243
+ const body = input;
244
+ const envelope = { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId };
245
+ if (route.endsWith('provider-sessions'))
246
+ return { ...envelope, registration: {
247
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
248
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
249
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned', revision: 1, state: 'attached',
250
+ leaseUntil: new Date(Date.now() + 60000).toISOString(), replay: false
251
+ } };
252
+ if (body.action === 'inbox')
253
+ return { ...envelope, result: { offers: offered && !accepted ? [q] : [] } };
254
+ if (body.action === 'accept')
255
+ accepted = true;
256
+ return { ...envelope, result: { questionId: q.questionId, taskId: q.taskId,
257
+ targetBindingId: f.binding.bindingId, state: body.action === 'reply' ? 'answered' : 'accepted', replay: false } };
258
+ } };
259
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
260
+ channelTransport, authorizeContent: async () => true,
261
+ budget: { reserve: async () => { reserves++; return true; } }, openTransport: async () => remote.transport });
262
+ const options = { loadRepositoryContext: async () => {
263
+ assert.equal(accepted, false);
264
+ loads++;
265
+ if (deniedContext)
266
+ throw new Error('synthetic_context_denied');
267
+ return context;
268
+ } };
269
+ try {
270
+ assert.equal((await Reflect.apply(inbox.runNext, inbox, [options])).state, 'idle');
271
+ assert.equal(loads, 0);
272
+ offered = true;
273
+ if (scenario === 'forged' || scenario === 'expired') {
274
+ await assert.rejects(Reflect.apply(inbox.runNext, inbox, [options]), /provider_session_channel_response/);
275
+ assert.equal(loads, 0);
276
+ assert.equal(reserves, 0);
277
+ assert.equal(accepted, false);
278
+ assert.equal(prompts.length, 0);
279
+ }
280
+ else if (deniedContext) {
281
+ await assert.rejects(Reflect.apply(inbox.runNext, inbox, [options]), /synthetic_context_denied/);
282
+ assert.equal(loads, 1);
283
+ assert.equal(reserves, 0);
284
+ assert.equal(accepted, false);
285
+ assert.equal(prompts.length, 0);
286
+ }
287
+ else {
288
+ assert.equal((await Reflect.apply(inbox.runNext, inbox, [options])).state, 'answered');
289
+ assert.equal(loads, 1);
290
+ assert.match(prompts[0], /CURRENT_SIGNED_V8/);
291
+ context = 'CURRENT_SIGNED_V9';
292
+ accepted = false;
293
+ q = signedQuestion(f.binding, f.now, '40000000-0000-4000-8000-000000000020');
294
+ assert.equal((await Reflect.apply(inbox.runNext, inbox, [options])).state, 'answered');
295
+ assert.equal(loads, 2);
296
+ assert.match(prompts[1], /CURRENT_SIGNED_V9/);
297
+ assert.doesNotMatch(prompts[1], /CURRENT_SIGNED_V8/);
298
+ }
299
+ }
300
+ finally {
301
+ await inbox.close();
302
+ f.vault.close();
303
+ }
304
+ });
219
305
  test('default inbox startup recovers the server revision with its retained local owner and no provider turn', async () => {
220
306
  const f = await fixture(), remote = fakeTransport(f.binding), actions = [];
221
307
  let reserves = 0;
@@ -684,6 +770,182 @@ describe('qualified Linux retained Codex consumer', { skip: process.platform !==
684
770
  f.vault.close();
685
771
  }
686
772
  });
773
+ test('an unaccepted question recovers its existing reservation only through a fresh acceptance', async () => {
774
+ const f = await fixture(), question = signedQuestion(f.binding, f.now);
775
+ const path = join(f.root, 'question-budget.sqlite');
776
+ let budget = new NamedSessionBudget(path, 'question-recovery', 50);
777
+ let accepts = 0, accepted = false;
778
+ const remotes = [];
779
+ const channelTransport = { async signedPost(route, input) {
780
+ const body = input;
781
+ const envelope = { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId };
782
+ if (route.endsWith('provider-sessions'))
783
+ return { ...envelope, registration: {
784
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
785
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
786
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
787
+ revision: Number(body.expectedRevision) + 1, state: 'attached',
788
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false
789
+ } };
790
+ if (body.action === 'inbox')
791
+ return { ...envelope, result: { offers: accepted ? [] : [question] } };
792
+ if (body.action === 'accept') {
793
+ accepts++;
794
+ if (accepts === 1)
795
+ throw new Error('private-transport-error-must-not-be-exposed');
796
+ assert.equal(accepted, false);
797
+ accepted = true;
798
+ }
799
+ return { ...envelope, result: { questionId: question.questionId, taskId: question.taskId,
800
+ targetBindingId: f.binding.bindingId, state: body.action === 'reply' ? 'answered' : 'accepted', replay: false } };
801
+ } };
802
+ const open = async () => {
803
+ const remote = fakeTransport(f.binding);
804
+ remotes.push(remote);
805
+ const options = { ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
806
+ budget: { reserve: async (id, cents) => budget.reserve(id, cents) },
807
+ questionBudget: { reserve: async (id, cents) => budget.reserveUnacceptedQuestion(id, cents) },
808
+ channelTransport, authorizeContent: async () => true, openTransport: async () => remote.transport };
809
+ return openCodexInboxSession(options);
810
+ };
811
+ let inbox = await open();
812
+ try {
813
+ await assert.rejects(inbox.runNext(), /provider_session_channel_uncertain/);
814
+ assert.equal(accepted, false);
815
+ assert.equal(remotes[0].calls.includes('turn/start'), false);
816
+ assert.equal(budget.status().reservedCents, 25);
817
+ await inbox.close();
818
+ budget.close();
819
+ budget = new NamedSessionBudget(path, 'question-recovery', 50);
820
+ inbox = await open();
821
+ assert.equal((await inbox.runNext()).state, 'answered');
822
+ assert.equal(accepts, 2);
823
+ assert.equal(remotes.flatMap(remote => remote.calls).filter(call => call === 'turn/start').length, 1);
824
+ assert.equal(budget.status().reservedCents, 25);
825
+ assert.equal(budget.reserve(question.questionId, 25), true, 'a distinct local-work purpose needs its own reservation');
826
+ assert.equal(budget.reserve(question.questionId, 25), false, 'local-work reservation replay stays forbidden');
827
+ assert.equal(budget.status().reservedCents, 50);
828
+ }
829
+ finally {
830
+ await inbox.close();
831
+ budget.close();
832
+ f.vault.close();
833
+ }
834
+ });
835
+ for (const lostAt of ['before_commit', 'after_commit'])
836
+ test(`HTTP acceptance loss ${lostAt} preserves cost and never repeats execution`, async () => {
837
+ const f = await fixture(), question = signedQuestion(f.binding, f.now);
838
+ const path = join(f.root, 'http-question-budget.sqlite');
839
+ let budget = new NamedSessionBudget(path, 'http-question-recovery', 50);
840
+ let accepted = false, accepts = 0;
841
+ const remotes = [];
842
+ const server = createServer(async (request, response) => {
843
+ const chunks = [];
844
+ for await (const chunk of request)
845
+ chunks.push(Buffer.from(chunk));
846
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
847
+ const envelope = { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId };
848
+ const send = (value) => { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify(value)); };
849
+ assert.equal(body.bindingId, f.binding.bindingId);
850
+ assert.equal(body.workspaceId, f.binding.workspaceId);
851
+ if (request.url?.endsWith('provider-sessions')) {
852
+ send({ ...envelope, registration: {
853
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
854
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
855
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
856
+ revision: Number(body.expectedRevision) + 1, state: 'attached',
857
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false
858
+ } });
859
+ return;
860
+ }
861
+ if (body.action === 'inbox') {
862
+ send({ ...envelope, result: { offers: accepted ? [] : [question] } });
863
+ return;
864
+ }
865
+ if (body.action === 'accept') {
866
+ accepts++;
867
+ if (accepts === 1) {
868
+ accepted = lostAt === 'after_commit';
869
+ request.socket.destroy();
870
+ return;
871
+ }
872
+ assert.equal(accepted, false);
873
+ accepted = true;
874
+ }
875
+ send({ ...envelope, result: { questionId: question.questionId, taskId: question.taskId,
876
+ targetBindingId: f.binding.bindingId, state: body.action === 'reply' ? 'answered' : 'accepted', replay: false } });
877
+ });
878
+ await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen));
879
+ const origin = `http://127.0.0.1:${server.address().port}`;
880
+ const channelTransport = { async signedPost(route, body) {
881
+ const response = await fetch(origin + route, { method: 'POST', body: JSON.stringify(body),
882
+ headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(3000) });
883
+ assert.equal(response.status, 200);
884
+ return await response.json();
885
+ } };
886
+ const open = async () => {
887
+ const remote = fakeTransport(f.binding);
888
+ remotes.push(remote);
889
+ return openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
890
+ budget: { reserve: async (id, cents) => budget.reserve(id, cents) },
891
+ questionBudget: { reserve: async (id, cents) => budget.reserveUnacceptedQuestion(id, cents) },
892
+ channelTransport, authorizeContent: async () => true, openTransport: async () => remote.transport });
893
+ };
894
+ let inbox = await open();
895
+ try {
896
+ await assert.rejects(inbox.runNext(), /provider_session_channel_uncertain/);
897
+ assert.equal(remotes[0].calls.includes('turn/start'), false);
898
+ assert.equal(budget.status().reservedCents, 25);
899
+ await inbox.close();
900
+ budget.close();
901
+ budget = new NamedSessionBudget(path, 'http-question-recovery', 50);
902
+ inbox = await open();
903
+ assert.equal((await inbox.runNext()).state, lostAt === 'before_commit' ? 'answered' : 'idle');
904
+ assert.equal(accepts, lostAt === 'before_commit' ? 2 : 1);
905
+ assert.equal(remotes.flatMap(remote => remote.calls).filter(call => call === 'turn/start').length, lostAt === 'before_commit' ? 1 : 0);
906
+ assert.equal(budget.status().reservedCents, 25);
907
+ }
908
+ finally {
909
+ await inbox.close();
910
+ budget.close();
911
+ f.vault.close();
912
+ server.closeAllConnections();
913
+ await new Promise(resolveClose => server.close(() => resolveClose()));
914
+ }
915
+ });
916
+ test('a replayed acceptance never authorizes another provider turn', async () => {
917
+ const f = await fixture(), question = signedQuestion(f.binding, f.now), remote = fakeTransport(f.binding);
918
+ let accepts = 0;
919
+ const channelTransport = { async signedPost(route, input) {
920
+ const body = input;
921
+ const envelope = { ok: true, organizationId: f.binding.organizationId, correlationId: ids.threadId };
922
+ if (route.endsWith('provider-sessions'))
923
+ return { ...envelope, registration: {
924
+ bindingId: f.binding.bindingId, workspaceId: f.binding.workspaceId, endpointId: f.binding.endpointId,
925
+ repositoryBindingId: f.binding.repositoryBindingId, membershipId: f.binding.membershipId,
926
+ deviceId: f.binding.deviceId, provider: 'codex', mode: 'bridge_owned',
927
+ revision: Number(body.expectedRevision) + 1, state: 'attached',
928
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(), replay: false
929
+ } };
930
+ if (body.action === 'inbox')
931
+ return { ...envelope, result: { offers: [question] } };
932
+ if (body.action === 'accept')
933
+ accepts++;
934
+ return { ...envelope, result: { questionId: question.questionId, taskId: question.taskId,
935
+ targetBindingId: f.binding.bindingId, state: body.action === 'reply' ? 'answered' : 'accepted', replay: true } };
936
+ } };
937
+ const inbox = await openCodexInboxSession({ ...f, bindingId: f.binding.bindingId, expectedRevision: 0,
938
+ channelTransport, authorizeContent: async () => true, openTransport: async () => remote.transport });
939
+ try {
940
+ await assert.rejects(inbox.runNext(), /replayed/);
941
+ assert.equal(accepts, 1);
942
+ assert.equal(remote.calls.includes('turn/start'), false);
943
+ }
944
+ finally {
945
+ await inbox.close();
946
+ f.vault.close();
947
+ }
948
+ });
687
949
  test('bound dispatch resolves one encrypted binding and releases a completed turn', async () => {
688
950
  const f = await fixture();
689
951
  const remote = fakeTransport(f.binding);