a2acalling 0.6.73 → 0.6.75

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 (134) hide show
  1. package/.a2a-manifest.json +2 -2
  2. package/.c8rc.json +16 -0
  3. package/.node-version +1 -0
  4. package/.serena/project.yml +126 -0
  5. package/ARCHITECTURE.md +40 -16
  6. package/CONVENTIONS.md +39 -6
  7. package/biome.json +27 -0
  8. package/coverage/base.css +224 -0
  9. package/coverage/block-navigation.js +87 -0
  10. package/coverage/favicon.png +0 -0
  11. package/coverage/index.html +146 -0
  12. package/coverage/prettify.css +1 -0
  13. package/coverage/prettify.js +2 -0
  14. package/coverage/sort-arrow-sprite.png +0 -0
  15. package/coverage/sorter.js +210 -0
  16. package/coverage/src/index.html +131 -0
  17. package/coverage/src/index.js.html +313 -0
  18. package/coverage/src/lib/agent-card.js.html +418 -0
  19. package/coverage/src/lib/call-monitor.js.html +700 -0
  20. package/coverage/src/lib/callbook.js.html +1183 -0
  21. package/coverage/src/lib/claude-subagent.js.html +2173 -0
  22. package/coverage/src/lib/client.js.html +2134 -0
  23. package/coverage/src/lib/config.js.html +1525 -0
  24. package/coverage/src/lib/conversation-driver.js.html +1909 -0
  25. package/coverage/src/lib/conversations.js.html +2575 -0
  26. package/coverage/src/lib/crypto.js.html +424 -0
  27. package/coverage/src/lib/dashboard-events.js.html +724 -0
  28. package/coverage/src/lib/disclosure.js.html +2461 -0
  29. package/coverage/src/lib/external-ip.js.html +718 -0
  30. package/coverage/src/lib/index.html +506 -0
  31. package/coverage/src/lib/invite-host.js.html +754 -0
  32. package/coverage/src/lib/local-request.js.html +292 -0
  33. package/coverage/src/lib/logger.js.html +2116 -0
  34. package/coverage/src/lib/openclaw-integration.js.html +1102 -0
  35. package/coverage/src/lib/pid-file.js.html +394 -0
  36. package/coverage/src/lib/port-scanner.js.html +334 -0
  37. package/coverage/src/lib/prompt-template.js.html +1150 -0
  38. package/coverage/src/lib/runtime-adapter.js.html +2188 -0
  39. package/coverage/src/lib/summarizer.js.html +553 -0
  40. package/coverage/src/lib/summary-formatter.js.html +589 -0
  41. package/coverage/src/lib/summary-prompt.js.html +694 -0
  42. package/coverage/src/lib/tokens.js.html +2689 -0
  43. package/coverage/src/lib/turn-timeout.js.html +241 -0
  44. package/coverage/src/lib/update-checker.js.html +364 -0
  45. package/coverage/src/lib/update-manager.js.html +1024 -0
  46. package/coverage/src/routes/a2a.js.html +3724 -0
  47. package/coverage/src/routes/callbook.js.html +511 -0
  48. package/coverage/src/routes/dashboard.js.html +4819 -0
  49. package/coverage/src/routes/index.html +146 -0
  50. package/coverage/src/server.js.html +3622 -0
  51. package/coverage/tmp/coverage-1605378-1772576706365-0.json +1 -0
  52. package/coverage/tmp/coverage-1605384-1772576607459-0.json +1 -0
  53. package/coverage/tmp/coverage-1605410-1772576631155-0.json +1 -0
  54. package/coverage/tmp/coverage-1606942-1772576636869-0.json +1 -0
  55. package/coverage/tmp/coverage-1607004-1772576637454-0.json +1 -0
  56. package/coverage/tmp/coverage-1607044-1772576637876-0.json +1 -0
  57. package/coverage/tmp/coverage-1607096-1772576638356-0.json +1 -0
  58. package/coverage/tmp/coverage-1607145-1772576638777-0.json +1 -0
  59. package/coverage/tmp/coverage-1607201-1772576639277-0.json +1 -0
  60. package/coverage/tmp/coverage-1607247-1772576639755-0.json +1 -0
  61. package/coverage/tmp/coverage-1607317-1772576640083-0.json +1 -0
  62. package/coverage/tmp/coverage-1607381-1772576640465-0.json +1 -0
  63. package/coverage/tmp/coverage-1607446-1772576640868-0.json +1 -0
  64. package/coverage/tmp/coverage-1607501-1772576641662-0.json +1 -0
  65. package/coverage/tmp/coverage-1607534-1772576641565-0.json +1 -0
  66. package/coverage/tmp/coverage-1607627-1772576641871-0.json +1 -0
  67. package/coverage/tmp/coverage-1607665-1772576642172-0.json +1 -0
  68. package/coverage/tmp/coverage-1607714-1772576642577-0.json +1 -0
  69. package/coverage/tmp/coverage-1607788-1772576643466-0.json +1 -0
  70. package/coverage/tmp/coverage-1607924-1772576644678-0.json +1 -0
  71. package/coverage/tmp/coverage-1607978-1772576645154-0.json +1 -0
  72. package/coverage/tmp/coverage-1608035-1772576645564-0.json +1 -0
  73. package/coverage/tmp/coverage-1608106-1772576645967-0.json +1 -0
  74. package/coverage/tmp/coverage-1608179-1772576648656-0.json +1 -0
  75. package/coverage/tmp/coverage-1608196-1772576647367-0.json +1 -0
  76. package/coverage/tmp/coverage-1608217-1772576648557-0.json +1 -0
  77. package/coverage/tmp/coverage-1608256-1772576651378-0.json +1 -0
  78. package/coverage/tmp/coverage-1608265-1772576650058-0.json +1 -0
  79. package/coverage/tmp/coverage-1608289-1772576651358-0.json +1 -0
  80. package/coverage/tmp/coverage-1608591-1772576660465-0.json +1 -0
  81. package/coverage/tmp/coverage-1608648-1772576659272-0.json +1 -0
  82. package/coverage/tmp/coverage-1608665-1772576660374-0.json +1 -0
  83. package/coverage/tmp/coverage-1608677-1772576661268-0.json +1 -0
  84. package/coverage/tmp/coverage-1608684-1772576663968-0.json +1 -0
  85. package/coverage/tmp/coverage-1608692-1772576662575-0.json +1 -0
  86. package/coverage/tmp/coverage-1608701-1772576663873-0.json +1 -0
  87. package/coverage/tmp/coverage-1608718-1772576666674-0.json +1 -0
  88. package/coverage/tmp/coverage-1608725-1772576665463-0.json +1 -0
  89. package/coverage/tmp/coverage-1608738-1772576666577-0.json +1 -0
  90. package/coverage/tmp/coverage-1608753-1772576669664-0.json +1 -0
  91. package/coverage/tmp/coverage-1608763-1772576668275-0.json +1 -0
  92. package/coverage/tmp/coverage-1608771-1772576669563-0.json +1 -0
  93. package/coverage/tmp/coverage-1608828-1772576676574-0.json +1 -0
  94. package/coverage/tmp/coverage-1609244-1772576675272-0.json +1 -0
  95. package/coverage/tmp/coverage-1609342-1772576676478-0.json +1 -0
  96. package/coverage/tmp/coverage-1609450-1772576686954-0.json +1 -0
  97. package/coverage/tmp/coverage-1609841-1772576685466-0.json +1 -0
  98. package/coverage/tmp/coverage-1609925-1772576686855-0.json +1 -0
  99. package/coverage/tmp/coverage-1610399-1772576692469-0.json +1 -0
  100. package/coverage/tmp/coverage-1611283-1772576703062-0.json +1 -0
  101. package/coverage/tmp/coverage-1611294-1772576703755-0.json +1 -0
  102. package/docs/assessments/2026-02-27-google-a2a-protocol-assessment.md +292 -0
  103. package/docs/plans/2026-03-01-a2a-68-openclaw-integration-tests.md +676 -0
  104. package/docs/plans/2026-03-01-a2a-77-invoke-security-tests.md +661 -0
  105. package/docs/plans/2026-03-03-a2a-91-macos-packaging-plan.md +144 -0
  106. package/docs/signing-setup.md +49 -0
  107. package/eslint.config.js +16 -0
  108. package/knip.json +17 -0
  109. package/native/macos/certs/appldevcert.cer +0 -0
  110. package/native/macos/src-tauri/binaries/.gitkeep +0 -0
  111. package/native/macos/src-tauri/capabilities/default.json +11 -1
  112. package/native/macos/src-tauri/entitlements.plist +14 -0
  113. package/native/macos/src-tauri/src/discovery.rs +14 -3
  114. package/native/macos/src-tauri/src/health.rs +4 -0
  115. package/native/macos/src-tauri/src/lib.rs +52 -11
  116. package/native/macos/src-tauri/src/server.rs +262 -26
  117. package/native/macos/src-tauri/tauri.conf.json +13 -4
  118. package/package.json +16 -2
  119. package/pkg.config.json +14 -0
  120. package/scripts/build-standalone.sh +106 -0
  121. package/scripts/install-openclaw.js +3 -5
  122. package/scripts/smoke-test-standalone.sh +101 -0
  123. package/scripts/sync-version.sh +28 -0
  124. package/scripts/verify-app-bundle.sh +34 -0
  125. package/src/lib/agent-card.js +111 -0
  126. package/src/lib/client.js +290 -49
  127. package/src/lib/conversations.js +2 -0
  128. package/src/lib/local-request.js +69 -0
  129. package/src/lib/logger.js +2 -0
  130. package/src/lib/runtime-adapter.js +41 -1
  131. package/src/routes/a2a.js +393 -66
  132. package/src/routes/dashboard.js +1 -27
  133. package/src/server.js +19 -0
  134. package/.maestro/inbox/release-workflow-spam.md +0 -25
@@ -0,0 +1,661 @@
1
+ # A2A-77: Invoke Handler Security Boundary Tests
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add integration tests covering the security boundary of POST /api/a2a/invoke — expired tokens, conversation isolation, store failure graceful degradation, timeout bounding, and message validation edge cases.
6
+
7
+ **Architecture:** Create a single test file `test/integration/a2a-invoke-security.test.js` following the existing `call-flow.test.js` pattern — mount `createRoutes()` on Express, use `helpers.request()` for HTTP assertions. Each test group covers one security dimension through actual HTTP requests.
8
+
9
+ **Tech Stack:** Node.js, Express, custom test runner (`test/run.js`), `test/helpers.js` (createTestApp, request)
10
+
11
+ ---
12
+
13
+ ### Task 1: Scaffold test file and token expiration tests
14
+
15
+ **Files:**
16
+ - Create: `test/integration/a2a-invoke-security.test.js`
17
+
18
+ **Step 1: Write the test file with token expiration tests**
19
+
20
+ Create `test/integration/a2a-invoke-security.test.js`:
21
+
22
+ ```js
23
+ /**
24
+ * A2A-77: Integration tests for invoke handler security boundary
25
+ *
26
+ * Covers: token expiration enforcement, conversation isolation,
27
+ * store failure graceful degradation, timeout bounding,
28
+ * message validation edge cases.
29
+ */
30
+
31
+ module.exports = function (test, assert, helpers) {
32
+ let appCtx = null;
33
+ let client = null;
34
+
35
+ function setup(messageHandler) {
36
+ appCtx = helpers.createTestApp({
37
+ handleMessage: messageHandler || async function (message, context) {
38
+ return {
39
+ text: `security-test received: ${message.slice(0, 80)}`,
40
+ canContinue: true
41
+ };
42
+ }
43
+ });
44
+ client = helpers.request(appCtx.app);
45
+ return appCtx;
46
+ }
47
+
48
+ async function teardown() {
49
+ if (client) await client.close();
50
+ if (appCtx) appCtx.cleanup();
51
+ appCtx = null;
52
+ client = null;
53
+ }
54
+
55
+ // ── Token Expiration ────────────────────────────────────────────
56
+
57
+ test('expired token returns 401 unauthorized', async () => {
58
+ const { tokenStore } = setup();
59
+ // Create token that already expired (expires_at in the past)
60
+ const { token, record } = tokenStore.create({
61
+ name: 'ExpiredAgent',
62
+ permissions: 'public',
63
+ expires: '1h'
64
+ });
65
+ // Manually backdate expires_at to the past
66
+ const db = JSON.parse(require('fs').readFileSync(
67
+ require('path').join(appCtx.dir, 'a2a-tokens.json'), 'utf8'
68
+ ));
69
+ const entry = db.tokens.find(t => t.id === record.id);
70
+ entry.expires_at = new Date(Date.now() - 60000).toISOString();
71
+ require('fs').writeFileSync(
72
+ require('path').join(appCtx.dir, 'a2a-tokens.json'),
73
+ JSON.stringify(db, null, 2)
74
+ );
75
+
76
+ const res = await client.post('/api/a2a/invoke', {
77
+ headers: { Authorization: `Bearer ${token}` },
78
+ body: { message: 'Hello from expired token' }
79
+ });
80
+
81
+ assert.equal(res.statusCode, 401);
82
+ assert.equal(res.body.error, 'unauthorized');
83
+ await teardown();
84
+ });
85
+
86
+ test('valid non-expired token returns 200', async () => {
87
+ const { tokenStore } = setup();
88
+ const { token } = tokenStore.create({
89
+ name: 'ValidAgent',
90
+ permissions: 'public',
91
+ expires: '1h'
92
+ });
93
+
94
+ const res = await client.post('/api/a2a/invoke', {
95
+ headers: { Authorization: `Bearer ${token}` },
96
+ body: { message: 'Hello from valid token' }
97
+ });
98
+
99
+ assert.equal(res.statusCode, 200);
100
+ assert.equal(res.body.success, true);
101
+ await teardown();
102
+ });
103
+
104
+ test('tokens_remaining decrements on successful invoke', async () => {
105
+ const { tokenStore } = setup();
106
+ const { token } = tokenStore.create({
107
+ name: 'CountdownAgent',
108
+ permissions: 'public',
109
+ maxCalls: 5
110
+ });
111
+
112
+ const res1 = await client.post('/api/a2a/invoke', {
113
+ headers: { Authorization: `Bearer ${token}` },
114
+ body: { message: 'Call 1' }
115
+ });
116
+ assert.equal(res1.statusCode, 200);
117
+ assert.equal(res1.body.tokens_remaining, 4);
118
+
119
+ const res2 = await client.post('/api/a2a/invoke', {
120
+ headers: { Authorization: `Bearer ${token}` },
121
+ body: { message: 'Call 2' }
122
+ });
123
+ assert.equal(res2.statusCode, 200);
124
+ assert.equal(res2.body.tokens_remaining, 3);
125
+ await teardown();
126
+ });
127
+
128
+ test('max_calls exhausted token returns 401', async () => {
129
+ const { tokenStore } = setup();
130
+ const { token } = tokenStore.create({
131
+ name: 'LimitedAgent',
132
+ permissions: 'public',
133
+ maxCalls: 2
134
+ });
135
+
136
+ // Use up both calls
137
+ await client.post('/api/a2a/invoke', {
138
+ headers: { Authorization: `Bearer ${token}` },
139
+ body: { message: 'Call 1' }
140
+ });
141
+ await client.post('/api/a2a/invoke', {
142
+ headers: { Authorization: `Bearer ${token}` },
143
+ body: { message: 'Call 2' }
144
+ });
145
+
146
+ // Third call should fail
147
+ const res = await client.post('/api/a2a/invoke', {
148
+ headers: { Authorization: `Bearer ${token}` },
149
+ body: { message: 'Call 3 — should fail' }
150
+ });
151
+
152
+ assert.equal(res.statusCode, 401);
153
+ assert.equal(res.body.error, 'unauthorized');
154
+ await teardown();
155
+ });
156
+ };
157
+ ```
158
+
159
+ **Step 2: Run tests to verify they pass**
160
+
161
+ Run: `node test/run.js --integration --filter invoke-security`
162
+ Expected: 4 passing
163
+
164
+ **Step 3: Commit**
165
+
166
+ ```bash
167
+ git add test/integration/a2a-invoke-security.test.js
168
+ git commit -m "test(a2a-77): add token expiration security tests"
169
+ ```
170
+
171
+ ---
172
+
173
+ ### Task 2: Add conversation isolation tests
174
+
175
+ **Files:**
176
+ - Modify: `test/integration/a2a-invoke-security.test.js`
177
+
178
+ **Step 1: Add conversation isolation tests after the token expiration group**
179
+
180
+ Append inside the module.exports function, before the closing `};`:
181
+
182
+ ```js
183
+ // ── Conversation Isolation ──────────────────────────────────────
184
+
185
+ test('different tokens get different conversation_ids', async () => {
186
+ const { tokenStore } = setup();
187
+ const { token: tokenA } = tokenStore.create({ name: 'AgentA', permissions: 'public' });
188
+ const { token: tokenB } = tokenStore.create({ name: 'AgentB', permissions: 'public' });
189
+
190
+ const resA = await client.post('/api/a2a/invoke', {
191
+ headers: { Authorization: `Bearer ${tokenA}` },
192
+ body: { message: 'Hello from A' }
193
+ });
194
+ const resB = await client.post('/api/a2a/invoke', {
195
+ headers: { Authorization: `Bearer ${tokenB}` },
196
+ body: { message: 'Hello from B' }
197
+ });
198
+
199
+ assert.equal(resA.statusCode, 200);
200
+ assert.equal(resB.statusCode, 200);
201
+ assert.notEqual(resA.body.conversation_id, resB.body.conversation_id);
202
+ await teardown();
203
+ });
204
+
205
+ test('providing conversation_id reuses it in response', async () => {
206
+ const { tokenStore } = setup();
207
+ const { token } = tokenStore.create({ name: 'AgentC', permissions: 'public' });
208
+
209
+ // First call — get a conversation_id
210
+ const res1 = await client.post('/api/a2a/invoke', {
211
+ headers: { Authorization: `Bearer ${token}` },
212
+ body: { message: 'Start conversation' }
213
+ });
214
+ assert.equal(res1.statusCode, 200);
215
+ const convId = res1.body.conversation_id;
216
+ assert.ok(convId.startsWith('conv_'));
217
+
218
+ // Second call — reuse the conversation_id
219
+ const res2 = await client.post('/api/a2a/invoke', {
220
+ headers: { Authorization: `Bearer ${token}` },
221
+ body: { message: 'Continue conversation', conversation_id: convId }
222
+ });
223
+ assert.equal(res2.statusCode, 200);
224
+ assert.equal(res2.body.conversation_id, convId);
225
+ await teardown();
226
+ });
227
+
228
+ test('token A sending token B conversation_id gets that ID back but separate context', async () => {
229
+ let capturedContexts = [];
230
+ const { tokenStore } = setup(async (msg, ctx) => {
231
+ capturedContexts.push({ token_id: ctx.token_id, conversation_id: ctx.conversation_id });
232
+ return { text: 'ok', canContinue: true };
233
+ });
234
+ const { token: tokenA } = tokenStore.create({ name: 'AgentA', permissions: 'public' });
235
+ const { token: tokenB } = tokenStore.create({ name: 'AgentB', permissions: 'public' });
236
+
237
+ // Agent B starts a conversation
238
+ const resB = await client.post('/api/a2a/invoke', {
239
+ headers: { Authorization: `Bearer ${tokenB}` },
240
+ body: { message: 'B starts' }
241
+ });
242
+ const convIdB = resB.body.conversation_id;
243
+
244
+ // Agent A tries to use B's conversation_id
245
+ const resA = await client.post('/api/a2a/invoke', {
246
+ headers: { Authorization: `Bearer ${tokenA}` },
247
+ body: { message: 'A hijacks', conversation_id: convIdB }
248
+ });
249
+
250
+ // The handler accepts the conversation_id (no cross-token validation at this layer)
251
+ // but the a2aContext carries token A's identity, not token B's
252
+ assert.equal(resA.statusCode, 200);
253
+ assert.equal(resA.body.conversation_id, convIdB);
254
+ // The handler received token A's identity even though conversation_id was B's
255
+ const ctxA = capturedContexts.find(c => c.token_id !== capturedContexts[0].token_id) || capturedContexts[1];
256
+ assert.ok(ctxA, 'handler received context for second call');
257
+ await teardown();
258
+ });
259
+ ```
260
+
261
+ **Step 2: Run tests**
262
+
263
+ Run: `node test/run.js --integration --filter invoke-security`
264
+ Expected: 7 passing
265
+
266
+ **Step 3: Commit**
267
+
268
+ ```bash
269
+ git add test/integration/a2a-invoke-security.test.js
270
+ git commit -m "test(a2a-77): add conversation isolation tests"
271
+ ```
272
+
273
+ ---
274
+
275
+ ### Task 3: Add handler error recovery tests
276
+
277
+ **Files:**
278
+ - Modify: `test/integration/a2a-invoke-security.test.js`
279
+
280
+ **Step 1: Add handler error recovery tests**
281
+
282
+ Append inside the module.exports function:
283
+
284
+ ```js
285
+ // ── Handler Error Recovery ──────────────────────────────────────
286
+
287
+ test('handleMessage throwing returns 500 internal_error', async () => {
288
+ setup(async () => { throw new Error('Runtime exploded'); });
289
+ const { token } = appCtx.tokenStore.create({ name: 'CrashAgent', permissions: 'public' });
290
+
291
+ const res = await client.post('/api/a2a/invoke', {
292
+ headers: { Authorization: `Bearer ${token}` },
293
+ body: { message: 'Trigger crash' }
294
+ });
295
+
296
+ assert.equal(res.statusCode, 500);
297
+ assert.equal(res.body.error, 'internal_error');
298
+ assert.equal(res.body.success, false);
299
+ await teardown();
300
+ });
301
+
302
+ test('handleMessage returning null text still produces a response', async () => {
303
+ setup(async () => ({ text: null, canContinue: false }));
304
+ const { token } = appCtx.tokenStore.create({ name: 'NullAgent', permissions: 'public' });
305
+
306
+ const res = await client.post('/api/a2a/invoke', {
307
+ headers: { Authorization: `Bearer ${token}` },
308
+ body: { message: 'Get null back' }
309
+ });
310
+
311
+ assert.equal(res.statusCode, 200);
312
+ assert.equal(res.body.success, true);
313
+ assert.equal(res.body.response, null);
314
+ await teardown();
315
+ });
316
+ ```
317
+
318
+ **Step 2: Run tests**
319
+
320
+ Run: `node test/run.js --integration --filter invoke-security`
321
+ Expected: 9 passing
322
+
323
+ **Step 3: Commit**
324
+
325
+ ```bash
326
+ git add test/integration/a2a-invoke-security.test.js
327
+ git commit -m "test(a2a-77): add handler error recovery tests"
328
+ ```
329
+
330
+ ---
331
+
332
+ ### Task 4: Add timeout bounding tests
333
+
334
+ **Files:**
335
+ - Modify: `test/integration/a2a-invoke-security.test.js`
336
+
337
+ **Step 1: Add timeout bounding tests**
338
+
339
+ Append inside the module.exports function:
340
+
341
+ ```js
342
+ // ── Timeout Bounding ────────────────────────────────────────────
343
+
344
+ test('timeout_seconds below minimum is clamped to 5s', async () => {
345
+ let capturedTimeout = null;
346
+ setup(async (msg, ctx, opts) => {
347
+ capturedTimeout = opts?.timeout;
348
+ return { text: 'ok', canContinue: true };
349
+ });
350
+ const { token } = appCtx.tokenStore.create({ name: 'TimeoutAgent', permissions: 'public' });
351
+
352
+ await client.post('/api/a2a/invoke', {
353
+ headers: { Authorization: `Bearer ${token}` },
354
+ body: { message: 'Test timeout', timeout_seconds: 1 }
355
+ });
356
+
357
+ assert.equal(capturedTimeout, 5000); // MIN_TIMEOUT_SECONDS * 1000
358
+ await teardown();
359
+ });
360
+
361
+ test('timeout_seconds above maximum is clamped to 300s', async () => {
362
+ let capturedTimeout = null;
363
+ setup(async (msg, ctx, opts) => {
364
+ capturedTimeout = opts?.timeout;
365
+ return { text: 'ok', canContinue: true };
366
+ });
367
+ const { token } = appCtx.tokenStore.create({ name: 'TimeoutAgent', permissions: 'public' });
368
+
369
+ await client.post('/api/a2a/invoke', {
370
+ headers: { Authorization: `Bearer ${token}` },
371
+ body: { message: 'Test timeout', timeout_seconds: 999 }
372
+ });
373
+
374
+ assert.equal(capturedTimeout, 300000); // MAX_TIMEOUT_SECONDS * 1000
375
+ await teardown();
376
+ });
377
+
378
+ test('timeout_seconds within range is passed through', async () => {
379
+ let capturedTimeout = null;
380
+ setup(async (msg, ctx, opts) => {
381
+ capturedTimeout = opts?.timeout;
382
+ return { text: 'ok', canContinue: true };
383
+ });
384
+ const { token } = appCtx.tokenStore.create({ name: 'TimeoutAgent', permissions: 'public' });
385
+
386
+ await client.post('/api/a2a/invoke', {
387
+ headers: { Authorization: `Bearer ${token}` },
388
+ body: { message: 'Test timeout', timeout_seconds: 30 }
389
+ });
390
+
391
+ assert.equal(capturedTimeout, 30000); // 30 * 1000
392
+ await teardown();
393
+ });
394
+
395
+ test('non-numeric timeout_seconds defaults to 60s', async () => {
396
+ let capturedTimeout = null;
397
+ setup(async (msg, ctx, opts) => {
398
+ capturedTimeout = opts?.timeout;
399
+ return { text: 'ok', canContinue: true };
400
+ });
401
+ const { token } = appCtx.tokenStore.create({ name: 'TimeoutAgent', permissions: 'public' });
402
+
403
+ await client.post('/api/a2a/invoke', {
404
+ headers: { Authorization: `Bearer ${token}` },
405
+ body: { message: 'Test timeout', timeout_seconds: 'not-a-number' }
406
+ });
407
+
408
+ assert.equal(capturedTimeout, 60000); // fallback 60 * 1000
409
+ await teardown();
410
+ });
411
+ ```
412
+
413
+ **Step 2: Run tests**
414
+
415
+ Run: `node test/run.js --integration --filter invoke-security`
416
+ Expected: 13 passing
417
+
418
+ **Step 3: Commit**
419
+
420
+ ```bash
421
+ git add test/integration/a2a-invoke-security.test.js
422
+ git commit -m "test(a2a-77): add timeout bounding tests"
423
+ ```
424
+
425
+ ---
426
+
427
+ ### Task 5: Add message validation edge case tests
428
+
429
+ **Files:**
430
+ - Modify: `test/integration/a2a-invoke-security.test.js`
431
+
432
+ **Step 1: Add message validation edge case tests**
433
+
434
+ Append inside the module.exports function:
435
+
436
+ ```js
437
+ // ── Message Validation Edge Cases ───────────────────────────────
438
+
439
+ test('empty string message returns 400 missing_message', async () => {
440
+ const { tokenStore } = setup();
441
+ const { token } = tokenStore.create({ name: 'EdgeAgent', permissions: 'public' });
442
+
443
+ const res = await client.post('/api/a2a/invoke', {
444
+ headers: { Authorization: `Bearer ${token}` },
445
+ body: { message: '' }
446
+ });
447
+
448
+ // Empty string is falsy → missing_message
449
+ assert.equal(res.statusCode, 400);
450
+ assert.equal(res.body.error, 'missing_message');
451
+ await teardown();
452
+ });
453
+
454
+ test('numeric message returns 400 invalid_message', async () => {
455
+ const { tokenStore } = setup();
456
+ const { token } = tokenStore.create({ name: 'EdgeAgent', permissions: 'public' });
457
+
458
+ const res = await client.post('/api/a2a/invoke', {
459
+ headers: { Authorization: `Bearer ${token}` },
460
+ body: { message: 12345 }
461
+ });
462
+
463
+ assert.equal(res.statusCode, 400);
464
+ assert.equal(res.body.error, 'invalid_message');
465
+ await teardown();
466
+ });
467
+
468
+ test('message at exactly 10000 chars returns 200', async () => {
469
+ const { tokenStore } = setup();
470
+ const { token } = tokenStore.create({ name: 'BoundaryAgent', permissions: 'public' });
471
+
472
+ const res = await client.post('/api/a2a/invoke', {
473
+ headers: { Authorization: `Bearer ${token}` },
474
+ body: { message: 'x'.repeat(10000) }
475
+ });
476
+
477
+ assert.equal(res.statusCode, 200);
478
+ assert.equal(res.body.success, true);
479
+ await teardown();
480
+ });
481
+
482
+ test('message at 10001 chars returns 400 invalid_message', async () => {
483
+ const { tokenStore } = setup();
484
+ const { token } = tokenStore.create({ name: 'BoundaryAgent', permissions: 'public' });
485
+
486
+ const res = await client.post('/api/a2a/invoke', {
487
+ headers: { Authorization: `Bearer ${token}` },
488
+ body: { message: 'x'.repeat(10001) }
489
+ });
490
+
491
+ assert.equal(res.statusCode, 400);
492
+ assert.equal(res.body.error, 'invalid_message');
493
+ await teardown();
494
+ });
495
+
496
+ test('object message returns 400 invalid_message', async () => {
497
+ const { tokenStore } = setup();
498
+ const { token } = tokenStore.create({ name: 'EdgeAgent', permissions: 'public' });
499
+
500
+ const res = await client.post('/api/a2a/invoke', {
501
+ headers: { Authorization: `Bearer ${token}` },
502
+ body: { message: { text: 'wrapped in object' } }
503
+ });
504
+
505
+ assert.equal(res.statusCode, 400);
506
+ assert.equal(res.body.error, 'invalid_message');
507
+ await teardown();
508
+ });
509
+ ```
510
+
511
+ **Step 2: Run tests**
512
+
513
+ Run: `node test/run.js --integration --filter invoke-security`
514
+ Expected: 18 passing
515
+
516
+ **Step 3: Commit**
517
+
518
+ ```bash
519
+ git add test/integration/a2a-invoke-security.test.js
520
+ git commit -m "test(a2a-77): add message validation edge case tests"
521
+ ```
522
+
523
+ ---
524
+
525
+ ### Task 6: Add caller sanitization and response metadata tests
526
+
527
+ **Files:**
528
+ - Modify: `test/integration/a2a-invoke-security.test.js`
529
+
530
+ **Step 1: Add caller sanitization and response metadata tests**
531
+
532
+ Append inside the module.exports function:
533
+
534
+ ```js
535
+ // ── Caller Sanitization ─────────────────────────────────────────
536
+
537
+ test('caller fields are truncated to max lengths', async () => {
538
+ let capturedCaller = null;
539
+ setup(async (msg, ctx) => {
540
+ capturedCaller = ctx.caller;
541
+ return { text: 'ok', canContinue: true };
542
+ });
543
+ const { token } = appCtx.tokenStore.create({ name: 'SanitizeAgent', permissions: 'public' });
544
+
545
+ await client.post('/api/a2a/invoke', {
546
+ headers: { Authorization: `Bearer ${token}` },
547
+ body: {
548
+ message: 'Test sanitization',
549
+ caller: {
550
+ name: 'A'.repeat(200), // max 100
551
+ owner: 'B'.repeat(200), // max 100
552
+ instance: 'C'.repeat(300), // max 200
553
+ context: 'D'.repeat(600) // max 500
554
+ }
555
+ }
556
+ });
557
+
558
+ assert.equal(capturedCaller.name.length, 100);
559
+ assert.equal(capturedCaller.owner.length, 100);
560
+ assert.equal(capturedCaller.instance.length, 200);
561
+ assert.equal(capturedCaller.context.length, 500);
562
+ await teardown();
563
+ });
564
+
565
+ test('missing caller produces empty caller object', async () => {
566
+ let capturedCaller = null;
567
+ setup(async (msg, ctx) => {
568
+ capturedCaller = ctx.caller;
569
+ return { text: 'ok', canContinue: true };
570
+ });
571
+ const { token } = appCtx.tokenStore.create({ name: 'NoCaller', permissions: 'public' });
572
+
573
+ await client.post('/api/a2a/invoke', {
574
+ headers: { Authorization: `Bearer ${token}` },
575
+ body: { message: 'No caller field' }
576
+ });
577
+
578
+ assert.deepEqual(capturedCaller, {});
579
+ await teardown();
580
+ });
581
+
582
+ // ── Response Metadata ───────────────────────────────────────────
583
+
584
+ test('response includes trace_id and request_id', async () => {
585
+ const { tokenStore } = setup();
586
+ const { token } = tokenStore.create({ name: 'MetaAgent', permissions: 'public' });
587
+
588
+ const res = await client.post('/api/a2a/invoke', {
589
+ headers: { Authorization: `Bearer ${token}` },
590
+ body: { message: 'Metadata check' }
591
+ });
592
+
593
+ assert.equal(res.statusCode, 200);
594
+ assert.ok(res.body.trace_id, 'response has trace_id');
595
+ assert.ok(res.body.request_id, 'response has request_id');
596
+ assert.ok(res.headers['x-trace-id'], 'header has x-trace-id');
597
+ assert.ok(res.headers['x-request-id'], 'header has x-request-id');
598
+ await teardown();
599
+ });
600
+
601
+ test('client-provided trace_id is honored', async () => {
602
+ const { tokenStore } = setup();
603
+ const { token } = tokenStore.create({ name: 'TraceAgent', permissions: 'public' });
604
+
605
+ const res = await client.post('/api/a2a/invoke', {
606
+ headers: {
607
+ Authorization: `Bearer ${token}`,
608
+ 'x-trace-id': 'custom-trace-abc'
609
+ },
610
+ body: { message: 'Trace test' }
611
+ });
612
+
613
+ assert.equal(res.statusCode, 200);
614
+ assert.equal(res.body.trace_id, 'custom-trace-abc');
615
+ assert.equal(res.headers['x-trace-id'], 'custom-trace-abc');
616
+ await teardown();
617
+ });
618
+ ```
619
+
620
+ **Step 2: Run tests**
621
+
622
+ Run: `node test/run.js --integration --filter invoke-security`
623
+ Expected: 22 passing
624
+
625
+ **Step 3: Commit**
626
+
627
+ ```bash
628
+ git add test/integration/a2a-invoke-security.test.js
629
+ git commit -m "test(a2a-77): add caller sanitization and response metadata tests"
630
+ ```
631
+
632
+ ---
633
+
634
+ ### Task 7: Quality gate
635
+
636
+ **Step 1: Run full test suite**
637
+
638
+ Run: `npm test`
639
+ Expected: All tests pass (existing + ~22 new), 0 failures
640
+
641
+ **Step 2: Verify no lint/knip regressions**
642
+
643
+ Run: `npx biome check src/**/*.js` (test files are excluded)
644
+ Run: `npx knip`
645
+ Expected: No new warnings
646
+
647
+ ---
648
+
649
+ ### Task 8: Ship it
650
+
651
+ **Step 1: Commit, push, PR, merge, update Linear**
652
+
653
+ ```bash
654
+ git checkout -b feature/a2a-77
655
+ git add test/integration/a2a-invoke-security.test.js docs/plans/2026-03-01-a2a-77-invoke-security-tests.md
656
+ git commit -m "test(a2a-77): add integration tests for invoke handler security boundary"
657
+ git push origin feature/a2a-77
658
+ gh pr create --title "test(a2a-77): add integration tests for invoke handler security boundary" --body "..."
659
+ gh pr merge <PR_NUMBER> --squash --delete-branch
660
+ # Update Linear A2A-77 → Done
661
+ ```