@robota-sdk/agent-transport-http 3.0.0-beta.100

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,1063 @@
1
+ /**
2
+ * Tests for HTTP transport routes.
3
+ * Uses Hono's built-in test client — no real HTTP server needed.
4
+ */
5
+
6
+ import { describe, it, expect, vi } from 'vitest';
7
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
8
+
9
+ import { createAgentRoutes } from '../routes.js';
10
+ import { createHttpTransport } from '../http-transport.js';
11
+ import type { IHttpTransportSession } from '../http-session.js';
12
+ import type { IInteractiveSession, ITurnHandle } from '@robota-sdk/agent-interface-session';
13
+
14
+ /**
15
+ * Built on the PUBLISHED conformant double, not a cast.
16
+ *
17
+ * It WAS a cast — `{ … } as unknown as IInteractiveSession` — and `/submit` refusing a session that
18
+ * cannot name itself is what exposed it: the cast omitted `getSession`, so five cases got a 500.
19
+ * That is the cast working exactly as this file's other double already warns it does — "a partial
20
+ * re-implementation nothing checks against the real thing", compiling whatever it happens to
21
+ * contain and silently missing whatever the contract gains later.
22
+ *
23
+ * The overrides are typed at their own sites for the same reason `createHonestSession` narrows
24
+ * each: `as never` or a blanket cast would defeat what the published double is for.
25
+ */
26
+ /**
27
+ * RUNTIME-003 landed while this PR was in review: `submit` now answers with a TURN HANDLE whose
28
+ * `completed` always settles. The honest stubs answer with one too — an empty result, since no case
29
+ * here reads the handle (the route relays EVENTS) — so the suite keeps compiling against the
30
+ * contract rather than against the shape it had last month.
31
+ */
32
+ function stubHandle(turnId: string): ITurnHandle {
33
+ return {
34
+ turnId,
35
+ completed: Promise.resolve({
36
+ response: '',
37
+ history: [],
38
+ toolSummaries: [],
39
+ contextState: { usedTokens: 0, maxTokens: 0, usedPercentage: 0, remainingPercentage: 100 },
40
+ }),
41
+ };
42
+ }
43
+
44
+ function createMockSession(overrides?: Partial<IInteractiveSession>) {
45
+ return createTestInteractiveSession({
46
+ submit: vi.fn() as unknown as IInteractiveSession['submit'],
47
+ abort: vi.fn(),
48
+ cancelQueue: vi.fn(),
49
+ getMessages: vi.fn().mockReturnValue([
50
+ { role: 'user', content: 'hello' },
51
+ { role: 'assistant', content: 'world' },
52
+ ]) as unknown as IInteractiveSession['getMessages'],
53
+ getContextState: vi.fn().mockReturnValue({
54
+ usedPercentage: 10,
55
+ usedTokens: 1000,
56
+ maxTokens: 200000,
57
+ }) as unknown as IInteractiveSession['getContextState'],
58
+ isExecuting: vi.fn().mockReturnValue(false) as unknown as IInteractiveSession['isExecuting'],
59
+ getPendingPrompt: vi
60
+ .fn()
61
+ .mockReturnValue(null) as unknown as IInteractiveSession['getPendingPrompt'],
62
+ executeCommand: vi.fn().mockResolvedValue({
63
+ message: 'Conversation cleared.',
64
+ success: true,
65
+ }) as unknown as IInteractiveSession['executeCommand'],
66
+ listCommands: vi.fn().mockReturnValue([]) as unknown as IInteractiveSession['listCommands'],
67
+ on: vi.fn() as unknown as IInteractiveSession['on'],
68
+ off: vi.fn() as unknown as IInteractiveSession['off'],
69
+ ...overrides,
70
+ });
71
+ }
72
+
73
+ function createHonestSession() {
74
+ let executing = false;
75
+ let started = 0;
76
+ const listeners = new Map<string, Set<(data: unknown) => void>>();
77
+ const emit = (event: string, data: unknown): void => {
78
+ for (const h of listeners.get(event) ?? []) h(data);
79
+ };
80
+ // Built on the PUBLISHED conformant double rather than another cast to the contract. A cast is a
81
+ // partial re-implementation nothing checks against the real thing — it compiles whatever it
82
+ // happens to contain, so a member the contract gains later is simply missing and the suite keeps
83
+ // passing.
84
+ //
85
+ // And the overrides are typed. The first version ended `} as never`, which defeated exactly what
86
+ // the double was chosen for: `never` is assignable to everything, so a misspelled member name or a
87
+ // wrong handler signature compiled silently — the same blindness as the cast it replaced, one line
88
+ // further down. Review found it. Each override is narrowed at its own site instead, so a typo is a
89
+ // compile error and nothing else is waved through.
90
+ const session = createTestInteractiveSession({
91
+ isExecuting: () => executing,
92
+ submit: async () => {
93
+ // The real `submit()` opens with `await ensureInitialized()` before it reads or sets the
94
+ // busy flag. A stub without that suspension point cannot express the window, and the first
95
+ // version of this file did not have it — which is why it reported no race.
96
+ await Promise.resolve();
97
+ if (executing) return stubHandle('queued'); // the real session queues here
98
+ started += 1;
99
+ executing = true;
100
+ await new Promise((r) => setTimeout(r, 20));
101
+ executing = false;
102
+ // The route's SSE stream ends on the terminal event, so a session that never emits one
103
+ // would hang the request and the case would fail on a TIMEOUT — a red for the wrong reason,
104
+ // which proves as little as a green for the wrong reason.
105
+ emit('complete', { success: true, content: 'done' });
106
+ return stubHandle(`turn-${started}`);
107
+ },
108
+ on: ((event: string, handler: (data: unknown) => void) => {
109
+ if (!listeners.has(event)) listeners.set(event, new Set());
110
+ listeners.get(event)?.add(handler);
111
+ }) as IInteractiveSession['on'],
112
+ off: ((event: string, handler: (data: unknown) => void) => {
113
+ listeners.get(event)?.delete(handler);
114
+ }) as IInteractiveSession['off'],
115
+ });
116
+ return { session, startedTurns: () => started };
117
+ }
118
+
119
+ describe('HTTP Transport Routes', () => {
120
+ it('serves requests, a turn stream, and cancellation through only the declared port', async () => {
121
+ const { session: full, startedTurns } = createHonestSession();
122
+ const {
123
+ submit,
124
+ on,
125
+ off,
126
+ abort,
127
+ cancelQueue,
128
+ getSession,
129
+ executeCommand,
130
+ listCommands,
131
+ listSkills,
132
+ listModels,
133
+ getMessages,
134
+ getFullHistory,
135
+ getMessagesDisplay,
136
+ getContextState,
137
+ isExecuting,
138
+ getPendingPrompt,
139
+ getPendingCount,
140
+ resolvePermission,
141
+ resolveAsk,
142
+ } = full;
143
+ const port: IHttpTransportSession = {
144
+ submit,
145
+ on,
146
+ off,
147
+ abort,
148
+ cancelQueue,
149
+ getSession,
150
+ executeCommand,
151
+ listCommands,
152
+ listSkills,
153
+ listModels,
154
+ getMessages,
155
+ getFullHistory,
156
+ getMessagesDisplay,
157
+ getContextState,
158
+ isExecuting,
159
+ getPendingPrompt,
160
+ getPendingCount,
161
+ resolvePermission,
162
+ resolveAsk,
163
+ };
164
+ // #3282 §2 added `listModels` to `ISessionCommands` (15 + 1); #3288 §2 added
165
+ // `getMessagesDisplay` to `ISessionConversationRead` (16 + 1); #3449 added the two prompt
166
+ // answers of `ISessionPromptResolution` (17 + 2).
167
+ expect(Object.keys(port)).toHaveLength(19);
168
+ const transport = createHttpTransport({
169
+ admission: { open: true, openReason: 'least-authority HTTP port scenario' },
170
+ });
171
+ transport.attach(port);
172
+ await transport.start();
173
+ try {
174
+ const app = transport.getApp();
175
+ const streamed = await app.request('/submit', {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({ prompt: 'hello' }),
179
+ });
180
+ expect(streamed.status).toBe(200);
181
+ expect(await streamed.text()).toContain('event: complete');
182
+ expect(startedTurns()).toBe(1);
183
+ expect((await app.request('/abort', { method: 'POST' })).status).toBe(200);
184
+ expect((await app.request('/cancel-queue', { method: 'POST' })).status).toBe(200);
185
+ } finally {
186
+ await transport.stop();
187
+ }
188
+ });
189
+
190
+ function createApp(session?: IInteractiveSession) {
191
+ const mockSession = session ?? createMockSession();
192
+ const app = createAgentRoutes({
193
+ sessionFactory: () => mockSession,
194
+ // SEC-008: these cases predate the trust boundary and are about what each route DOES. They say
195
+ // so rather than carrying a credential, so a reader can tell "admission is not under test" from
196
+ // "admission was forgotten" — which is the distinction the boundary exists to make possible.
197
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
198
+ });
199
+ return { app, mockSession };
200
+ }
201
+
202
+ // ── POST /abort ───────────────────────────────────────────────
203
+
204
+ it('POST /abort calls session.abort()', async () => {
205
+ const { app, mockSession } = createApp();
206
+ const res = await app.request('/abort', { method: 'POST' });
207
+ expect(res.status).toBe(200);
208
+ expect(await res.json()).toEqual({ ok: true });
209
+ expect(mockSession.abort).toHaveBeenCalled();
210
+ });
211
+
212
+ // ── POST /cancel-queue ────────────────────────────────────────
213
+
214
+ it('POST /cancel-queue calls session.cancelQueue()', async () => {
215
+ const { app, mockSession } = createApp();
216
+ const res = await app.request('/cancel-queue', { method: 'POST' });
217
+ expect(res.status).toBe(200);
218
+ expect(await res.json()).toEqual({ ok: true });
219
+ expect(mockSession.cancelQueue).toHaveBeenCalled();
220
+ });
221
+
222
+ // ── GET /messages ─────────────────────────────────────────────
223
+
224
+ it('GET /messages returns message history', async () => {
225
+ const { app } = createApp();
226
+ const res = await app.request('/messages');
227
+ expect(res.status).toBe(200);
228
+ const body = await res.json();
229
+ expect(body).toHaveLength(2);
230
+ expect(body[0].role).toBe('user');
231
+ expect(body[1].role).toBe('assistant');
232
+ });
233
+
234
+ // ── GET /context ──────────────────────────────────────────────
235
+
236
+ it('GET /context returns context window state', async () => {
237
+ const { app } = createApp();
238
+ const res = await app.request('/context');
239
+ expect(res.status).toBe(200);
240
+ const body = await res.json();
241
+ expect(body.usedTokens).toBe(1000);
242
+ expect(body.maxTokens).toBe(200000);
243
+ });
244
+
245
+ // ── GET /executing ────────────────────────────────────────────
246
+
247
+ it('GET /executing returns execution status', async () => {
248
+ const { app } = createApp();
249
+ const res = await app.request('/executing');
250
+ expect(res.status).toBe(200);
251
+ expect(await res.json()).toEqual({ executing: false });
252
+ });
253
+
254
+ // ── GET /pending ──────────────────────────────────────────────
255
+
256
+ it('GET /pending returns null when no queue', async () => {
257
+ const { app } = createApp();
258
+ const res = await app.request('/pending');
259
+ expect(res.status).toBe(200);
260
+ expect(await res.json()).toEqual({ pending: null });
261
+ });
262
+
263
+ it('GET /pending returns queued prompt', async () => {
264
+ const mockSession = createMockSession({
265
+ getPendingPrompt: vi.fn().mockReturnValue('queued prompt'),
266
+ });
267
+ const { app } = createApp(mockSession);
268
+ const res = await app.request('/pending');
269
+ expect(res.status).toBe(200);
270
+ expect(await res.json()).toEqual({ pending: 'queued prompt' });
271
+ });
272
+
273
+ // ── POST /command ─────────────────────────────────────────────
274
+
275
+ it('POST /command executes system command via session.executeCommand()', async () => {
276
+ const { app, mockSession } = createApp();
277
+ const res = await app.request('/command', {
278
+ method: 'POST',
279
+ headers: { 'Content-Type': 'application/json' },
280
+ body: JSON.stringify({ name: 'clear', args: '' }),
281
+ });
282
+ expect(res.status).toBe(200);
283
+ const body = await res.json();
284
+ expect(body.success).toBe(true);
285
+ expect(body.message).toBe('Conversation cleared.');
286
+ // SEC-008: the call now carries its origin — a peer over HTTP is 'remote', not the operator.
287
+ expect(mockSession.executeCommand).toHaveBeenCalledWith('clear', '', 'remote');
288
+ });
289
+
290
+ it('POST /command returns 400 without name', async () => {
291
+ const { app } = createApp();
292
+ const res = await app.request('/command', {
293
+ method: 'POST',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({}),
296
+ });
297
+ expect(res.status).toBe(400);
298
+ });
299
+
300
+ it('POST /command returns 404 for unknown command', async () => {
301
+ const mockSession = createMockSession({
302
+ executeCommand: vi.fn().mockResolvedValue(null),
303
+ });
304
+ const { app } = createApp(mockSession);
305
+
306
+ const res = await app.request('/command', {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify({ name: 'nonexistent' }),
310
+ });
311
+ expect(res.status).toBe(404);
312
+ });
313
+
314
+ // ── POST /submit validation ───────────────────────────────────
315
+
316
+ it('POST /submit returns 400 without prompt', async () => {
317
+ const { app } = createApp();
318
+ const res = await app.request('/submit', {
319
+ method: 'POST',
320
+ headers: { 'Content-Type': 'application/json' },
321
+ body: JSON.stringify({}),
322
+ });
323
+ expect(res.status).toBe(400);
324
+ });
325
+
326
+ // ── POST /submit completion (decoupled from thinking(false)) ──────
327
+ //
328
+ // Regression guard for ARL-04: the /submit done-promise must resolve from the
329
+ // terminal handler (complete/interrupted/error), and the terminal SSE event must
330
+ // be flushed to the client, WITHOUT relying on a trailing thinking(false). Each
331
+ // scripted session emits ONLY its terminal event (no trailing thinking event).
332
+
333
+ /**
334
+ * Build a session whose on/off truly register handlers and whose submit()
335
+ * emits exactly one terminal event with NO trailing thinking(false).
336
+ */
337
+ function createEmitterSession(terminalEvent: string, terminalData: unknown) {
338
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
339
+ const session = createMockSession({
340
+ on: vi.fn((event: string, handler: (data: unknown) => void) => {
341
+ const set = handlers.get(event) ?? new Set();
342
+ set.add(handler);
343
+ handlers.set(event, set);
344
+ }) as unknown as IInteractiveSession['on'],
345
+ off: vi.fn((event: string, handler: (data: unknown) => void) => {
346
+ handlers.get(event)?.delete(handler);
347
+ }) as unknown as IInteractiveSession['off'],
348
+ submit: vi.fn(async () => {
349
+ // Emit ONLY the terminal event — deliberately no trailing thinking(false).
350
+ for (const handler of handlers.get(terminalEvent) ?? []) {
351
+ handler(terminalData);
352
+ }
353
+ return stubHandle('emitter-turn');
354
+ }),
355
+ });
356
+ return session;
357
+ }
358
+
359
+ async function requestSubmit(session: IInteractiveSession): Promise<string> {
360
+ const app = createAgentRoutes({
361
+ sessionFactory: () => session,
362
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
363
+ });
364
+ const res = await app.request('/submit', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ prompt: 'hi' }),
368
+ });
369
+ expect(res.status).toBe(200);
370
+ // Draining the SSE body must complete (no hang) and contain the terminal event.
371
+ return res.text();
372
+ }
373
+
374
+ it('POST /submit attributes the turn to the host-assigned driver and surface, never the client', async () => {
375
+ const session = createEmitterSession('complete', { ok: true });
376
+ const app = createAgentRoutes({
377
+ sessionFactory: () => session,
378
+ admission: { open: true, openReason: 'this case is about attribution, not admission' },
379
+ attribution: { driverId: 'remote:http', surface: 'remote' },
380
+ });
381
+ const res = await app.request('/submit', {
382
+ method: 'POST',
383
+ headers: { 'Content-Type': 'application/json' },
384
+ body: JSON.stringify({ prompt: 'hi', driverId: 'owner', surface: 'cli' }),
385
+ });
386
+ await res.text();
387
+ expect(session.submit).toHaveBeenCalledWith('hi', undefined, undefined, {
388
+ driverId: 'remote:http',
389
+ surface: 'remote',
390
+ });
391
+ });
392
+
393
+ it('POST /submit leaves attribution to the session when the host assigns none', async () => {
394
+ const session = createEmitterSession('complete', { ok: true });
395
+ await requestSubmit(session);
396
+ expect(session.submit).toHaveBeenCalledWith('hi');
397
+ });
398
+
399
+ it('POST /submit receives the complete event without a trailing thinking(false)', async () => {
400
+ const session = createEmitterSession('complete', { ok: true });
401
+ const body = await requestSubmit(session);
402
+ expect(body).toContain('event: complete');
403
+ expect(body).toContain('"ok":true');
404
+ });
405
+
406
+ it('POST /submit receives the interrupted event without a trailing thinking(false)', async () => {
407
+ const session = createEmitterSession('interrupted', { reason: 'user' });
408
+ const body = await requestSubmit(session);
409
+ expect(body).toContain('event: interrupted');
410
+ expect(body).toContain('"reason":"user"');
411
+ });
412
+
413
+ it('POST /submit receives the error event without a trailing thinking(false)', async () => {
414
+ const session = createEmitterSession('error', new Error('boom'));
415
+ const body = await requestSubmit(session);
416
+ expect(body).toContain('event: error');
417
+ expect(body).toContain('"message":"boom"');
418
+ });
419
+
420
+ // ── ARCH-004 RUNTIME-38: reject concurrent /submit on a busy session ──
421
+
422
+ it('POST /submit returns 409 while a turn is already in flight', async () => {
423
+ // The route CLAIMS the turn now rather than reading `session.isExecuting()`, so a session stubbed
424
+ // as "already executing" no longer expresses this: the flag it used to set is set inside
425
+ // `submit()`, past a suspension point, which is exactly the race that made the old check
426
+ // unreliable. A request actually in flight is what the refusal is about, so that is what this
427
+ // holds — and it is a stronger statement than the stub was.
428
+ const { session } = createHonestSession();
429
+ const app = createAgentRoutes({
430
+ sessionFactory: () => session,
431
+ // SEC-008: admission is not under test here, and the reason it is open says so.
432
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
433
+ });
434
+ const post = async (prompt: string): Promise<Response> =>
435
+ app.request('/submit', {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({ prompt }),
439
+ });
440
+
441
+ const [first, second] = await Promise.all([post('first'), post('second')]);
442
+ await Promise.all([first.text(), second.text()]);
443
+
444
+ expect([first.status, second.status].sort()).toEqual([200, 409]);
445
+ });
446
+
447
+ // ── ARCH-004 RUNTIME-14: SSE teardown always removes every listener (no leak) ──
448
+
449
+ it('POST /submit unsubscribes every listener it added once the stream completes', async () => {
450
+ const session = createEmitterSession('complete', { ok: true });
451
+ await requestSubmit(session);
452
+ // The try/finally teardown must `off` exactly what it `on`'d — a balanced count means zero leaked listeners.
453
+ const onCount = (session.on as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
454
+ const offCount = (session.off as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
455
+ expect(offCount).toBe(onCount);
456
+ expect(onCount).toBeGreaterThan(0);
457
+ });
458
+
459
+ /**
460
+ * The load-bearing RUNTIME-14 case: a client that disconnects MID-STREAM (before any terminal event). The
461
+ * pre-fix code ran cleanup only after `await done`, and `done` resolved solely on complete/interrupted/error
462
+ * — so a disconnect leaked every listener forever. The fix's `stream.onAbort` must cancel the run
463
+ * (`session.abort()`) and unblock `done` so the `finally` teardown runs.
464
+ */
465
+ it('POST /submit tears down + aborts the run when the client disconnects mid-stream', async () => {
466
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
467
+ let settleSubmit: (() => void) | undefined;
468
+ const session = createMockSession({
469
+ isExecuting: vi.fn().mockReturnValue(false) as unknown as IInteractiveSession['isExecuting'],
470
+ on: vi.fn((event: string, handler: (data: unknown) => void) => {
471
+ const set = handlers.get(event) ?? new Set();
472
+ set.add(handler);
473
+ handlers.set(event, set);
474
+ }) as unknown as IInteractiveSession['on'],
475
+ off: vi.fn((event: string, handler: (data: unknown) => void) => {
476
+ handlers.get(event)?.delete(handler);
477
+ }) as unknown as IInteractiveSession['off'],
478
+ // A run that never emits a terminal event on its own — it settles only when abort() is called.
479
+ submit: vi.fn(
480
+ () => new Promise<void>((resolve) => (settleSubmit = resolve)),
481
+ ) as unknown as IInteractiveSession['submit'],
482
+ abort: vi.fn(() => settleSubmit?.()),
483
+ });
484
+
485
+ const app = createAgentRoutes({
486
+ sessionFactory: () => session,
487
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
488
+ });
489
+ const res = await app.request('/submit', {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json' },
492
+ body: JSON.stringify({ prompt: 'hi' }),
493
+ });
494
+ expect(res.status).toBe(200);
495
+
496
+ // Let the handler subscribe + enter the in-flight `submit()`, then cancel the response stream — the
497
+ // Web-standard equivalent of a client disconnecting mid-stream (Hono fires `stream.onAbort`).
498
+ const reader = res.body!.getReader();
499
+ await new Promise((r) => setTimeout(r, 20));
500
+ await reader.cancel();
501
+ await new Promise((r) => setTimeout(r, 20)); // let onAbort → session.abort() + the finally teardown run
502
+
503
+ expect(session.abort).toHaveBeenCalled(); // onAbort cancelled the run, not just stopped writing
504
+ const onCount = (session.on as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
505
+ const offCount = (session.off as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
506
+ expect(offCount).toBe(onCount); // finally teardown removed every listener — no leak
507
+ expect(onCount).toBeGreaterThan(0);
508
+ });
509
+ });
510
+
511
+ // ── RUNTIME-003 P2: the busy check must be a CLAIM, not a look ──────────────
512
+
513
+ describe('RUNTIME-003: two submissions in the same tick', () => {
514
+ /**
515
+ * A session whose `isExecuting()` tells the truth — it flips only when a turn actually starts.
516
+ *
517
+ * This is the whole point of the case. The old test for the 409 handed the route a session that
518
+ * was ALREADY executing, which proves the route reads the flag but says nothing about the window
519
+ * the route itself documented: the check and the `await session.submit` inside `streamSSE` are
520
+ * separated by awaits, so two requests arriving together both read `false` and both proceed.
521
+ */
522
+ it('releases the claim when SETUP throws, not only when the turn does', async () => {
523
+ // A REGRESSION guard, labelled as one: it passes against both placements of the `try`, and that
524
+ // is worth saying rather than leaving as an implied red-proof.
525
+ //
526
+ // Review was right that the protected region should open at the claim — the subscription setup
527
+ // runs in a synchronous promise executor, outside where the `try` used to start. Measured, the
528
+ // session is released either way: the router swallows a throw from the stream callback and the
529
+ // `finally` runs regardless. So the correctness today does not depend on the placement; it
530
+ // depends on the router, which is not this route's to promise.
531
+ //
532
+ // The `try` opens at the claim anyway, because a claim whose release is not in the same region
533
+ // is a lock, and this case pins the observable outcome so a router that stopped swallowing would
534
+ // be caught here rather than by a session wedged at 409 forever.
535
+ let requests = 0;
536
+ const { session } = createHonestSession();
537
+ const realOn = session.on.bind(session);
538
+ session.on = ((event: string, handler: () => void) => {
539
+ if (requests === 0 && event === 'text_delta') throw new Error('listener cap reached');
540
+ return realOn(event as 'text_delta', handler);
541
+ }) as IInteractiveSession['on'];
542
+ const app = createAgentRoutes({
543
+ sessionFactory: () => session,
544
+ // SEC-008: admission is not under test here, and the reason it is open says so.
545
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
546
+ });
547
+ const post = async (prompt: string): Promise<Response> =>
548
+ app.request('/submit', {
549
+ method: 'POST',
550
+ body: JSON.stringify({ prompt }),
551
+ headers: { 'content-type': 'application/json' },
552
+ });
553
+
554
+ await (await post('the one that throws in setup')).text().catch(() => '');
555
+ requests += 1;
556
+ const after = await post('the one that must still be served');
557
+ await after.text();
558
+
559
+ expect(after.status, 'the session stayed claimed after setup threw').toBe(200);
560
+ });
561
+
562
+ it('does NOT consume a turn when the subscription setup throws', async () => {
563
+ // The case above pins the CLAIM. Review pointed out that it says nothing about the turn, and
564
+ // that the turn was the more expensive half: the subscriptions were wired inside a promise
565
+ // executor, so a throw from `session.on` was caught by the Promise constructor and became an
566
+ // already-rejected `done` rather than propagating. Execution fell through to `submit()` — a
567
+ // real turn ran, with only some of its listeners attached and nothing relaying it — and the
568
+ // rejection surfaced afterwards at `await done`, by which point the turn was gone.
569
+ //
570
+ // RED-PROVED against the executor placement: `submit` was called once.
571
+ let threw = false;
572
+ const { session } = createHonestSession();
573
+ const realOn = session.on.bind(session);
574
+ session.on = ((event: string, handler: () => void) => {
575
+ if (!threw && event === 'text_delta') {
576
+ threw = true;
577
+ throw new Error('listener cap reached');
578
+ }
579
+ return realOn(event as 'text_delta', handler);
580
+ }) as IInteractiveSession['on'];
581
+ const submit = vi.spyOn(session, 'submit');
582
+
583
+ const app = createAgentRoutes({
584
+ sessionFactory: () => session,
585
+ // SEC-008: admission is not under test here, and the reason it is open says so.
586
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
587
+ });
588
+ await (
589
+ await app.request('/submit', {
590
+ method: 'POST',
591
+ body: JSON.stringify({ prompt: 'must never reach the session' }),
592
+ headers: { 'content-type': 'application/json' },
593
+ })
594
+ )
595
+ .text()
596
+ .catch(() => '');
597
+
598
+ expect(threw, 'the fixture never threw, so this case proves nothing').toBe(true);
599
+ expect(
600
+ submit,
601
+ 'a turn was consumed by a request that could not relay it',
602
+ ).not.toHaveBeenCalled();
603
+ });
604
+
605
+ it('TELLS the client when the stream callback throws', async () => {
606
+ // The throw above had nowhere to go. `streamSSE` had no error handler, so it became an
607
+ // UNHANDLED rejection — silent in a browser, and red under vitest, which is how it was found
608
+ // (the `quality` job on this PR). A stream that dies without saying so leaves the client
609
+ // waiting on a 200 and an open connection forever.
610
+ //
611
+ // It cannot be an error STATUS: the headers are already out by the time the callback runs. So
612
+ // it is reported on the channel the client is listening to.
613
+ const { session } = createHonestSession();
614
+ const realOn = session.on.bind(session);
615
+ session.on = ((event: string, handler: () => void) => {
616
+ if (event === 'text_delta') throw new Error('listener cap reached');
617
+ return realOn(event as 'text_delta', handler);
618
+ }) as IInteractiveSession['on'];
619
+
620
+ const app = createAgentRoutes({
621
+ sessionFactory: () => session,
622
+ // SEC-008: admission is not under test here, and the reason it is open says so.
623
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
624
+ });
625
+ const response = await app.request('/submit', {
626
+ method: 'POST',
627
+ body: JSON.stringify({ prompt: 'the setup will throw' }),
628
+ headers: { 'content-type': 'application/json' },
629
+ });
630
+ const body = await response.text();
631
+
632
+ expect(response.status).toBe(200);
633
+ expect(body, 'the stream closed without telling the client anything').toContain('event: error');
634
+ // The GENERIC line, and NOT the exception — review caught this assertion contradicting the
635
+ // trust-boundary design, and measuring it found a real leak: Hono's runner follows any
636
+ // `onError` by writing the raw `e.message` to the stream, so the body carried TWO error events,
637
+ // the generic one and the leak. The callback catches its own failures now, so the runner's
638
+ // write is unreachable.
639
+ expect(body).toContain('the stream failed on the server');
640
+ expect(body, 'the raw exception crossed the trust boundary').not.toContain(
641
+ 'listener cap reached',
642
+ );
643
+ });
644
+
645
+ it('refuses when the session is busy from SOMEWHERE ELSE', async () => {
646
+ // Claiming per route closed the race but dropped `session.isExecuting()` entirely, so a turn
647
+ // started by another surface — the TUI, a WS client, a previous process — was invisible here and
648
+ // this route would start a second one on a session already running. Review found it: the claim
649
+ // is what this route knows, and isExecuting is what the session knows. Both are needed.
650
+ const { session } = createHonestSession();
651
+ session.isExecuting = () => true; // busy, but not by anything this router claimed
652
+ const app = createAgentRoutes({
653
+ sessionFactory: () => session,
654
+ // SEC-008: admission is not under test here, and the reason it is open says so.
655
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
656
+ });
657
+
658
+ const res = await app.request('/submit', {
659
+ method: 'POST',
660
+ body: JSON.stringify({ prompt: 'while another surface holds the turn' }),
661
+ headers: { 'content-type': 'application/json' },
662
+ });
663
+
664
+ expect(res.status).toBe(409);
665
+ });
666
+
667
+ it('does not refuse a DIFFERENT session because another one is busy', async () => {
668
+ // Review found this: the claim was a single flag on the router, but `sessionFactory` is
669
+ // documented as resolving a session per request — the example says "multi-tenant" in as many
670
+ // words. One tenant's turn refused every other tenant's, which is a worse defect than the race
671
+ // it closed: a busy neighbour is not a reason to refuse you.
672
+ const first = createHonestSession();
673
+ const second = createHonestSession();
674
+ let call = 0;
675
+ const app = createAgentRoutes({
676
+ sessionFactory: () => (call++ === 0 ? first.session : second.session),
677
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
678
+ });
679
+ const post = async (prompt: string): Promise<Response> =>
680
+ app.request('/submit', {
681
+ method: 'POST',
682
+ body: JSON.stringify({ prompt }),
683
+ headers: { 'content-type': 'application/json' },
684
+ });
685
+
686
+ const both = await Promise.all([post('tenant A'), post('tenant B')]);
687
+ await Promise.all(both.map((r) => r.text()));
688
+
689
+ expect(both.map((r) => r.status)).toEqual([200, 200]);
690
+ expect(first.startedTurns()).toBe(1);
691
+ expect(second.startedTurns(), 'a second tenant was refused because the first was busy').toBe(1);
692
+ });
693
+
694
+ it('still refuses when the factory returns a FRESH WRAPPER for the same session', async () => {
695
+ // The claim used to be a `WeakSet` keyed on the OBJECT, and the first answer to this was a
696
+ // paragraph telling callers to be identity-stable — a requirement nothing could check, which
697
+ // review rightly refused. The contract already gives every session a stable id
698
+ // (`getSession(): { getSessionId(): string }`), so the claim is keyed by that and the
699
+ // requirement is gone rather than documented.
700
+ //
701
+ // RED-PROVED against the WeakSet: [200, 200], and BOTH turns started.
702
+ const backing = createHonestSession();
703
+ const app = createAgentRoutes({
704
+ // A new object every call, forwarding to one session — the shape that defeated identity.
705
+ sessionFactory: () => Object.assign(createTestInteractiveSession(), { ...backing.session }),
706
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
707
+ });
708
+ const post = async (prompt: string): Promise<Response> =>
709
+ app.request('/submit', {
710
+ method: 'POST',
711
+ body: JSON.stringify({ prompt }),
712
+ headers: { 'content-type': 'application/json' },
713
+ });
714
+
715
+ const both = await Promise.all([post('first'), post('second')]);
716
+ await Promise.all(both.map((r) => r.text()));
717
+
718
+ expect(both.map((r) => r.status).sort()).toEqual([200, 409]);
719
+ expect(backing.startedTurns(), 'two turns ran on one session').toBe(1);
720
+ });
721
+
722
+ it('REFUSES a session that will not name itself, rather than serving it racily', async () => {
723
+ // Review: with no id, `claim` was `undefined` and the busy check fell back to `isExecuting()`
724
+ // alone — which the route's own comment documents as the racy read this whole change exists to
725
+ // stop relying on. So the fix silently degraded into the bug for exactly the callers who could
726
+ // not be told they were affected.
727
+ //
728
+ // The contract types `getSessionId()` as returning a `string`, so this is unreachable for a
729
+ // conformant session; naming it is what keeps that true.
730
+ const { session } = createHonestSession();
731
+ session.getSession = (() => ({ getSessionId: () => '' })) as IInteractiveSession['getSession'];
732
+ const app = createAgentRoutes({
733
+ sessionFactory: () => session,
734
+ // SEC-008: admission is not under test here, and the reason it is open says so.
735
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
736
+ });
737
+
738
+ const response = await app.request('/submit', {
739
+ method: 'POST',
740
+ body: JSON.stringify({ prompt: 'nobody can guarantee this is mine' }),
741
+ headers: { 'content-type': 'application/json' },
742
+ });
743
+
744
+ // Read ONCE — a Response body is not re-readable, and asserting twice off `await json()` throws
745
+ // `Body is unusable` rather than failing the assertion it was written for.
746
+ const body = (await response.json()) as { error: string };
747
+
748
+ expect(response.status).toBe(500);
749
+ expect(body.error).toMatch(/concurrent-turn tracking is unavailable/);
750
+ // And it names no internal method: this route can be mounted outside a trust boundary, and a
751
+ // client cannot act on a contract it does not implement. Review asked for the call; this is it.
752
+ expect(body.error, 'the internal signature leaked').not.toMatch(/getSessionId/);
753
+ });
754
+
755
+ it('reports the SAME busy from /executing as /submit refuses on', async () => {
756
+ // Review: `/executing` asked only `session.isExecuting()`, so a client polling it could see
757
+ // `executing: false` and still get a 409 from `/submit` — two endpoints disagreeing about one
758
+ // word, which is a worse answer than either alone.
759
+ //
760
+ // The divergence needs a session whose turn is CLAIMED but not yet EXECUTING, and the honest
761
+ // double cannot hold that open: it sets `executing` inside `submit`, so both are true at the
762
+ // same moment and the case passes either way. Measured — my first version of this case was
763
+ // green with the fix reverted.
764
+ //
765
+ // This one holds the window: `submit` never resolves and never sets `executing`, which is what
766
+ // the real session looks like between the route's claim and the flag flipping past
767
+ // `await ensureInitialized()`.
768
+ const executing = false;
769
+ const session = createTestInteractiveSession({
770
+ isExecuting: () => executing,
771
+ submit: (() => new Promise(() => {})) as unknown as IInteractiveSession['submit'],
772
+ });
773
+ const app = createAgentRoutes({
774
+ sessionFactory: () => session,
775
+ // SEC-008: admission is not under test here, and the reason it is open says so.
776
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
777
+ });
778
+ const post = async (prompt: string): Promise<Response> =>
779
+ app.request('/submit', {
780
+ method: 'POST',
781
+ body: JSON.stringify({ prompt }),
782
+ headers: { 'content-type': 'application/json' },
783
+ });
784
+
785
+ void post('holds the claim, never executes');
786
+ // The 409 establishes that the claim IS held — one microtask is not enough for `/submit` to
787
+ // reach it, so querying before that measures the wrong moment.
788
+ const refused = await post('arrives while claimed');
789
+ await refused.text();
790
+ const busy = await (await app.request('/executing')).json();
791
+
792
+ expect(refused.status, 'the second submission was not refused').toBe(409);
793
+ expect(executing, 'the fixture was executing, so it cannot show the divergence').toBe(false);
794
+ expect(busy, '/executing said idle while /submit said busy').toEqual({ executing: true });
795
+ });
796
+
797
+ it('starts exactly one turn, and answers the other 409', async () => {
798
+ // The window is REAL, and review found it after the first version of this case declared it was
799
+ // not. Measured: `submit()` opens with `await ensureInitialized()`, an unconditional suspension
800
+ // point, and the `executing` flag `isExecuting()` reads is only set past it. So two requests both
801
+ // read `false`, both call `submit`, and the loser silently coalesces into the pending queue while
802
+ // its HTTP response streams back the WINNER'S events — both are subscribed to one shared emitter.
803
+ //
804
+ // That is worse than what the original comment warned about: not two turns, but one turn's output
805
+ // delivered to two clients as if each had asked for it.
806
+ //
807
+ // The earlier version of this case measured `check → submit → check` and concluded there was no
808
+ // race. It was measuring a `submit` with no suspension point in it — a stub I wrote — so it was
809
+ // reporting on my fixture rather than on the session.
810
+ const { session, startedTurns } = createHonestSession();
811
+ const app = createAgentRoutes({
812
+ sessionFactory: () => session,
813
+ // SEC-008: admission is not under test here, and the reason it is open says so.
814
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
815
+ });
816
+
817
+ const post = async (prompt: string): Promise<Response> =>
818
+ app.request('/submit', {
819
+ method: 'POST',
820
+ body: JSON.stringify({ prompt }),
821
+ headers: { 'content-type': 'application/json' },
822
+ });
823
+
824
+ const both = await Promise.all([post('AAAA'), post('BBBB')]);
825
+ await Promise.all(both.map((r) => r.text()));
826
+
827
+ expect(
828
+ startedTurns(),
829
+ 'both requests passed the isExecuting() look and reached submit — one turn, two clients',
830
+ ).toBe(1);
831
+ expect(both.map((r) => r.status).sort()).toEqual([200, 409]);
832
+ });
833
+ });
834
+
835
+ // ── SEC-008: the route is a trust boundary, not a pass-through ───────────────
836
+
837
+ describe('SEC-008: an unadmitted request never reaches the session', () => {
838
+ /**
839
+ * A session that records whether anything actually got through to it.
840
+ *
841
+ * Built on the PUBLISHED conformant double rather than another cast to the contract. A cast is a
842
+ * partial re-implementation nothing checks against the real thing: it compiles whatever it happens
843
+ * to contain, so a member the contract gains later is simply missing here and the suite keeps
844
+ * passing.
845
+ */
846
+ function createRecordingSession() {
847
+ const reached: string[] = [];
848
+ const session = createTestInteractiveSession({
849
+ submit: async (prompt: string) => {
850
+ reached.push(`submit:${prompt}`);
851
+ // No cast, and the previous version of this comment predicted this edit: it said RUNTIME-003
852
+ // would change the return type on its own branch "and the type is what will tell this file
853
+ // about it". It landed, the compiler told, and the stub answers with the handle the contract
854
+ // now promises. No case here reads it — `reached` is the observable.
855
+ return {
856
+ turnId: 'recorded-turn',
857
+ completed: Promise.resolve({
858
+ response: '',
859
+ history: [],
860
+ toolSummaries: [],
861
+ contextState: {
862
+ usedTokens: 0,
863
+ maxTokens: 0,
864
+ usedPercentage: 0,
865
+ remainingPercentage: 100,
866
+ },
867
+ }),
868
+ };
869
+ },
870
+ executeCommand: async (name: string) => {
871
+ reached.push(`command:${name}`);
872
+ return { message: 'ran', success: true };
873
+ },
874
+ });
875
+ return { session, reached };
876
+ }
877
+
878
+ const CREDENTIAL = 'a'.repeat(64);
879
+
880
+ it('refuses POST /submit with no credential, before the prompt runs', async () => {
881
+ const { session, reached } = createRecordingSession();
882
+ const app = createAgentRoutes({
883
+ sessionFactory: () => session,
884
+ admission: { token: CREDENTIAL },
885
+ });
886
+
887
+ const res = await app.request('/submit', {
888
+ method: 'POST',
889
+ body: JSON.stringify({ prompt: 'run something' }),
890
+ headers: { 'content-type': 'application/json' },
891
+ });
892
+
893
+ expect(res.status).toBe(401);
894
+ // The status alone would not be enough. What matters is that the prompt did not execute — a
895
+ // route that runs the turn and THEN reports 401 has already done the thing it refused.
896
+ expect(reached, 'the prompt reached the session despite the refusal').toEqual([]);
897
+ });
898
+
899
+ it('refuses POST /command with no credential, before the command runs', async () => {
900
+ const { session, reached } = createRecordingSession();
901
+ const app = createAgentRoutes({
902
+ sessionFactory: () => session,
903
+ admission: { token: CREDENTIAL },
904
+ });
905
+
906
+ const res = await app.request('/command', {
907
+ method: 'POST',
908
+ body: JSON.stringify({ name: 'clear', args: '' }),
909
+ headers: { 'content-type': 'application/json' },
910
+ });
911
+
912
+ expect(res.status).toBe(401);
913
+ expect(reached, 'the command reached the session despite the refusal').toEqual([]);
914
+ });
915
+
916
+ it('refuses a WRONG credential the same way it refuses a missing one', async () => {
917
+ const { session, reached } = createRecordingSession();
918
+ const app = createAgentRoutes({
919
+ sessionFactory: () => session,
920
+ admission: { token: CREDENTIAL },
921
+ });
922
+
923
+ const res = await app.request('/submit', {
924
+ method: 'POST',
925
+ body: JSON.stringify({ prompt: 'run something' }),
926
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${'b'.repeat(64)}` },
927
+ });
928
+
929
+ expect(res.status).toBe(401);
930
+ expect(reached).toEqual([]);
931
+ });
932
+
933
+ it('attributes an admitted command to a REMOTE source', async () => {
934
+ // The same defect MCP had, in the transport beside it: `executeCommand` with no `source` defaults
935
+ // to `'user'` — the local operator — so a remote peer is attributed as the person at the keyboard
936
+ // and the `'remote'` policy seam is never consulted. Admission decides WHO may reach the session;
937
+ // it does not say who they are.
938
+ const executeCommand = vi.fn().mockResolvedValue({ message: 'ran', success: true });
939
+ const session = createTestInteractiveSession({ executeCommand });
940
+ const app = createAgentRoutes({
941
+ sessionFactory: () => session,
942
+ admission: { token: CREDENTIAL },
943
+ });
944
+
945
+ await app.request('/command', {
946
+ method: 'POST',
947
+ body: JSON.stringify({ name: 'clear', args: '' }),
948
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${CREDENTIAL}` },
949
+ });
950
+
951
+ expect(executeCommand).toHaveBeenCalledWith('clear', '', 'remote');
952
+ });
953
+
954
+ it('admits a correct credential', async () => {
955
+ const { session, reached } = createRecordingSession();
956
+ const app = createAgentRoutes({
957
+ sessionFactory: () => session,
958
+ admission: { token: CREDENTIAL },
959
+ });
960
+
961
+ const res = await app.request('/command', {
962
+ method: 'POST',
963
+ body: JSON.stringify({ name: 'clear', args: '' }),
964
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${CREDENTIAL}` },
965
+ });
966
+
967
+ // Without this the suite would pass by refusing EVERYTHING, which is a gate nobody can use.
968
+ expect(res.status).toBe(200);
969
+ expect(reached).toEqual(['command:clear']);
970
+ });
971
+
972
+ it('admits with no credential only when the host said so, in writing', async () => {
973
+ const { session, reached } = createRecordingSession();
974
+ const app = createAgentRoutes({
975
+ sessionFactory: () => session,
976
+ admission: { open: true, openReason: 'unit test — no boundary under test here' },
977
+ });
978
+
979
+ const res = await app.request('/command', {
980
+ method: 'POST',
981
+ body: JSON.stringify({ name: 'clear', args: '' }),
982
+ headers: { 'content-type': 'application/json' },
983
+ });
984
+
985
+ expect(res.status).toBe(200);
986
+ expect(reached).toEqual(['command:clear']);
987
+ });
988
+
989
+ it('leaves an empty bearer unadmitted whatever the host configured', async () => {
990
+ // `bearerCredential` requires at least one character after `Bearer `, so a presented credential
991
+ // is never the empty string — MEASURED, for `Bearer`, `Bearer `, `Bearer ` and no header at
992
+ // all. This pins that, because it is what makes the discriminator defect a LOCKOUT rather than
993
+ // a bypass, and the difference is worth having a case for rather than a claim about.
994
+ const { session, reached } = createRecordingSession();
995
+ const app = createAgentRoutes({
996
+ sessionFactory: () => session,
997
+ admission: { token: CREDENTIAL },
998
+ });
999
+
1000
+ for (const authorization of ['Bearer ', 'Bearer', 'Bearer ']) {
1001
+ const res = await app.request('/command', {
1002
+ method: 'POST',
1003
+ body: JSON.stringify({ name: 'clear', args: '' }),
1004
+ headers: { 'content-type': 'application/json', authorization },
1005
+ });
1006
+ expect(res.status, authorization).toBe(401);
1007
+ }
1008
+ expect(reached).toEqual([]);
1009
+ });
1010
+ });
1011
+
1012
+ describe('a session that builds itself in the background', () => {
1013
+ /**
1014
+ * `InteractiveSession` has no id until its background initialization finishes, so naming it
1015
+ * throws until then. It says when it is ready through `whenInitialized()`.
1016
+ */
1017
+ function createStartingSession(initialization: Promise<void>): IHttpTransportSession {
1018
+ const { session } = createHonestSession();
1019
+ let ready = false;
1020
+ void initialization.then(
1021
+ () => (ready = true),
1022
+ () => undefined,
1023
+ );
1024
+ session.getSession = (() => {
1025
+ if (!ready) throw new Error('InteractiveSession not initialized.');
1026
+ return { getSessionId: () => 'session_started' };
1027
+ }) as IInteractiveSession['getSession'];
1028
+ return Object.assign(session, { whenInitialized: () => initialization });
1029
+ }
1030
+
1031
+ async function submit(session: IHttpTransportSession): Promise<Response> {
1032
+ const app = createAgentRoutes({
1033
+ sessionFactory: () => session,
1034
+ admission: { open: true, openReason: 'SEC-008: this case is about routing, not admission' },
1035
+ });
1036
+ return app.request('/submit', {
1037
+ method: 'POST',
1038
+ body: JSON.stringify({ prompt: 'hi' }),
1039
+ headers: { 'content-type': 'application/json' },
1040
+ });
1041
+ }
1042
+
1043
+ it('serves the first /submit once the session is ready, instead of refusing it', async () => {
1044
+ const session = createStartingSession(new Promise((resolve) => setTimeout(resolve, 10)));
1045
+
1046
+ const response = await submit(session);
1047
+
1048
+ expect(response.status).toBe(200);
1049
+ expect(await response.text()).toContain('event: complete');
1050
+ });
1051
+
1052
+ it('answers 500 without the reason when the session fails to start', async () => {
1053
+ const failed = Promise.reject(new Error('settings at /home/me/.fixture-state are broken'));
1054
+ const session = createStartingSession(failed);
1055
+
1056
+ const response = await submit(session);
1057
+ const body = (await response.json()) as { error: string };
1058
+
1059
+ expect(response.status).toBe(500);
1060
+ expect(body.error).toMatch(/could not be started/);
1061
+ expect(body.error).not.toMatch(/home/);
1062
+ });
1063
+ });