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.
- package/.a2a-manifest.json +2 -2
- package/.c8rc.json +16 -0
- package/.node-version +1 -0
- package/.serena/project.yml +126 -0
- package/ARCHITECTURE.md +40 -16
- package/CONVENTIONS.md +39 -6
- package/biome.json +27 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.js.html +313 -0
- package/coverage/src/lib/agent-card.js.html +418 -0
- package/coverage/src/lib/call-monitor.js.html +700 -0
- package/coverage/src/lib/callbook.js.html +1183 -0
- package/coverage/src/lib/claude-subagent.js.html +2173 -0
- package/coverage/src/lib/client.js.html +2134 -0
- package/coverage/src/lib/config.js.html +1525 -0
- package/coverage/src/lib/conversation-driver.js.html +1909 -0
- package/coverage/src/lib/conversations.js.html +2575 -0
- package/coverage/src/lib/crypto.js.html +424 -0
- package/coverage/src/lib/dashboard-events.js.html +724 -0
- package/coverage/src/lib/disclosure.js.html +2461 -0
- package/coverage/src/lib/external-ip.js.html +718 -0
- package/coverage/src/lib/index.html +506 -0
- package/coverage/src/lib/invite-host.js.html +754 -0
- package/coverage/src/lib/local-request.js.html +292 -0
- package/coverage/src/lib/logger.js.html +2116 -0
- package/coverage/src/lib/openclaw-integration.js.html +1102 -0
- package/coverage/src/lib/pid-file.js.html +394 -0
- package/coverage/src/lib/port-scanner.js.html +334 -0
- package/coverage/src/lib/prompt-template.js.html +1150 -0
- package/coverage/src/lib/runtime-adapter.js.html +2188 -0
- package/coverage/src/lib/summarizer.js.html +553 -0
- package/coverage/src/lib/summary-formatter.js.html +589 -0
- package/coverage/src/lib/summary-prompt.js.html +694 -0
- package/coverage/src/lib/tokens.js.html +2689 -0
- package/coverage/src/lib/turn-timeout.js.html +241 -0
- package/coverage/src/lib/update-checker.js.html +364 -0
- package/coverage/src/lib/update-manager.js.html +1024 -0
- package/coverage/src/routes/a2a.js.html +3724 -0
- package/coverage/src/routes/callbook.js.html +511 -0
- package/coverage/src/routes/dashboard.js.html +4819 -0
- package/coverage/src/routes/index.html +146 -0
- package/coverage/src/server.js.html +3622 -0
- package/coverage/tmp/coverage-1605378-1772576706365-0.json +1 -0
- package/coverage/tmp/coverage-1605384-1772576607459-0.json +1 -0
- package/coverage/tmp/coverage-1605410-1772576631155-0.json +1 -0
- package/coverage/tmp/coverage-1606942-1772576636869-0.json +1 -0
- package/coverage/tmp/coverage-1607004-1772576637454-0.json +1 -0
- package/coverage/tmp/coverage-1607044-1772576637876-0.json +1 -0
- package/coverage/tmp/coverage-1607096-1772576638356-0.json +1 -0
- package/coverage/tmp/coverage-1607145-1772576638777-0.json +1 -0
- package/coverage/tmp/coverage-1607201-1772576639277-0.json +1 -0
- package/coverage/tmp/coverage-1607247-1772576639755-0.json +1 -0
- package/coverage/tmp/coverage-1607317-1772576640083-0.json +1 -0
- package/coverage/tmp/coverage-1607381-1772576640465-0.json +1 -0
- package/coverage/tmp/coverage-1607446-1772576640868-0.json +1 -0
- package/coverage/tmp/coverage-1607501-1772576641662-0.json +1 -0
- package/coverage/tmp/coverage-1607534-1772576641565-0.json +1 -0
- package/coverage/tmp/coverage-1607627-1772576641871-0.json +1 -0
- package/coverage/tmp/coverage-1607665-1772576642172-0.json +1 -0
- package/coverage/tmp/coverage-1607714-1772576642577-0.json +1 -0
- package/coverage/tmp/coverage-1607788-1772576643466-0.json +1 -0
- package/coverage/tmp/coverage-1607924-1772576644678-0.json +1 -0
- package/coverage/tmp/coverage-1607978-1772576645154-0.json +1 -0
- package/coverage/tmp/coverage-1608035-1772576645564-0.json +1 -0
- package/coverage/tmp/coverage-1608106-1772576645967-0.json +1 -0
- package/coverage/tmp/coverage-1608179-1772576648656-0.json +1 -0
- package/coverage/tmp/coverage-1608196-1772576647367-0.json +1 -0
- package/coverage/tmp/coverage-1608217-1772576648557-0.json +1 -0
- package/coverage/tmp/coverage-1608256-1772576651378-0.json +1 -0
- package/coverage/tmp/coverage-1608265-1772576650058-0.json +1 -0
- package/coverage/tmp/coverage-1608289-1772576651358-0.json +1 -0
- package/coverage/tmp/coverage-1608591-1772576660465-0.json +1 -0
- package/coverage/tmp/coverage-1608648-1772576659272-0.json +1 -0
- package/coverage/tmp/coverage-1608665-1772576660374-0.json +1 -0
- package/coverage/tmp/coverage-1608677-1772576661268-0.json +1 -0
- package/coverage/tmp/coverage-1608684-1772576663968-0.json +1 -0
- package/coverage/tmp/coverage-1608692-1772576662575-0.json +1 -0
- package/coverage/tmp/coverage-1608701-1772576663873-0.json +1 -0
- package/coverage/tmp/coverage-1608718-1772576666674-0.json +1 -0
- package/coverage/tmp/coverage-1608725-1772576665463-0.json +1 -0
- package/coverage/tmp/coverage-1608738-1772576666577-0.json +1 -0
- package/coverage/tmp/coverage-1608753-1772576669664-0.json +1 -0
- package/coverage/tmp/coverage-1608763-1772576668275-0.json +1 -0
- package/coverage/tmp/coverage-1608771-1772576669563-0.json +1 -0
- package/coverage/tmp/coverage-1608828-1772576676574-0.json +1 -0
- package/coverage/tmp/coverage-1609244-1772576675272-0.json +1 -0
- package/coverage/tmp/coverage-1609342-1772576676478-0.json +1 -0
- package/coverage/tmp/coverage-1609450-1772576686954-0.json +1 -0
- package/coverage/tmp/coverage-1609841-1772576685466-0.json +1 -0
- package/coverage/tmp/coverage-1609925-1772576686855-0.json +1 -0
- package/coverage/tmp/coverage-1610399-1772576692469-0.json +1 -0
- package/coverage/tmp/coverage-1611283-1772576703062-0.json +1 -0
- package/coverage/tmp/coverage-1611294-1772576703755-0.json +1 -0
- package/docs/assessments/2026-02-27-google-a2a-protocol-assessment.md +292 -0
- package/docs/plans/2026-03-01-a2a-68-openclaw-integration-tests.md +676 -0
- package/docs/plans/2026-03-01-a2a-77-invoke-security-tests.md +661 -0
- package/docs/plans/2026-03-03-a2a-91-macos-packaging-plan.md +144 -0
- package/docs/signing-setup.md +49 -0
- package/eslint.config.js +16 -0
- package/knip.json +17 -0
- package/native/macos/certs/appldevcert.cer +0 -0
- package/native/macos/src-tauri/binaries/.gitkeep +0 -0
- package/native/macos/src-tauri/capabilities/default.json +11 -1
- package/native/macos/src-tauri/entitlements.plist +14 -0
- package/native/macos/src-tauri/src/discovery.rs +14 -3
- package/native/macos/src-tauri/src/health.rs +4 -0
- package/native/macos/src-tauri/src/lib.rs +52 -11
- package/native/macos/src-tauri/src/server.rs +262 -26
- package/native/macos/src-tauri/tauri.conf.json +13 -4
- package/package.json +16 -2
- package/pkg.config.json +14 -0
- package/scripts/build-standalone.sh +106 -0
- package/scripts/install-openclaw.js +3 -5
- package/scripts/smoke-test-standalone.sh +101 -0
- package/scripts/sync-version.sh +28 -0
- package/scripts/verify-app-bundle.sh +34 -0
- package/src/lib/agent-card.js +111 -0
- package/src/lib/client.js +290 -49
- package/src/lib/conversations.js +2 -0
- package/src/lib/local-request.js +69 -0
- package/src/lib/logger.js +2 -0
- package/src/lib/runtime-adapter.js +41 -1
- package/src/routes/a2a.js +393 -66
- package/src/routes/dashboard.js +1 -27
- package/src/server.js +19 -0
- 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
|
+
```
|