@sleep2agi/commhub-server 0.9.0-preview.29 → 0.9.0-preview.30
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/README.md +115 -0
- package/package.json +2 -2
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/db.ts +65 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/rest-explicit-columns-http.test.ts +81 -19
- package/src/rest-identity.test.ts +10 -1
- package/src/rest-identity.ts +11 -4
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-tasks-http.test.ts +20 -1
- package/src/scheduled-tasks.ts +13 -2
- package/src/server.ts +135 -16
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/task-consumption.test.ts +69 -0
- package/src/task-diagnostic.test.ts +33 -0
- package/src/task-diagnostic.ts +32 -3
- package/src/task-idempotency-mcp.test.ts +5 -0
- package/src/task-lifecycle-watcher.test.ts +45 -5
- package/src/tools.ts +115 -9
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的
|
|
2
|
+
// 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们:
|
|
3
|
+
//
|
|
4
|
+
// resolveRestNetworkScope 1 canRestWriteNetwork 1
|
|
5
|
+
// singleNetworkId 2 addNetworkScope 1
|
|
6
|
+
// getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0
|
|
7
|
+
//
|
|
8
|
+
// 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
|
|
9
|
+
// 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
|
|
10
|
+
// 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有
|
|
11
|
+
// 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。
|
|
12
|
+
//
|
|
13
|
+
// 🔴 这条规则的两半都要钉:
|
|
14
|
+
// - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员
|
|
15
|
+
// 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修;
|
|
16
|
+
// - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被
|
|
17
|
+
// 指定的网络里**,而调用方看到的是成功。
|
|
18
|
+
//
|
|
19
|
+
// 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。
|
|
20
|
+
// 所以每一条收紧断言都配了正控。
|
|
21
|
+
//
|
|
22
|
+
// Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
27
|
+
|
|
28
|
+
const NET_A = "net_819_a";
|
|
29
|
+
const NET_B = "net_819_b";
|
|
30
|
+
const U_SINGLE = "u819_single"; // 只属于 NET_A
|
|
31
|
+
const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B
|
|
32
|
+
const U_NONE = "u819_none"; // 一个都不属于
|
|
33
|
+
const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE];
|
|
34
|
+
|
|
35
|
+
function cleanup() {
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
38
|
+
for (const u of ALL_USERS) {
|
|
39
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function seed() {
|
|
44
|
+
for (const u of ALL_USERS) {
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
47
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
48
|
+
[u, u],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
|
|
52
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
|
|
53
|
+
const member = (u: string, net: string, role: string) =>
|
|
54
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
|
|
55
|
+
member(U_SINGLE, NET_A, "owner");
|
|
56
|
+
member(U_MULTI, NET_A, "member");
|
|
57
|
+
member(U_MULTI, NET_B, "owner");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
61
|
+
afterAll(() => { cleanup(); });
|
|
62
|
+
|
|
63
|
+
/** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */
|
|
64
|
+
const ADMIN_SCOPE: RestNetworkScope = { networkIds: null };
|
|
65
|
+
const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids });
|
|
66
|
+
const ctx = (userId: string) => ({ userId, networkId: null });
|
|
67
|
+
|
|
68
|
+
describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => {
|
|
69
|
+
test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => {
|
|
70
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A);
|
|
71
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => {
|
|
75
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」,
|
|
79
|
+
// 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系
|
|
80
|
+
// 回退分支,而 U_SINGLE 恰好只有一个成员关系。
|
|
81
|
+
//
|
|
82
|
+
// 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行,
|
|
83
|
+
// **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied
|
|
84
|
+
// to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。
|
|
85
|
+
//
|
|
86
|
+
// 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回
|
|
87
|
+
// networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。
|
|
88
|
+
//
|
|
89
|
+
// 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [],
|
|
90
|
+
// 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住:
|
|
91
|
+
// 当前行为,以及那条让它安全的前提。
|
|
92
|
+
test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => {
|
|
93
|
+
expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => {
|
|
97
|
+
expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull();
|
|
98
|
+
// 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。
|
|
99
|
+
const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true);
|
|
100
|
+
expect(adminScope.networkIds).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("#819 admin 的全局读作用域不能替写入决定网络", () => {
|
|
105
|
+
test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => {
|
|
106
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => {
|
|
110
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => {
|
|
114
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => {
|
|
118
|
+
// 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。
|
|
119
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("没有 authCtx + 全局作用域 → null", () => {
|
|
123
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("#819 作用域优先于成员关系回退", () => {
|
|
128
|
+
test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => {
|
|
129
|
+
// 归属由作用域决定,不由「他碰巧属于哪个网络」决定。
|
|
130
|
+
// 如果实现把两者的优先级搞反,这条会返回 NET_A。
|
|
131
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { normalizeSessionRuntime } from "./runtime-label";
|
|
3
|
+
|
|
4
|
+
describe("session runtime projection", () => {
|
|
5
|
+
test("preserves exact co-presence and CLI runtimes", () => {
|
|
6
|
+
expect(normalizeSessionRuntime("agent-node:codex-app-server")).toBe("codex-app-server");
|
|
7
|
+
expect(normalizeSessionRuntime("agent-node:grok-build-cli")).toBe("grok-build-cli");
|
|
8
|
+
});
|
|
9
|
+
test("keeps legacy labels backward-compatible", () => {
|
|
10
|
+
expect(normalizeSessionRuntime("agent-node:codex")).toBe("codex-sdk");
|
|
11
|
+
expect(normalizeSessionRuntime("agent-node:claude")).toBe("claude-agent-sdk");
|
|
12
|
+
expect(normalizeSessionRuntime("agent-node:grok")).toBe("grok-build-acp");
|
|
13
|
+
expect(normalizeSessionRuntime("agent-node:opencode")).toBe("opencode-cli");
|
|
14
|
+
expect(normalizeSessionRuntime("claude-code")).toBe("claude-code-cli");
|
|
15
|
+
});
|
|
16
|
+
test("does not invent unknown runtimes", () => {
|
|
17
|
+
expect(normalizeSessionRuntime("agent-node:future-runtime")).toBeNull();
|
|
18
|
+
expect(normalizeSessionRuntime("agent-node:codex-app-server-evil")).toBeNull();
|
|
19
|
+
expect(normalizeSessionRuntime("agent-node:grok-build-cli-preview")).toBeNull();
|
|
20
|
+
expect(normalizeSessionRuntime(null)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Preserve exact agent-node runtimes before applying legacy family aliases. */
|
|
2
|
+
export function normalizeSessionRuntime(agent: unknown): string | null {
|
|
3
|
+
if (typeof agent !== "string" || agent.length === 0) return null;
|
|
4
|
+
if (agent === "claude-code") return "claude-code-cli";
|
|
5
|
+
const reported = agent.startsWith("agent-node:") ? agent.slice("agent-node:".length) : "";
|
|
6
|
+
const exact: Record<string, string> = {
|
|
7
|
+
"codex-app-server": "codex-app-server",
|
|
8
|
+
"grok-build-cli": "grok-build-cli",
|
|
9
|
+
opencode: "opencode-cli",
|
|
10
|
+
"opencode-cli": "opencode-cli",
|
|
11
|
+
codex: "codex-sdk",
|
|
12
|
+
"codex-sdk": "codex-sdk",
|
|
13
|
+
claude: "claude-agent-sdk",
|
|
14
|
+
"claude-agent-sdk": "claude-agent-sdk",
|
|
15
|
+
grok: "grok-build-acp",
|
|
16
|
+
"grok-build-acp": "grok-build-acp",
|
|
17
|
+
};
|
|
18
|
+
if (exact[reported]) return exact[reported];
|
|
19
|
+
if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -442,9 +442,28 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
442
442
|
const manual = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
443
443
|
expect(manual.status).toBe(202);
|
|
444
444
|
expect(manual.body.taskId).toBeTruthy();
|
|
445
|
-
|
|
445
|
+
// Cancel via POST /cancel (the path the dashboard uses, since some
|
|
446
|
+
// reverse proxies rewrite DELETE to 405). Then verify the legacy
|
|
447
|
+
// DELETE endpoint is still accepted and idempotent when the row is
|
|
448
|
+
// already cancelled — both spellings must reach the same state.
|
|
449
|
+
const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
450
|
+
expect(cancelled.status).toBe(200);
|
|
446
451
|
expect(cancelled.body.status).toBe("cancelled");
|
|
447
452
|
const cancelledRow = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
453
|
+
const revisionAfterCancel = cancelledRow.revision;
|
|
454
|
+
// Idempotency: DELETE on an already-cancelled row must be 200 with the
|
|
455
|
+
// same revision, not 409, and must not bump revision again.
|
|
456
|
+
const reCancelledDelete = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" });
|
|
457
|
+
expect(reCancelledDelete.status).toBe(200);
|
|
458
|
+
expect(reCancelledDelete.body.status).toBe("cancelled");
|
|
459
|
+
const afterDelete = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
460
|
+
expect(afterDelete.revision).toBe(revisionAfterCancel);
|
|
461
|
+
// And POST /cancel on an already-cancelled row is also idempotent.
|
|
462
|
+
const reCancelledPost = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
463
|
+
expect(reCancelledPost.status).toBe(200);
|
|
464
|
+
expect(reCancelledPost.body.status).toBe("cancelled");
|
|
465
|
+
const afterPost = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
466
|
+
expect(afterPost.revision).toBe(revisionAfterCancel);
|
|
448
467
|
const resurrect = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
449
468
|
method: "PATCH",
|
|
450
469
|
body: JSON.stringify({ revision: cancelledRow.revision, status: "active", name: "must not revive" }),
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -451,7 +451,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now))?$/);
|
|
454
|
+
const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now|cancel))?$/);
|
|
455
455
|
if (!match) return jsonError("not_found", 404);
|
|
456
456
|
const scheduleId = decodeURIComponent(match[1]);
|
|
457
457
|
const sub = match[2] || null;
|
|
@@ -479,7 +479,18 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
|
|
482
|
+
// Cancel — accept two spellings for the same operation:
|
|
483
|
+
// DELETE /api/scheduled-tasks/:id (original)
|
|
484
|
+
// POST /api/scheduled-tasks/:id/cancel (added because some reverse
|
|
485
|
+
// proxies swallow DELETE and
|
|
486
|
+
// return 405 HTML; POST is
|
|
487
|
+
// universally allowed)
|
|
488
|
+
// Idempotent: re-cancelling an already-cancelled row is a 200 no-op, not a
|
|
489
|
+
// 409, because a client that just saw the row (still on screen as
|
|
490
|
+
// "cancelled" between polls) should get the same outcome whether it's the
|
|
491
|
+
// first click or a retry.
|
|
492
|
+
if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) {
|
|
493
|
+
if (row.status === "cancelled") return Response.json({ ok: true, status: "cancelled" });
|
|
483
494
|
db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
|
|
484
495
|
return Response.json({ ok: true, status: "cancelled" });
|
|
485
496
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { resolvePort } from "./resolve-port.js";
|
|
3
|
+
import { normalizeSessionRuntime } from "./runtime-label.js";
|
|
2
4
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
5
|
import { z } from "zod/v4";
|
|
4
6
|
import { registerTools } from "./tools.js";
|
|
@@ -47,8 +49,10 @@ import { diagnoseTask } from "./task-diagnostic.js";
|
|
|
47
49
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
48
50
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
49
51
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
52
|
+
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
53
|
+
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
50
54
|
|
|
51
|
-
const PORT =
|
|
55
|
+
const PORT = resolvePort(process.env.PORT);
|
|
52
56
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
53
57
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
54
58
|
const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
|
|
@@ -62,6 +66,18 @@ const TMUX_ALLOWLIST = new Set(
|
|
|
62
66
|
);
|
|
63
67
|
let masterTokenDeprecationLogged = false;
|
|
64
68
|
|
|
69
|
+
// #175 PR2 starts fail-closed. Only a verified native adapter can be installed;
|
|
70
|
+
// the execution port deliberately has no ordinary task/FIFO delivery method.
|
|
71
|
+
export const sideThreadPortRegistry = new SideThreadPortRegistry();
|
|
72
|
+
export function installSideThreadExecutionPort(port: SideThreadExecutionPort): () => void {
|
|
73
|
+
return sideThreadPortRegistry.install(port);
|
|
74
|
+
}
|
|
75
|
+
const sideThreadCoordinator = new SideThreadCoordinator(new SideThreadStore(db), sideThreadPortRegistry, {
|
|
76
|
+
enabled: process.env[SIDE_THREAD_FEATURE_FLAG] === "1",
|
|
77
|
+
authorizeNode: (actor, networkId, nodeId) => authorizeSideThreadNode(actor, networkId, nodeId),
|
|
78
|
+
authorizeAttachment: (actor, networkId, ref) => authorizeSideThreadAttachment(actor, networkId, ref),
|
|
79
|
+
});
|
|
80
|
+
|
|
65
81
|
if (AUTH_TOKEN) {
|
|
66
82
|
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
67
83
|
}
|
|
@@ -191,19 +207,7 @@ function isLocalhostIP(ip: string): boolean {
|
|
|
191
207
|
// Normalize the raw `agent` field into a canonical runtime identifier for the
|
|
192
208
|
// dashboard's Runtime badge. Returns null for unknown/absent agents so the
|
|
193
209
|
// frontend can fall back to a placeholder.
|
|
194
|
-
|
|
195
|
-
if (typeof agent !== "string" || agent.length === 0) return null;
|
|
196
|
-
if (agent === "claude-code") return "claude-code-cli";
|
|
197
|
-
if (agent.startsWith("agent-node:codex")) return "codex-sdk";
|
|
198
|
-
if (agent.startsWith("agent-node:claude")) return "claude-agent-sdk";
|
|
199
|
-
if (agent.startsWith("agent-node:grok")) return "grok-build-acp";
|
|
200
|
-
// RFC-029 — agent-node reports `agent-node:opencode` when RUNTIME
|
|
201
|
-
// bucket resolves to "opencode". Dashboard needs the canonical
|
|
202
|
-
// launcher name so its Runtime badge matches wizard choices.
|
|
203
|
-
if (agent.startsWith("agent-node:opencode")) return "opencode-cli";
|
|
204
|
-
if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
210
|
+
const normalizeRuntime = normalizeSessionRuntime;
|
|
207
211
|
|
|
208
212
|
function isTmuxAllowedIP(ip: string): boolean {
|
|
209
213
|
return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
|
|
@@ -234,6 +238,25 @@ function resolveRequestAuth(req: Request, options: RequestTokenOptions = {}): {
|
|
|
234
238
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
235
239
|
}
|
|
236
240
|
|
|
241
|
+
function resolveSideThreadActor(req: Request, auth: ReturnType<typeof resolveRequestAuth>, isAdmin: boolean): SideThreadActor | null {
|
|
242
|
+
// Legacy master and DEV_OPEN cannot own durable records because neither
|
|
243
|
+
// supplies a stable per-user identity for later window hydration.
|
|
244
|
+
if (!auth?.userId || !auth.tokenId) return null;
|
|
245
|
+
const nodeCredential = requestToken(req).startsWith("ntok_");
|
|
246
|
+
const tokenRow = nodeCredential
|
|
247
|
+
? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", auth.tokenId)
|
|
248
|
+
: null;
|
|
249
|
+
return {
|
|
250
|
+
userId: auth.userId,
|
|
251
|
+
username: auth.username,
|
|
252
|
+
tokenId: auth.tokenId,
|
|
253
|
+
kind: nodeCredential ? "node" : "user",
|
|
254
|
+
...(auth.networkId ? { boundNetworkId: auth.networkId } : {}),
|
|
255
|
+
...(tokenRow?.bound_node_id ? { boundNodeId: tokenRow.bound_node_id } : {}),
|
|
256
|
+
isAdmin,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
237
260
|
// #503 — the credential kinds that authorization decisions branch on.
|
|
238
261
|
// Resolved once per request at the handler, so the authz helpers stay
|
|
239
262
|
// pure functions of (principal, entry) and every credential kind is a
|
|
@@ -336,6 +359,46 @@ export function authorizeFileDownload(
|
|
|
336
359
|
return false;
|
|
337
360
|
}
|
|
338
361
|
|
|
362
|
+
function authorizeSideThreadNode(actor: SideThreadActor, networkId: string, nodeId: string): boolean {
|
|
363
|
+
const node = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
364
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1", nodeId,
|
|
365
|
+
);
|
|
366
|
+
if (!node || node.network_id !== networkId) return false;
|
|
367
|
+
if (!getUserNetworkRole(actor.userId, networkId)) return false;
|
|
368
|
+
if (!node.owner_user_id || node.owner_user_id !== actor.userId) return false;
|
|
369
|
+
if (actor.boundNetworkId && actor.boundNetworkId !== networkId) return false;
|
|
370
|
+
if (actor.kind === "node" && actor.boundNodeId !== nodeId) return false;
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resolveSideThreadCapabilityTarget(input: { actor: SideThreadActor; alias?: string; nodeId?: string; networkId?: string }): { nodeId: string; networkId: string } | undefined {
|
|
375
|
+
if ((!input.alias && !input.nodeId) || (input.alias && input.nodeId)) return undefined;
|
|
376
|
+
const networkId = input.actor.boundNetworkId ?? input.networkId;
|
|
377
|
+
const params: unknown[] = [input.nodeId ?? input.alias];
|
|
378
|
+
let sql = input.nodeId
|
|
379
|
+
? "SELECT node_id,network_id FROM nodes WHERE node_id=?1"
|
|
380
|
+
: "SELECT node_id,network_id FROM nodes WHERE alias=?1";
|
|
381
|
+
if (networkId) { params.push(networkId); sql += " AND network_id=?2"; }
|
|
382
|
+
const authorized = db.all<{ node_id: string; network_id: string | null }>(sql, ...params)
|
|
383
|
+
.filter(row => !!row.network_id && authorizeSideThreadNode(input.actor, row.network_id, row.node_id));
|
|
384
|
+
if (authorized.length !== 1) return undefined;
|
|
385
|
+
return { nodeId: authorized[0].node_id, networkId: authorized[0].network_id! };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function authorizeSideThreadAttachment(actor: SideThreadActor, networkId: string, ref: SideThreadAttachmentRef): boolean {
|
|
389
|
+
if (!FILE_ID_REGEX.test(ref.fileId)) return false;
|
|
390
|
+
const path = indexEntryPath(ref.fileId);
|
|
391
|
+
if (!path || !existsSync(path)) return false;
|
|
392
|
+
try {
|
|
393
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
394
|
+
if (!validateIndexEntry(entry) || entry.file_id !== ref.fileId) return false;
|
|
395
|
+
if (entry.network_id !== networkId || entry.owner_id !== actor.userId) return false;
|
|
396
|
+
if (!isValidCalendarBucket(entry.date_bucket)) return false;
|
|
397
|
+
const storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
|
|
398
|
+
return isPathInsideUploadsRoot(storage.absolutePath) && existsSync(storage.absolutePath);
|
|
399
|
+
} catch { return false; }
|
|
400
|
+
}
|
|
401
|
+
|
|
339
402
|
type ByteRange =
|
|
340
403
|
| { ok: true; start: number; end: number; length: number }
|
|
341
404
|
| { ok: false };
|
|
@@ -496,6 +559,17 @@ type RestDeliveryTarget =
|
|
|
496
559
|
| { state: "offline"; alias: string; session: any; message: string }
|
|
497
560
|
| { state: "not_found"; alias: string; message: string };
|
|
498
561
|
|
|
562
|
+
// Build this only after authorization and a network-scoped lookup succeeded.
|
|
563
|
+
// In particular, not-found and permission-denied responses must not become a
|
|
564
|
+
// cross-network alias/node enumeration oracle.
|
|
565
|
+
function restActualTo(target: Exclude<RestDeliveryTarget, { state: "not_found" }>, networkId: string | null) {
|
|
566
|
+
return {
|
|
567
|
+
alias: target.alias,
|
|
568
|
+
to_node_id: target.session?.node_id ?? null,
|
|
569
|
+
network_id: networkId,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
499
573
|
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
500
574
|
const params: any[] = [alias];
|
|
501
575
|
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
@@ -648,7 +722,10 @@ export function patrolExpiredTasks(): void {
|
|
|
648
722
|
WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
|
|
649
723
|
[task.task_id],
|
|
650
724
|
);
|
|
651
|
-
|
|
725
|
+
// SQLite may include the AFTER UPDATE terminal-journal trigger write
|
|
726
|
+
// in this count. The WHERE clause still targets one exact guarded
|
|
727
|
+
// task, so any positive count proves the task transition happened.
|
|
728
|
+
if (result.changes < 1) continue;
|
|
652
729
|
syncScheduledRunForTask(task.task_id, task.network_id);
|
|
653
730
|
changed.push(task);
|
|
654
731
|
}
|
|
@@ -1442,6 +1519,16 @@ return Bun.serve({
|
|
|
1442
1519
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1443
1520
|
}
|
|
1444
1521
|
|
|
1522
|
+
const sideThreadResponse = await handleSideThreadHttpRequest({
|
|
1523
|
+
req,
|
|
1524
|
+
url,
|
|
1525
|
+
// Durable prompts/results never accept query-string credentials.
|
|
1526
|
+
actor: resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin),
|
|
1527
|
+
coordinator: sideThreadCoordinator,
|
|
1528
|
+
resolveCapabilityTarget: resolveSideThreadCapabilityTarget,
|
|
1529
|
+
});
|
|
1530
|
+
if (sideThreadResponse) return withCors(req, sideThreadResponse);
|
|
1531
|
+
|
|
1445
1532
|
// RFC-036 owner-gated host schedule edits. This handler independently
|
|
1446
1533
|
// verifies exact node ownership and node-token binding; network admin is
|
|
1447
1534
|
// deliberately not an override.
|
|
@@ -2262,6 +2349,7 @@ return Bun.serve({
|
|
|
2262
2349
|
const identity = resolveRestFromSession({
|
|
2263
2350
|
token: requestToken(req),
|
|
2264
2351
|
tokenName: restAuth?.tokenName,
|
|
2352
|
+
authenticatedUsername: restAuth?.username,
|
|
2265
2353
|
requestedFrom: body.from,
|
|
2266
2354
|
});
|
|
2267
2355
|
if (!identity.ok) {
|
|
@@ -2277,6 +2365,7 @@ return Bun.serve({
|
|
|
2277
2365
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
2278
2366
|
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
2279
2367
|
const targetNodeId = target.session?.node_id ?? null;
|
|
2368
|
+
const actualTo = restActualTo(target, taskNetId);
|
|
2280
2369
|
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
2281
2370
|
// the REST and MCP send_task transports produce identical
|
|
2282
2371
|
// tasks.meta_json shape downstream. Top-level wins over any
|
|
@@ -2377,10 +2466,17 @@ return Bun.serve({
|
|
|
2377
2466
|
task_id: id,
|
|
2378
2467
|
message_id: id,
|
|
2379
2468
|
session_status: target.session.status ?? "offline",
|
|
2469
|
+
actual_to: actualTo,
|
|
2380
2470
|
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
2381
2471
|
}, { status: 202 }));
|
|
2382
2472
|
}
|
|
2383
|
-
return withCors(req, Response.json({
|
|
2473
|
+
return withCors(req, Response.json({
|
|
2474
|
+
ok: true,
|
|
2475
|
+
task_id: id,
|
|
2476
|
+
message_id: id,
|
|
2477
|
+
actual_to: actualTo,
|
|
2478
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
2479
|
+
}));
|
|
2384
2480
|
}
|
|
2385
2481
|
|
|
2386
2482
|
// ── REST: broadcast ──
|
|
@@ -3044,6 +3140,28 @@ return Bun.serve({
|
|
|
3044
3140
|
const targetSession = db.get<{ alias: string; status: string }>(targetSql, ...targetParams);
|
|
3045
3141
|
const targetAlias = targetSession?.alias ?? canonicalTarget ?? String(task.to_name ?? "");
|
|
3046
3142
|
const liveSseConnections = getSSEStats().sessions[`${taskNetworkId || "global"}:${targetAlias}`] ?? 0;
|
|
3143
|
+
// 🔴 #1083:这个目标【历史上有没有报过任何一次】运行时证据。
|
|
3144
|
+
// 只用 Hub 自己已有的数据算,不需要 agent 侧任何改动。
|
|
3145
|
+
// 为空时 runtime_submitted_at/consumed_at 是「不会走到」而不是「还没走到」,
|
|
3146
|
+
// 两者处置相反(别等去接线 vs 等)。参数编号沿用上面 targetSql 的写法。
|
|
3147
|
+
const evidenceParams: unknown[] = [];
|
|
3148
|
+
let evidenceSql = "SELECT 1 AS seen FROM tasks WHERE runtime_submitted_at IS NOT NULL AND ";
|
|
3149
|
+
if (task.to_node_id) {
|
|
3150
|
+
evidenceSql += "to_node_id = ?1";
|
|
3151
|
+
evidenceParams.push(task.to_node_id);
|
|
3152
|
+
} else {
|
|
3153
|
+
evidenceSql += "to_node_id IS NULL AND to_name = ?1";
|
|
3154
|
+
evidenceParams.push(canonicalTarget ?? String(task.to_name ?? ""));
|
|
3155
|
+
}
|
|
3156
|
+
if (taskNetworkId) {
|
|
3157
|
+
evidenceSql += " AND network_id = ?2";
|
|
3158
|
+
evidenceParams.push(taskNetworkId);
|
|
3159
|
+
} else {
|
|
3160
|
+
evidenceSql += " AND network_id IS NULL";
|
|
3161
|
+
}
|
|
3162
|
+
evidenceSql += " LIMIT 1";
|
|
3163
|
+
const targetEverReportedRuntimeEvidence =
|
|
3164
|
+
Boolean(db.get<{ seen: number }>(evidenceSql, ...evidenceParams));
|
|
3047
3165
|
const diagnostic = diagnoseTask({
|
|
3048
3166
|
status: String(task.status ?? "unknown"),
|
|
3049
3167
|
runtimeSubmittedAt: typeof task.runtime_submitted_at === "string" ? task.runtime_submitted_at : null,
|
|
@@ -3051,6 +3169,7 @@ return Bun.serve({
|
|
|
3051
3169
|
targetSessionStatus: targetSession?.status ?? null,
|
|
3052
3170
|
targetSessionExists: Boolean(targetSession),
|
|
3053
3171
|
liveSseConnections,
|
|
3172
|
+
targetEverReportedRuntimeEvidence,
|
|
3054
3173
|
});
|
|
3055
3174
|
return withCors(req, Response.json({ ok: true, task, diagnostic }));
|
|
3056
3175
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SQLiteAdapter } from "./db-adapter";
|
|
4
|
+
import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
|
|
5
|
+
import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
|
|
6
|
+
|
|
7
|
+
function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
|
|
8
|
+
function fixture() {
|
|
9
|
+
const db = new SQLiteAdapter(new Database(":memory:"));
|
|
10
|
+
db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
|
|
11
|
+
const store = new SideThreadCommandStore(db, () => 123);
|
|
12
|
+
const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
|
|
13
|
+
capabilityForNode: () => ({ supported: true, mode: "native-exact-fork", exactBoundary: { through: true, before: true } }),
|
|
14
|
+
grantAttachment: (_node, ref) => ({ fileId: ref.fileId, grantId: `grant-${ref.fileId}`, sha256: "a".repeat(64), size: 7, mediaType: "image/png" }) });
|
|
15
|
+
return { db, store, port };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("Hub durable SideThread command outbox", () => {
|
|
19
|
+
test("fails closed on the currently non-atomic PostgreSQL adapter", () => {
|
|
20
|
+
expect(() => new SideThreadCommandStore({ dialect: "postgres" } as any)).toThrow(/atomic SQLite/);
|
|
21
|
+
});
|
|
22
|
+
test("allows exactly one durable terminal applier", () => {
|
|
23
|
+
const f = fixture(); const close = f.port.subscribe(async () => {});
|
|
24
|
+
expect(() => f.port.subscribe(async () => {})).toThrow(/exactly one/);
|
|
25
|
+
close();
|
|
26
|
+
});
|
|
27
|
+
test("queues outside inbox/FIFO and only exact node token can retain the delivery", async () => {
|
|
28
|
+
const f = fixture();
|
|
29
|
+
await expect(f.port.fork({ operationId: "op-1", requestKey: "rk-1", sideChatId: "side-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through", turnId: "source-turn" } })).rejects.toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
|
|
30
|
+
expect(f.db.get<{ n: number }>("SELECT COUNT(*) n FROM inbox")!.n).toBe(0);
|
|
31
|
+
expect(f.store.claim({ ...actor(), nodeId: "node-2" })).toBeNull();
|
|
32
|
+
const delivered = f.store.claim(actor())!;
|
|
33
|
+
expect(delivered).toMatchObject({ protocol: "side_thread.command.v1", operationId: "op-1", nodeId: "node-1" });
|
|
34
|
+
expect(f.store.claim(actor())).toEqual(delivered);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("ACK response loss is idempotent and foreign token/collision cannot rewrite it", async () => {
|
|
38
|
+
const f = fixture();
|
|
39
|
+
await f.port.start({ operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1", nodeId: "node-1", threadId: "derived-1", prompt: "q", attachments: [] }).catch(() => {});
|
|
40
|
+
const command = f.store.claim(actor())!;
|
|
41
|
+
const ack = { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-start", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-1", destinationTurnId: null } };
|
|
42
|
+
expect(f.store.ack(actor(), ack).idempotent).toBe(false);
|
|
43
|
+
expect(f.store.ack(actor(), ack).idempotent).toBe(true);
|
|
44
|
+
expect(() => f.store.ack(actor("tok-foreign"), ack)).toThrow(/ownership/);
|
|
45
|
+
expect(() => f.store.ack(actor(), { ...ack, state: "failed", errorCode: "SIDE_THREAD_CONFLICT" })).toThrow(/immutable/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("terminal receipt requires exact side/attempt/thread/turn tuple and is replay-safe", async () => {
|
|
49
|
+
const f = fixture(); const events: any[] = []; f.port.subscribe((e) => events.push(e));
|
|
50
|
+
await f.port.start({ operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1", nodeId: "node-1", threadId: "derived-1", prompt: "q", attachments: [] }).catch(() => {});
|
|
51
|
+
const command = f.store.claim(actor())!;
|
|
52
|
+
f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-start", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-1", destinationTurnId: null } });
|
|
53
|
+
const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-1", attemptId: "attempt-1", threadId: "derived-1", turnId: "turn-1", status: "completed", text: "answer", errorCode: null };
|
|
54
|
+
await expect(f.port.acceptTerminal(actor(), { ...terminal, turnId: "turn-foreign" })).rejects.toThrow(/four-tuple/);
|
|
55
|
+
expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
|
|
56
|
+
expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(true);
|
|
57
|
+
expect(events).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("durable accepted receipt reconciles a later POST retry without a second delivery", async () => {
|
|
61
|
+
const f = fixture(); const input = { operationId: "op-fork", requestKey: "rk-fork", sideChatId: "side-1", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "before" as const, turnId: "source-turn" } };
|
|
62
|
+
await f.port.fork(input).catch(() => {});
|
|
63
|
+
const command = f.store.claim(actor())!;
|
|
64
|
+
f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: input.operationId, state: "accepted", errorCode: null, result: { threadId: "derived-1", turnId: null, destinationTurnId: null } });
|
|
65
|
+
expect(await f.port.fork(input)).toEqual({ threadId: "derived-1" });
|
|
66
|
+
expect(f.store.claim(actor())).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("HTTP surface binds path node and ACK body to the authenticated node actor", async () => {
|
|
70
|
+
const f = fixture();
|
|
71
|
+
await f.port.fork({ operationId: "op-http", requestKey: "rk-http", sideChatId: "side-http", nodeId: "node-1", sourceThreadId: "source-1", boundary: { kind: "through", turnId: "t-1" } }).catch(() => {});
|
|
72
|
+
const foreign = await handleSideThreadCommandRequest({ req: new Request("http://hub/api/nodes/node-1/side-thread-commands/pending"), url: new URL("http://hub/api/nodes/node-1/side-thread-commands/pending"), actor: { ...actor(), nodeId: "node-2" }, store: f.store, port: f.port });
|
|
73
|
+
expect(foreign?.status).toBe(403);
|
|
74
|
+
const pulled = await handleSideThreadCommandRequest({ req: new Request("http://hub/api/nodes/node-1/side-thread-commands/pending"), url: new URL("http://hub/api/nodes/node-1/side-thread-commands/pending"), actor: actor(), store: f.store, port: f.port });
|
|
75
|
+
const command = (await pulled!.json() as any).command;
|
|
76
|
+
const badBody = { protocol: "side_thread.ack.v1", commandId: "other-command", operationId: command.operationId, state: "accepted", errorCode: null, result: { threadId: "derived", turnId: null, destinationTurnId: null } };
|
|
77
|
+
const rejected = await handleSideThreadCommandRequest({ req: new Request(`http://hub/api/nodes/node-1/side-thread-commands/${command.commandId}/ack`, { method: "POST", body: JSON.stringify(badBody) }), url: new URL(`http://hub/api/nodes/node-1/side-thread-commands/${command.commandId}/ack`), actor: actor(), store: f.store, port: f.port });
|
|
78
|
+
expect(rejected?.status).toBe(409);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("hostile node cannot smuggle extra receipt fields or unbounded terminal text", async () => {
|
|
82
|
+
const f = fixture(); f.port.subscribe(async () => {});
|
|
83
|
+
await f.port.start({ operationId: "op-hostile", requestKey: "rk-hostile", sideChatId: "side-hostile", attemptId: "attempt-hostile", nodeId: "node-1", threadId: "derived-hostile", prompt: "q", attachments: [] }).catch(() => {});
|
|
84
|
+
const command = f.store.claim(actor())!;
|
|
85
|
+
const clean = { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-hostile", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-hostile", destinationTurnId: null } };
|
|
86
|
+
expect(() => f.store.ack(actor(), { ...clean, exception: "Bearer ntok_secret" })).toThrow(/unexpected protocol fields/);
|
|
87
|
+
f.store.ack(actor(), clean);
|
|
88
|
+
const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-hostile", attemptId: "attempt-hostile", threadId: "derived-hostile", turnId: "turn-hostile", status: "completed", text: "ok", errorCode: null };
|
|
89
|
+
await expect(f.port.acceptTerminal(actor(), { ...terminal, stack: "secret" })).rejects.toThrow(/unexpected protocol fields/);
|
|
90
|
+
await expect(f.port.acceptTerminal(actor(), { ...terminal, text: "x".repeat(1_000_001) })).rejects.toThrow(/completed terminal payload/);
|
|
91
|
+
expect((await f.port.acceptTerminal(actor(), terminal)).idempotent).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("receipt commit followed by listener failure remains durably redeliverable", async () => {
|
|
95
|
+
const f = fixture(); let calls = 0;
|
|
96
|
+
f.port.subscribe(async () => { calls++; if (calls === 1) throw new Error("coordinator crashed"); });
|
|
97
|
+
await f.port.start({ operationId: "op-terminal-crash", requestKey: "rk-terminal-crash", sideChatId: "side-crash", attemptId: "attempt-crash", nodeId: "node-1", threadId: "derived-crash", prompt: "q", attachments: [] }).catch(() => {});
|
|
98
|
+
const command = f.store.claim(actor())!;
|
|
99
|
+
f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-terminal-crash", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-crash", destinationTurnId: null } });
|
|
100
|
+
const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-crash", attemptId: "attempt-crash", threadId: "derived-crash", turnId: "turn-crash", status: "completed", text: "answer", errorCode: null };
|
|
101
|
+
await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/coordinator crashed/);
|
|
102
|
+
expect((await f.port.acceptTerminal(actor(), terminal)).pending).toBe(false);
|
|
103
|
+
expect(calls).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("concurrent terminal POST cannot acknowledge WAL deletion while apply is pending", async () => {
|
|
107
|
+
const f = fixture(); let release!: () => void;
|
|
108
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
109
|
+
f.port.subscribe(async () => { await gate; throw new Error("late apply failure"); });
|
|
110
|
+
await f.port.start({ operationId: "op-concurrent-terminal", requestKey: "rk-concurrent-terminal", sideChatId: "side-concurrent", attemptId: "attempt-concurrent", nodeId: "node-1", threadId: "derived-concurrent", prompt: "q", attachments: [] }).catch(() => {});
|
|
111
|
+
const command = f.store.claim(actor())!;
|
|
112
|
+
f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-concurrent-terminal", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-concurrent", destinationTurnId: null } });
|
|
113
|
+
const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-concurrent", attemptId: "attempt-concurrent", threadId: "derived-concurrent", turnId: "turn-concurrent", status: "completed", text: "answer", errorCode: null };
|
|
114
|
+
const url = new URL("http://hub/api/nodes/node-1/side-thread-commands/terminals");
|
|
115
|
+
const call = () => handleSideThreadCommandRequest({ req: new Request(url, { method: "POST", body: JSON.stringify(terminal) }), url, actor: actor(), store: f.store, port: f.port });
|
|
116
|
+
const first = call(); await Bun.sleep(10);
|
|
117
|
+
const second = await call();
|
|
118
|
+
expect(second?.status).toBe(503);
|
|
119
|
+
expect(await second?.json()).toMatchObject({ ok: false, retryable: true });
|
|
120
|
+
release();
|
|
121
|
+
expect((await first)?.status).toBe(409);
|
|
122
|
+
expect(f.store.terminalApplyState(["side-concurrent", "attempt-concurrent", "derived-concurrent", "turn-concurrent"])).toBe("received");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("real coordinator database failure rejects apply and leaves receipt retryable", async () => {
|
|
126
|
+
const f = fixture();
|
|
127
|
+
const sideStore = new SideThreadStore(f.db);
|
|
128
|
+
const coordinator = new SideThreadCoordinator(sideStore, f.port, { enabled: true, authorizeNode: () => true });
|
|
129
|
+
await f.port.start({ operationId: "op-db-fail", requestKey: "rk-db-fail", sideChatId: "side-db", attemptId: "attempt-db", nodeId: "node-1", threadId: "derived-db", prompt: "q", attachments: [] }).catch(() => {});
|
|
130
|
+
const command = f.store.claim(actor())!;
|
|
131
|
+
f.store.ack(actor(), { protocol: "side_thread.ack.v1", commandId: command.commandId, operationId: "op-db-fail", state: "accepted", errorCode: null, result: { threadId: null, turnId: "turn-db", destinationTurnId: null } });
|
|
132
|
+
const original = f.db.transaction.bind(f.db);
|
|
133
|
+
(f.db as any).transaction = () => { throw new Error("disk I/O failure"); };
|
|
134
|
+
const terminal = { protocol: "side_thread.terminal.v1", sideThreadId: "side-db", attemptId: "attempt-db", threadId: "derived-db", turnId: "turn-db", status: "completed", text: "answer", errorCode: null };
|
|
135
|
+
await expect(f.port.acceptTerminal(actor(), terminal)).rejects.toThrow(/disk I\/O/);
|
|
136
|
+
expect(f.store.terminalApplyState(["side-db", "attempt-db", "derived-db", "turn-db"])).toBe("received");
|
|
137
|
+
(f.db as any).transaction = original;
|
|
138
|
+
coordinator.close();
|
|
139
|
+
});
|
|
140
|
+
});
|