a2a-exposed 0.7.14 → 0.7.15
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/lib/main.mjs +1 -0
- package/package.json +1 -1
- package/worker/src/export.ts +2 -2
- package/worker/src/hosted.ts +27 -0
- package/worker/test/export.test.ts +26 -1
- package/worker/test/hosted.test.ts +55 -0
package/lib/main.mjs
CHANGED
|
@@ -192,6 +192,7 @@ Control plane account (only when that deployment's login is on)
|
|
|
192
192
|
that token after a confirmation and a recent sign-in. The new token is
|
|
193
193
|
shown once on that agent's page. The previous one stops working immediately.
|
|
194
194
|
The token is shown only when the stored hash is still that token.
|
|
195
|
+
Deleting an agent asks for its name and a recent sign-in. A self-hosted inbox has no such delete.
|
|
195
196
|
That page is not stored.
|
|
196
197
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
197
198
|
Unset, there is no limit. The database rejects another insert.
|
package/package.json
CHANGED
package/worker/src/export.ts
CHANGED
|
@@ -100,7 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
|
|
|
100
100
|
outbound: ["push_token_hash"],
|
|
101
101
|
mcp_grants: ["access_hash", "refresh_hash"],
|
|
102
102
|
outbound_peers: ["token_enc"],
|
|
103
|
-
device_requests: ["replaces_hash"],
|
|
103
|
+
device_requests: ["replaces_hash", "ip", "country"],
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
|
|
@@ -120,7 +120,7 @@ function placeholder(table: string, row: Record<string, unknown>, index: number)
|
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
122
|
* A copy of `file` safe to show on the dashboard.
|
|
123
|
-
* Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace).
|
|
123
|
+
* Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace) and `device_requests.ip` and `device_requests.country` (the requester's address).
|
|
124
124
|
* `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
|
|
125
125
|
* `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
|
|
126
126
|
* Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
|
package/worker/src/hosted.ts
CHANGED
|
@@ -269,6 +269,9 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
269
269
|
async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
|
|
270
270
|
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
271
271
|
checkPush(body);
|
|
272
|
+
const deleted = await (this.ctx.storage as unknown as { get(key: string): Promise<unknown> }).get("tenant_deleted");
|
|
273
|
+
// deleteStorage leaves this key after wiping SQL. A later push must not recreate the agent.
|
|
274
|
+
if (deleted != null) return { version: 0, applied: false };
|
|
272
275
|
const current = this.#row ?? readRow(this.#sql);
|
|
273
276
|
if (current && body.version <= current.version) {
|
|
274
277
|
// A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
|
|
@@ -322,6 +325,30 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
322
325
|
return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
|
|
323
326
|
}
|
|
324
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Drop this object's alarm and storage. A second call on an empty object succeeds.
|
|
330
|
+
* Not an owner HTTP route. Self-host never calls it.
|
|
331
|
+
*/
|
|
332
|
+
async deleteStorage(): Promise<{ deleted: true }> {
|
|
333
|
+
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
334
|
+
const storage = this.ctx.storage as unknown as {
|
|
335
|
+
deleteAlarm(): Promise<void>;
|
|
336
|
+
deleteAll(): Promise<void>;
|
|
337
|
+
put(key: string, value: string): Promise<void>;
|
|
338
|
+
};
|
|
339
|
+
const tenantId = this.#row?.tenantId ?? null;
|
|
340
|
+
await storage.deleteAlarm();
|
|
341
|
+
await storage.deleteAll();
|
|
342
|
+
const left = readRow(this.#sql);
|
|
343
|
+
if (left) throw new Error("deleteStorage: data remains");
|
|
344
|
+
// After SQL is gone. A later pushConfig sees this and does not recreate the agent.
|
|
345
|
+
await storage.put("tenant_deleted", "1");
|
|
346
|
+
this.#row = null;
|
|
347
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "tenant_storage_deleted", tenant: tenantId }));
|
|
348
|
+
return { deleted: true };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
325
352
|
/** SQL dump of the application tables. Refuses an object that has no config row. */
|
|
326
353
|
async exportSql(): Promise<{ sql: string; exportedAt: string }> {
|
|
327
354
|
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
@@ -88,7 +88,7 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
88
88
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
|
|
89
89
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
|
|
90
90
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
|
|
91
|
-
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada',
|
|
91
|
+
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash, ip, country) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?, '203.0.113.50', 'XQ')").bind(replaces).run();
|
|
92
92
|
await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
|
|
93
93
|
await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
|
|
94
94
|
await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
|
|
@@ -97,12 +97,16 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
97
97
|
const redacted = redactExport(file);
|
|
98
98
|
assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
|
|
99
99
|
assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
|
|
100
|
+
assert.equal(file.tables.device_requests?.[0]?.ip, "203.0.113.50");
|
|
101
|
+
assert.equal(file.tables.device_requests?.[0]?.country, "XQ");
|
|
100
102
|
assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
|
|
101
103
|
assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
|
|
102
104
|
assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
|
|
103
105
|
assert.equal(redacted.tables.peers?.[0]?.label, "ada");
|
|
104
106
|
assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
|
|
105
107
|
assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
|
|
108
|
+
assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "ip"), false);
|
|
109
|
+
assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "country"), false);
|
|
106
110
|
assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
|
|
107
111
|
assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
|
|
108
112
|
assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
|
|
@@ -130,6 +134,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
130
134
|
assert.equal(sql.includes("oauth_codes"), false);
|
|
131
135
|
assert.equal(sql.includes("oidc_txns"), false);
|
|
132
136
|
assert.equal(sql.includes("replaces_hash"), false);
|
|
137
|
+
assert.equal(sql.includes("203.0.113.50"), false);
|
|
138
|
+
assert.equal(sql.includes("XQ"), false);
|
|
133
139
|
assert.equal(sql.includes("password_setup"), false);
|
|
134
140
|
assert.equal(ownerResponseLeaks({ sql }), false);
|
|
135
141
|
for (const secret of [token, owner, hash, replaces, setupHash]) {
|
|
@@ -138,6 +144,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
138
144
|
assert.match(toSql(file), /verifier-secret-value/);
|
|
139
145
|
assert.match(toSql(file), new RegExp(hash));
|
|
140
146
|
assert.match(toSql(file), /replaces_hash/);
|
|
147
|
+
assert.match(toSql(file), /203\.0\.113\.50/);
|
|
148
|
+
assert.match(toSql(file), /'XQ'/);
|
|
141
149
|
});
|
|
142
150
|
|
|
143
151
|
test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
|
|
@@ -173,19 +181,36 @@ test("redactExport removes device_requests.replaces_hash", () => {
|
|
|
173
181
|
status: "pending",
|
|
174
182
|
replaces_label: "ada",
|
|
175
183
|
replaces_hash: replaces,
|
|
184
|
+
ip: "203.0.113.50",
|
|
185
|
+
country: "XQ",
|
|
176
186
|
}],
|
|
177
187
|
},
|
|
178
188
|
});
|
|
179
189
|
const row = redacted.tables.device_requests?.[0] ?? {};
|
|
180
190
|
assert.equal(Object.hasOwn(row, "replaces_hash"), false);
|
|
191
|
+
assert.equal(Object.hasOwn(row, "ip"), false);
|
|
192
|
+
assert.equal(Object.hasOwn(row, "country"), false);
|
|
181
193
|
assert.equal(row.replaces_label, "ada");
|
|
182
194
|
assert.equal(row.device_hash, "redacted:WDJB4827");
|
|
183
195
|
const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
|
|
184
196
|
assert.equal(sql.includes("replaces_hash"), false);
|
|
197
|
+
assert.equal(sql.includes("203.0.113.50"), false);
|
|
185
198
|
assert.equal(sql.includes(replaces), false);
|
|
186
199
|
assert.equal(JSON.stringify(redacted).includes(replaces), false);
|
|
187
200
|
});
|
|
188
201
|
|
|
202
|
+
test("self-host owner HTTP has no delete route", async () => {
|
|
203
|
+
const DB = db();
|
|
204
|
+
const env = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" };
|
|
205
|
+
const res = await worker.fetch(new Request(BASE + "/owner/delete", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { authorization: "Bearer owner-secret" },
|
|
208
|
+
}) as never, env as never, ectx as never);
|
|
209
|
+
assert.equal(res.status, 404);
|
|
210
|
+
const tables = await DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'peers'").all<{ name: string }>();
|
|
211
|
+
assert.equal(tables.results.length, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
189
214
|
test("exportRows reads every application table in one batch", async () => {
|
|
190
215
|
const DB = db();
|
|
191
216
|
await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
|
|
@@ -750,3 +750,58 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
750
750
|
assert.equal(await saw('"reason":"account"'), true);
|
|
751
751
|
assert.equal(lines.some((l) => l.includes("../acct")), false);
|
|
752
752
|
});
|
|
753
|
+
|
|
754
|
+
test("TenantStore.deleteStorage wipes the object and a second call succeeds", async (t) => {
|
|
755
|
+
const mf = await start({
|
|
756
|
+
bindings: {
|
|
757
|
+
TENANCY: "host",
|
|
758
|
+
TENANT_DOMAIN: "example.com",
|
|
759
|
+
OWNER_TOKEN: "platform-owner",
|
|
760
|
+
},
|
|
761
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-delete" },
|
|
762
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
763
|
+
});
|
|
764
|
+
t.after(() => mf.dispose());
|
|
765
|
+
await mf.ready;
|
|
766
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
767
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
768
|
+
const hash = await sha256("owner-delta");
|
|
769
|
+
type Stub = {
|
|
770
|
+
pushConfig(body: Record<string, unknown>): Promise<{ version: number; applied: boolean }>;
|
|
771
|
+
deleteStorage(): Promise<{ deleted: boolean }>;
|
|
772
|
+
storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
|
|
773
|
+
};
|
|
774
|
+
const stub = ns.get(ns.idFromName("id-delta")) as unknown as Stub;
|
|
775
|
+
await stub.pushConfig({
|
|
776
|
+
version: 1, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
|
|
777
|
+
config: { AGENT_NAME: "delta" },
|
|
778
|
+
});
|
|
779
|
+
await kv.put("tenant:delta", JSON.stringify({ id: "id-delta", status: "active", region: "default", version: 1 }));
|
|
780
|
+
const before = await stub.storageStatus();
|
|
781
|
+
assert.equal(before.configured, true);
|
|
782
|
+
assert.ok(before.tables.includes("tenant_config"));
|
|
783
|
+
const http = await mf.dispatchFetch("https://delta.example.com/owner/delete", {
|
|
784
|
+
method: "POST",
|
|
785
|
+
headers: { authorization: "Bearer owner-delta" },
|
|
786
|
+
});
|
|
787
|
+
assert.equal(http.status, 404);
|
|
788
|
+
assert.equal((await stub.storageStatus()).configured, true, "the owner HTTP API does not wipe storage");
|
|
789
|
+
const wiped = await stub.deleteStorage();
|
|
790
|
+
assert.equal(wiped.deleted, true);
|
|
791
|
+
const after = await stub.storageStatus();
|
|
792
|
+
assert.equal(after.configured, false);
|
|
793
|
+
assert.deepEqual(after.tables, []);
|
|
794
|
+
const again = await stub.deleteStorage();
|
|
795
|
+
assert.equal(again.deleted, true);
|
|
796
|
+
assert.equal((await stub.storageStatus()).configured, false);
|
|
797
|
+
assert.deepEqual((await stub.storageStatus()).tables, []);
|
|
798
|
+
const refused = await stub.pushConfig({
|
|
799
|
+
version: 2, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
|
|
800
|
+
config: { AGENT_NAME: "delta" },
|
|
801
|
+
});
|
|
802
|
+
assert.equal(refused.applied, false);
|
|
803
|
+
assert.equal(refused.version, 0);
|
|
804
|
+
const stayed = await stub.storageStatus();
|
|
805
|
+
assert.equal(stayed.configured, false);
|
|
806
|
+
assert.deepEqual(stayed.tables, []);
|
|
807
|
+
});
|