@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- 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-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3508 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2312 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// REST from_session identity binding.
|
|
2
|
+
//
|
|
3
|
+
// Run: COMMHUB_DB=/tmp/rest-identity-test.db bun test src/rest-identity.test.ts
|
|
4
|
+
// (COMMHUB_DB must be set explicitly — unset defaults to the production DB.)
|
|
5
|
+
//
|
|
6
|
+
// Assertion discipline: results are compared with toEqual against the whole
|
|
7
|
+
// object, so a widened accepted-set fails instead of silently passing.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { nodeAliasForToken, resolveRestFromSession } from "./rest-identity.js";
|
|
11
|
+
|
|
12
|
+
const NTOK = "ntok_abc123";
|
|
13
|
+
const UTOK = "utok_abc123";
|
|
14
|
+
|
|
15
|
+
describe("REST from_session identity binding", () => {
|
|
16
|
+
test("node token claiming another node's alias is refused", () => {
|
|
17
|
+
expect(resolveRestFromSession({
|
|
18
|
+
token: NTOK,
|
|
19
|
+
tokenName: "node:通信IM牛",
|
|
20
|
+
requestedFrom: "通信龙",
|
|
21
|
+
})).toEqual({
|
|
22
|
+
ok: false,
|
|
23
|
+
error: "from_session_identity_mismatch",
|
|
24
|
+
message: "network token from_session does not match token-bound node alias",
|
|
25
|
+
tokenAlias: "通信IM牛",
|
|
26
|
+
requestedFromSession: "通信龙",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("node token omitting from is bound to its own alias, not 'api'", () => {
|
|
31
|
+
expect(resolveRestFromSession({
|
|
32
|
+
token: NTOK,
|
|
33
|
+
tokenName: "node:通信IM牛",
|
|
34
|
+
requestedFrom: undefined,
|
|
35
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("node token claiming its own alias is accepted", () => {
|
|
39
|
+
expect(resolveRestFromSession({
|
|
40
|
+
token: NTOK,
|
|
41
|
+
tokenName: "node:通信IM牛",
|
|
42
|
+
requestedFrom: "通信IM牛",
|
|
43
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("whitespace padding does not defeat the comparison", () => {
|
|
47
|
+
expect(resolveRestFromSession({
|
|
48
|
+
token: NTOK,
|
|
49
|
+
tokenName: "node:通信IM牛",
|
|
50
|
+
requestedFrom: " 通信IM牛 ",
|
|
51
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The Dashboard posts as its logged-in user; this path must not change.
|
|
55
|
+
test("user token keeps the previous behaviour", () => {
|
|
56
|
+
expect(resolveRestFromSession({
|
|
57
|
+
token: UTOK,
|
|
58
|
+
tokenName: "dashboard",
|
|
59
|
+
requestedFrom: "admin",
|
|
60
|
+
})).toEqual({ ok: true, fromSession: "admin" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("user token with no from defaults to the authenticated username", () => {
|
|
64
|
+
expect(resolveRestFromSession({
|
|
65
|
+
token: UTOK,
|
|
66
|
+
tokenName: null,
|
|
67
|
+
authenticatedUsername: "admin",
|
|
68
|
+
requestedFrom: undefined,
|
|
69
|
+
})).toEqual({ ok: true, fromSession: "admin" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("legacy user auth without a username still falls back to 'api'", () => {
|
|
73
|
+
expect(resolveRestFromSession({
|
|
74
|
+
token: UTOK,
|
|
75
|
+
tokenName: null,
|
|
76
|
+
requestedFrom: undefined,
|
|
77
|
+
})).toEqual({ ok: true, fromSession: "api" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A non-string `from` must not be coerced into an identity.
|
|
81
|
+
test("non-string from is not treated as a claim", () => {
|
|
82
|
+
expect(resolveRestFromSession({
|
|
83
|
+
token: NTOK,
|
|
84
|
+
tokenName: "node:通信IM牛",
|
|
85
|
+
requestedFrom: { toString: () => "通信龙" },
|
|
86
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A network-bound token we cannot resolve to an alias must not be able to
|
|
90
|
+
// call itself anything. Found by independent review of this change.
|
|
91
|
+
describe("ntok_ with an unresolvable alias fails closed", () => {
|
|
92
|
+
for (const tokenName of ["node:", "node: ", "dashboard", null, undefined]) {
|
|
93
|
+
test(`claim is refused when tokenName is ${JSON.stringify(tokenName)}`, () => {
|
|
94
|
+
expect(resolveRestFromSession({
|
|
95
|
+
token: NTOK,
|
|
96
|
+
tokenName,
|
|
97
|
+
requestedFrom: "通信龙",
|
|
98
|
+
})).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: "from_session_identity_mismatch",
|
|
101
|
+
message: "network token has no resolvable node alias and may not claim a from_session",
|
|
102
|
+
tokenAlias: "",
|
|
103
|
+
requestedFromSession: "通信龙",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("no claim at all still resolves to 'api'", () => {
|
|
109
|
+
expect(resolveRestFromSession({
|
|
110
|
+
token: NTOK,
|
|
111
|
+
tokenName: "node:",
|
|
112
|
+
requestedFrom: undefined,
|
|
113
|
+
})).toEqual({ ok: true, fromSession: "api" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("nodeAliasForToken", () => {
|
|
118
|
+
test("only ntok_ + node: prefix yields an alias", () => {
|
|
119
|
+
expect(nodeAliasForToken(NTOK, "node:X")).toBe("X");
|
|
120
|
+
// A user token whose name merely looks node-shaped must not bind.
|
|
121
|
+
expect(nodeAliasForToken(UTOK, "node:X")).toBeNull();
|
|
122
|
+
// A node token whose name is not node-scoped must not bind.
|
|
123
|
+
expect(nodeAliasForToken(NTOK, "dashboard")).toBeNull();
|
|
124
|
+
expect(nodeAliasForToken(NTOK, null)).toBeNull();
|
|
125
|
+
// `node:` with nothing after it is not an identity.
|
|
126
|
+
expect(nodeAliasForToken(NTOK, "node: ")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// REST from_session identity binding.
|
|
2
|
+
//
|
|
3
|
+
// Both transports resolve the same `api_tokens` row through resolveToken, and
|
|
4
|
+
// auth.ts already states the invariant:
|
|
5
|
+
//
|
|
6
|
+
// "Network-bound node tokens are an identity boundary: they must not spoof
|
|
7
|
+
// another node via from_session."
|
|
8
|
+
//
|
|
9
|
+
// tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
|
|
10
|
+
// POST /api/task did not — it took `body.from` verbatim, so any node holding
|
|
11
|
+
// its own ntok_ could claim another node's alias. That value is stored on the
|
|
12
|
+
// task row and, once sender labels reach the copresence TUIs, rendered to a
|
|
13
|
+
// human as an attribution.
|
|
14
|
+
//
|
|
15
|
+
// This is the same decision expressed as a pure function so every credential
|
|
16
|
+
// kind is a directly constructible test input rather than a hand-assembled
|
|
17
|
+
// Request (the #503 convention in this codebase).
|
|
18
|
+
|
|
19
|
+
export interface RestIdentityInput {
|
|
20
|
+
/** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
|
|
21
|
+
token: string;
|
|
22
|
+
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
|
+
tokenName: string | null | undefined;
|
|
24
|
+
/** Username resolved from the authenticated token. */
|
|
25
|
+
authenticatedUsername?: string | null;
|
|
26
|
+
/** Caller-supplied `body.from`. */
|
|
27
|
+
requestedFrom: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RestIdentityResult =
|
|
31
|
+
| { ok: true; fromSession: string }
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: "from_session_identity_mismatch";
|
|
35
|
+
message: string;
|
|
36
|
+
tokenAlias: string;
|
|
37
|
+
requestedFromSession: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Alias a token is bound to, or null when it is not node-scoped. */
|
|
41
|
+
export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
|
|
42
|
+
if (!token.startsWith("ntok_")) return null;
|
|
43
|
+
if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
|
|
44
|
+
const alias = tokenName.slice("node:".length).trim();
|
|
45
|
+
return alias || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
49
|
+
const alias = nodeAliasForToken(input.token, input.tokenName);
|
|
50
|
+
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
51
|
+
|
|
52
|
+
// A network-bound token whose name we cannot resolve to an alias (`node:`,
|
|
53
|
+
// `node: `, or a name that was never node-scoped) is the degenerate case.
|
|
54
|
+
// We know it IS a node credential; we just cannot tell WHICH node. Letting it
|
|
55
|
+
// claim anything is the worst available option, so refuse the claim outright
|
|
56
|
+
// rather than falling through to the unbound path.
|
|
57
|
+
//
|
|
58
|
+
// Production always writes `node:${nodeName}`, so this costs nothing real —
|
|
59
|
+
// but "we couldn't identify you, so you may call yourself anything" is
|
|
60
|
+
// exactly the fail-open shape this change exists to remove.
|
|
61
|
+
if (!alias && input.token.startsWith("ntok_")) {
|
|
62
|
+
if (requested) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "from_session_identity_mismatch",
|
|
66
|
+
message: "network token has no resolvable node alias and may not claim a from_session",
|
|
67
|
+
tokenAlias: "",
|
|
68
|
+
requestedFromSession: requested,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, fromSession: "api" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A user-token client normally does not need to repeat its identity in every
|
|
75
|
+
// request. Prefer an explicit, backwards-compatible claim when present;
|
|
76
|
+
// otherwise attribute the task to the authenticated username. Falling back
|
|
77
|
+
// to the transport label `api` is reserved for legacy auth contexts where
|
|
78
|
+
// the server genuinely has no user identity.
|
|
79
|
+
if (!alias) {
|
|
80
|
+
const authenticatedUsername = typeof input.authenticatedUsername === "string"
|
|
81
|
+
? input.authenticatedUsername.trim()
|
|
82
|
+
: "";
|
|
83
|
+
return { ok: true, fromSession: requested || authenticatedUsername || "api" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Node token claiming somebody else — refuse rather than silently rewrite,
|
|
87
|
+
// so a misconfigured caller is visible instead of quietly relabelled.
|
|
88
|
+
if (requested && requested !== alias) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "from_session_identity_mismatch",
|
|
92
|
+
message: "network token from_session does not match token-bound node alias",
|
|
93
|
+
tokenAlias: alias,
|
|
94
|
+
requestedFromSession: requested,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Node token, no claim or a matching one: bind to the token's own alias.
|
|
99
|
+
return { ok: true, fromSession: alias };
|
|
100
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable REST row projections.
|
|
3
|
+
*
|
|
4
|
+
* These lists deliberately mirror the fields already exposed by the public
|
|
5
|
+
* endpoints before #311. Keeping them in one place makes the wire contract
|
|
6
|
+
* independent from future ALTER TABLE migrations: adding a storage-only
|
|
7
|
+
* column must never silently add a REST response key.
|
|
8
|
+
*/
|
|
9
|
+
export const NETWORK_REST_COLUMNS = [
|
|
10
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
11
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const SESSION_REST_COLUMNS = [
|
|
15
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
16
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
17
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
18
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
19
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
20
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
21
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
22
|
+
"external_schedules",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export const AUDIT_LOG_REST_COLUMNS = [
|
|
26
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
27
|
+
"ip", "network_id", "created_at",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export const TASK_EVENT_REST_COLUMNS = [
|
|
31
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
32
|
+
"created_at", "network_id",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export const TASK_REST_COLUMNS = [
|
|
36
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
37
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
38
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
|
|
39
|
+
"network_id", "parent_task_id", "meta_json",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export const COMPLETION_REST_COLUMNS = [
|
|
43
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
44
|
+
"duration_minutes", "network_id", "completed_at",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
// Storage projection for scheduled-task rows. `created_by` and
|
|
48
|
+
// `schedule_json` are intentionally selected for server-side auth/decoding,
|
|
49
|
+
// then removed by decodeRow before the REST response is serialized.
|
|
50
|
+
export const SCHEDULED_TASK_STORAGE_COLUMNS = [
|
|
51
|
+
"schedule_id", "network_id", "created_by", "name", "target_node_id",
|
|
52
|
+
"target_alias", "task_content", "priority", "schedule_type", "schedule_json",
|
|
53
|
+
"timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
|
|
54
|
+
"last_run_at", "revision", "created_at", "updated_at",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export function sqlColumns(columns: readonly string[], qualifier?: string): string {
|
|
58
|
+
return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
|
|
62
|
+
export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
|
|
63
|
+
export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
|
|
64
|
+
export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
|
|
65
|
+
export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
|
|
66
|
+
export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
|
|
67
|
+
export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);
|
|
@@ -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
|
+
}
|