@sleep2agi/commhub-server 0.9.0-preview.139 → 0.9.0-preview.140
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/package.json +1 -1
- package/src/agent-department-http.test.ts +148 -0
- package/src/chain-attempt-dedup.test.ts +447 -0
- package/src/create-node-tool-schema.test.ts +1 -0
- package/src/create-node-validate.test.ts +6 -0
- package/src/create-node-validate.ts +7 -2
- package/src/db.ts +190 -21
- package/src/departments.ts +16 -0
- package/src/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -0
- package/src/host-supervisor-identity-http.test.ts +20 -4
- package/src/hub-catalog-read-http.test.ts +187 -0
- package/src/node-secret-rest-http.test.ts +190 -0
- package/src/observer-avatar-http.test.ts +13 -2
- package/src/report-status-resume-id-handover.test.ts +34 -0
- package/src/report-status-secret-keys.test.ts +140 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-people.ts +8 -4
- package/src/rest-projections.ts +2 -0
- package/src/schedule-agent.ts +1 -1
- package/src/scheduled-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -0
- package/src/scheduled-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -0
- package/src/scheduled-tasks.ts +78 -23
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +15 -0
- package/src/server.ts +162 -3
- package/src/tools.ts +99 -16
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
3
|
+
import { db, syncScheduledRunForTask } from "./db.js";
|
|
4
|
+
import { runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
5
|
+
|
|
6
|
+
if (!process.env.COMMHUB_DB) throw new Error("test1274 requires an explicit disposable SQLite COMMHUB_DB");
|
|
7
|
+
let server: any, base = "", network = "", ownerToken = "", ownerId = "";
|
|
8
|
+
const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
|
|
9
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
10
|
+
const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
|
|
11
|
+
"Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
12
|
+
return { status: r.status, body: await r.json() as any };
|
|
13
|
+
}
|
|
14
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>) {
|
|
15
|
+
const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
|
|
16
|
+
"Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
17
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
|
|
18
|
+
const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
|
|
19
|
+
const envelope = JSON.parse(line ? line.slice(6) : raw);
|
|
20
|
+
try { return JSON.parse(envelope.result.content[0].text); }
|
|
21
|
+
catch { return { ok: false, transportError: true }; }
|
|
22
|
+
}
|
|
23
|
+
const readRun = (taskId: string) => db.get<any>(
|
|
24
|
+
"SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
|
|
25
|
+
const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
|
|
26
|
+
const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
|
|
27
|
+
async function fire() {
|
|
28
|
+
const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
|
|
29
|
+
name: `test1274-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1274 work",
|
|
30
|
+
schedule: { type: "interval", every_seconds: 120 }, timezone: "UTC" });
|
|
31
|
+
expect(created.status).toBe(201);
|
|
32
|
+
const scheduleId = created.body.schedule.schedule_id;
|
|
33
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
|
|
34
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
|
|
35
|
+
await runDueScheduledTasks();
|
|
36
|
+
const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
|
|
37
|
+
expect(run.task_id).toBeTruthy();
|
|
38
|
+
return { taskId: run.task_id as string, scheduleId };
|
|
39
|
+
}
|
|
40
|
+
const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
|
|
41
|
+
mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
|
|
42
|
+
const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
|
|
46
|
+
network = owner.network_id!; ownerId = owner.user!.user_id;
|
|
47
|
+
for (const key of ["a", "b"]) {
|
|
48
|
+
const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
|
|
49
|
+
const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
|
|
50
|
+
nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
|
|
51
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
|
|
52
|
+
}
|
|
53
|
+
const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
54
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
55
|
+
}, 30_000);
|
|
56
|
+
afterAll(() => server?.stop?.(true));
|
|
57
|
+
|
|
58
|
+
describe("#1274 exact successful scheduler work outcomes over real HTTP/MCP", () => {
|
|
59
|
+
test("explicit no_changes persists with the same successful run and is readable; cadence stays unchanged", async () => {
|
|
60
|
+
const { taskId, scheduleId } = await fire(), before = cadence(scheduleId);
|
|
61
|
+
expect(readRun(taskId).schedule_revision).toBe(before.revision);
|
|
62
|
+
const out = await reply(taskId, { scheduled_work_outcome: "no_changes", scheduled_run_id: "forged-and-ignored" });
|
|
63
|
+
expect(out.ok).toBe(true); expect(out.scheduled_work_outcome).toBe("no_changes");
|
|
64
|
+
expect(readTask(taskId).status).toBe("replied");
|
|
65
|
+
expect(readRun(taskId)).toMatchObject({ status: "replied", work_outcome: "no_changes", work_outcome_node_id: nodes.a.id });
|
|
66
|
+
expect(cadence(scheduleId)).toEqual(before);
|
|
67
|
+
const history = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}/runs`);
|
|
68
|
+
expect(history.status).toBe(200);
|
|
69
|
+
expect(history.body.runs[0]).toMatchObject({ task_id: taskId, work_outcome: "no_changes", schedule_revision: before.revision, work_outcome_node_id: nodes.a.id });
|
|
70
|
+
});
|
|
71
|
+
test("changed is explicit even when the reply's natural-language text says no changes", async () => {
|
|
72
|
+
const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: "changed" })).ok).toBe(true);
|
|
73
|
+
expect(readRun(taskId).work_outcome).toBe("changed");
|
|
74
|
+
});
|
|
75
|
+
test("legacy success and empty-looking text remain unknown with unchanged response shape", async () => {
|
|
76
|
+
const { taskId } = await fire(), out = await reply(taskId);
|
|
77
|
+
expect(out.ok).toBe(true); expect(out.scheduled_work_outcome).toBeUndefined();
|
|
78
|
+
expect(readRun(taskId)).toMatchObject({ status: "replied", work_outcome: null, work_outcome_node_id: null });
|
|
79
|
+
});
|
|
80
|
+
test("malformed, failed and cancelled signals do not write; ordinary terminal failures stay unknown", async () => {
|
|
81
|
+
for (const [status, value] of [["replied", null], ["replied", "empty"], ["failed", "no_changes"], ["cancelled", "no_changes"]]) {
|
|
82
|
+
const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: value }, status!)).ok).toBe(false);
|
|
83
|
+
expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0);
|
|
84
|
+
expect((await reply(taskId, undefined, status!)).ok).toBe(true); expect(readRun(taskId).work_outcome).toBeNull();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test("sibling node and a human token cannot classify the assigned node's run", async () => {
|
|
88
|
+
const { taskId } = await fire();
|
|
89
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", nodes.b.token)).ok).toBe(false);
|
|
90
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", ownerToken)).ok).toBe(false);
|
|
91
|
+
expect(readTask(taskId).status).toBe("delivered"); expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
test("a foreign network token cannot classify or terminalize the run", async () => {
|
|
94
|
+
const { taskId } = await fire(), foreign = register(`work_foreign_${crypto.randomUUID()}`, "Fixture1274Only!x");
|
|
95
|
+
const token = createNetworkTokenForNode(foreign.user!.user_id, foreign.network_id!, `foreign-${crypto.randomUUID()}`, `n_${crypto.randomUUID()}`);
|
|
96
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", token.token!)).ok).toBe(false);
|
|
97
|
+
expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
test("a legacy unbound node token can still reply normally but cannot report a work signal", async () => {
|
|
100
|
+
const { taskId } = await fire();
|
|
101
|
+
db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1 AND network_id = ?2", [nodes.a.tokenId, network]);
|
|
102
|
+
try {
|
|
103
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(false);
|
|
104
|
+
expect((await reply(taskId)).ok).toBe(true); expect(readRun(taskId).work_outcome).toBeNull();
|
|
105
|
+
} finally { db.run("UPDATE api_tokens SET bound_node_id = ?1 WHERE token_id = ?2 AND network_id = ?3", [nodes.a.id, nodes.a.tokenId, network]); }
|
|
106
|
+
});
|
|
107
|
+
test("forged schedule metadata on an ordinary task cannot become an authoritative occurrence", async () => {
|
|
108
|
+
const { scheduleId } = await fire(); const sent = await rest(ownerToken, "POST", "/api/task", { network_id: network, alias: nodes.a.alias, task: "ordinary1274" });
|
|
109
|
+
expect(sent.status).toBe(200);
|
|
110
|
+
expect((await reply(sent.body.task_id, { scheduled_work_outcome: "no_changes", scheduled_task_id: scheduleId })).ok).toBe(false);
|
|
111
|
+
expect(readTask(sent.body.task_id).status).toBe("delivered"); expect(countReplies(sent.body.task_id)).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
test("multiple authoritative bindings refuse instead of fanning one outcome into two runs", async () => {
|
|
114
|
+
const { taskId, scheduleId } = await fire(), run = readRun(taskId), duplicate = `srun_dup_${crypto.randomUUID()}`;
|
|
115
|
+
db.run("INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status) VALUES (?1, ?2, ?3, ?4, ?5, 'delivered')", [duplicate, scheduleId, network, new Date(Date.now() + 86400_000).toISOString(), taskId]);
|
|
116
|
+
try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).error).toBe("scheduled_work_outcome_run_unresolved");
|
|
117
|
+
expect(readTask(taskId).status).toBe("delivered"); expect(countReplies(taskId)).toBe(0); }
|
|
118
|
+
finally { db.run("DELETE FROM scheduled_task_runs WHERE run_id = ?1", duplicate); }
|
|
119
|
+
expect(readRun(taskId).run_id).toBe(run.run_id); expect(readRun(taskId).work_outcome).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
test("expired/late receipts never classify a run or rewrite its original terminal state", async () => {
|
|
122
|
+
const { taskId } = await fire(), thread_id = `thread_${crypto.randomUUID()}`, turn_id = `turn_${crypto.randomUUID()}`;
|
|
123
|
+
db.run("UPDATE tasks SET status = 'expired', thread_id = ?1, turn_id = ?2, completed_at = datetime('now') WHERE task_id = ?3", [thread_id, turn_id, taskId]); syncScheduledRunForTask(taskId, network);
|
|
124
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" }, "replied", nodes.a.token, { thread_id, turn_id })).error).toBe("scheduled_work_outcome_terminal");
|
|
125
|
+
expect((await reply(taskId, undefined, "replied", nodes.a.token, { thread_id, turn_id })).late).toBe(true);
|
|
126
|
+
expect(readTask(taskId).status).toBe("expired"); expect(readRun(taskId)).toMatchObject({ status: "expired", work_outcome: null });
|
|
127
|
+
});
|
|
128
|
+
test("success cannot retry; an expired fixture task clears stale outcome on retry and later failure", async () => {
|
|
129
|
+
const { taskId } = await fire(); expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
130
|
+
expect((await mcp(ownerToken, "retry_task", { task_id: taskId, network_id: network })).ok).toBe(false);
|
|
131
|
+
// Fixture-only legacy/corrupt terminal injection: preserve the stale run
|
|
132
|
+
// signal deliberately so the retry clearing branch is load-bearing.
|
|
133
|
+
db.run("UPDATE tasks SET status = 'expired' WHERE task_id = ?1", taskId);
|
|
134
|
+
expect((await mcp(ownerToken, "retry_task", { task_id: taskId, network_id: network })).ok).toBe(true);
|
|
135
|
+
expect(readRun(taskId)).toMatchObject({ status: "delivered", work_outcome: null, work_outcome_node_id: null });
|
|
136
|
+
expect((await reply(taskId, undefined, "failed")).ok).toBe(true); expect(readRun(taskId)).toMatchObject({ status: "failed", work_outcome: null });
|
|
137
|
+
});
|
|
138
|
+
test("injected outcome write failure rolls back task, inbox and run atomically", async () => {
|
|
139
|
+
const { taskId } = await fire(), run = readRun(taskId);
|
|
140
|
+
db.exec(`CREATE TRIGGER test1274_outcome_fault BEFORE UPDATE OF work_outcome ON scheduled_task_runs WHEN NEW.run_id = '${run.run_id}' AND NEW.work_outcome IS NOT NULL BEGIN SELECT RAISE(ABORT, 'test1274_fault'); END`);
|
|
141
|
+
try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
|
|
142
|
+
finally { db.exec("DROP TRIGGER test1274_outcome_fault"); }
|
|
143
|
+
expect(readTask(taskId)).toMatchObject({ status: "delivered", result: null });
|
|
144
|
+
expect(readRun(taskId)).toMatchObject({ status: "delivered", work_outcome: null }); expect(countReplies(taskId)).toBe(0);
|
|
145
|
+
expect((await reply(taskId, { scheduled_work_outcome: "changed" })).ok).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
test("a later schedule edit cannot relabel the dispatch revision; history alone grants no backoff", async () => {
|
|
148
|
+
const { taskId, scheduleId } = await fire(), revision = readRun(taskId).schedule_revision;
|
|
149
|
+
const edit = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision, name: "edited1274" }); expect(edit.status).toBe(200);
|
|
150
|
+
const before = cadence(scheduleId); expect(before.revision).toBe(revision + 1);
|
|
151
|
+
expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
|
|
152
|
+
expect(readRun(taskId).schedule_revision).toBe(revision); expect(cadence(scheduleId)).toEqual(before);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
export type ScheduledWorkOutcome = "changed" | "no_changes";
|
|
4
|
+
export function parseScheduledWorkOutcome(meta: unknown, status: string):
|
|
5
|
+
{ ok: true; outcome?: ScheduledWorkOutcome } | { ok: false; error: string } {
|
|
6
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)
|
|
7
|
+
|| !Object.hasOwn(meta, "scheduled_work_outcome")) return { ok: true };
|
|
8
|
+
const outcome = (meta as Record<string, unknown>).scheduled_work_outcome;
|
|
9
|
+
if (outcome !== "changed" && outcome !== "no_changes")
|
|
10
|
+
return { ok: false, error: "invalid_scheduled_work_outcome" };
|
|
11
|
+
if (status !== "replied") return { ok: false, error: "scheduled_work_outcome_requires_success" };
|
|
12
|
+
return { ok: true, outcome };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Binding = { run_id: string; schedule_id: string; task_id: string; network_id: string; node_id: string };
|
|
16
|
+
/** Called within the normal reply transaction, after the existing sender and
|
|
17
|
+
* recipient checks. Caller metadata never supplies schedule/run bindings. */
|
|
18
|
+
export function qualifyScheduledWorkOutcome(taskId: string, networkId: string, nodeId: string | null):
|
|
19
|
+
{ ok: true; binding: Binding } | { ok: false; error: string } {
|
|
20
|
+
if (!nodeId) return { ok: false, error: "scheduled_work_outcome_bound_node_required" };
|
|
21
|
+
if (db.transactionalFeaturesRefusal !== null)
|
|
22
|
+
return { ok: false, error: "scheduled_work_outcome_backend_unsupported" };
|
|
23
|
+
const task = db.get<{ to_node_id: string | null; from_name: string; status: string }>(
|
|
24
|
+
"SELECT to_node_id, from_name, status FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
|
|
25
|
+
if (!task || task.to_node_id !== nodeId || task.from_name !== "scheduler"
|
|
26
|
+
|| !["created", "delivered", "acked", "running"].includes(task.status))
|
|
27
|
+
return { ok: false, error: "scheduled_work_outcome_task_not_owned" };
|
|
28
|
+
const runs = db.all<{ run_id: string; schedule_id: string; status: string; work_outcome: string | null }>(
|
|
29
|
+
`SELECT r.run_id, r.schedule_id, r.status, r.work_outcome FROM scheduled_task_runs r
|
|
30
|
+
WHERE r.task_id = ?1 AND r.network_id = ?2`, taskId, networkId);
|
|
31
|
+
if (runs.length !== 1 || !["queued", "delivered"].includes(runs[0].status) || runs[0].work_outcome !== null)
|
|
32
|
+
return { ok: false, error: "scheduled_work_outcome_run_unresolved" };
|
|
33
|
+
if (!db.get("SELECT schedule_id FROM scheduled_tasks WHERE schedule_id = ?1 AND network_id = ?2", runs[0].schedule_id, networkId))
|
|
34
|
+
return { ok: false, error: "scheduled_work_outcome_run_unresolved" };
|
|
35
|
+
return { ok: true, binding: { run_id: runs[0].run_id, schedule_id: runs[0].schedule_id,
|
|
36
|
+
task_id: taskId, network_id: networkId, node_id: nodeId } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The task has just reached replied in this same transaction. An outcome
|
|
40
|
+
* write failure must roll back task, inbox and run together. No cadence write. */
|
|
41
|
+
export function saveScheduledWorkOutcome(binding: Binding, outcome: ScheduledWorkOutcome): void {
|
|
42
|
+
const result = db.run(
|
|
43
|
+
`UPDATE scheduled_task_runs SET work_outcome = ?1, work_outcome_node_id = ?6
|
|
44
|
+
WHERE run_id = ?2 AND schedule_id = ?3 AND task_id = ?4 AND network_id = ?5
|
|
45
|
+
AND work_outcome IS NULL AND status IN ('queued', 'delivered')
|
|
46
|
+
AND EXISTS (SELECT 1 FROM tasks t WHERE t.task_id = ?4 AND t.network_id = ?5
|
|
47
|
+
AND t.to_node_id = ?6 AND t.from_name = 'scheduler' AND t.status = 'replied')`,
|
|
48
|
+
[outcome, binding.run_id, binding.schedule_id, binding.task_id, binding.network_id, binding.node_id]);
|
|
49
|
+
if (result.changes !== 1) throw new Error("scheduled_work_outcome_atomic_write_failed");
|
|
50
|
+
}
|
package/src/scoped-secrets.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { db } from './db';
|
|
|
4
4
|
import { getUserNetworkRole } from './auth';
|
|
5
5
|
import { providerScanTargets } from './daemon-provider-snapshots';
|
|
6
6
|
import { validScopedSecretKey } from './shared/scoped-secret-contract';
|
|
7
|
+
import { observeSecretWrite, transportOf } from './env-transport-gate.js';
|
|
8
|
+
import { refusesPlainTransport } from './env-transport-policy.js';
|
|
7
9
|
type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
|
|
8
10
|
type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
|
|
9
11
|
type Request = { request_id: string; network_id: string; daemon_node_id: string | null; node_id: string | null;
|
|
@@ -37,6 +39,13 @@ export function requestNodeSecrets(h: Human, node: string, action: Request['acti
|
|
|
37
39
|
if (action !== 'list' && (!validScopedSecretKey(key) || !Number.isSafeInteger(revision) || revision! < 0
|
|
38
40
|
|| (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
|
|
39
41
|
expire();
|
|
42
|
+
// #1510 — transport gate (log-only by default). A `put` carries the secret, so
|
|
43
|
+
// that is the only action worth observing; delete/list leak nothing.
|
|
44
|
+
if (action === 'put') {
|
|
45
|
+
const transport = transportOf(h.networkId!, node);
|
|
46
|
+
if (refusesPlainTransport(transport)) return denied('secret_transport_plain');
|
|
47
|
+
observeSecretWrite(h.networkId!, node, `node_secret_${action}`);
|
|
48
|
+
}
|
|
40
49
|
if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === null && r.node_id === node && r.status === 'pending')) return denied('secret_store_busy');
|
|
41
50
|
if (requests.size >= 1000) return denied('secret_queue_busy');
|
|
42
51
|
const r: Request = { request_id: `nsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: null,
|
|
@@ -77,6 +86,12 @@ export function requestScopedSecrets(h: Human, daemon: string, node: string | nu
|
|
|
77
86
|
if (action !== 'list' && (!validScopedSecretKey(key)
|
|
78
87
|
|| !Number.isSafeInteger(revision) || revision! < 0 || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
|
|
79
88
|
expire();
|
|
89
|
+
// #1510 — same observation on the daemon lane (the daemon applies the value).
|
|
90
|
+
if (action === 'put') {
|
|
91
|
+
const transport = transportOf(h.networkId!, daemon);
|
|
92
|
+
if (refusesPlainTransport(transport)) return denied('secret_transport_plain');
|
|
93
|
+
observeSecretWrite(h.networkId!, daemon, `secret_${action}`);
|
|
94
|
+
}
|
|
80
95
|
if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === daemon && r.status === 'pending')) return denied('secret_store_busy');
|
|
81
96
|
if (requests.size >= 1000) return denied('secret_queue_busy');
|
|
82
97
|
const r: Request = { request_id: `dsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: daemon,
|
package/src/server.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
|
11
11
|
import { readNodeHealth } from "./node-health-store.js";
|
|
12
12
|
import { wantsAllTools } from "./tool-audience.js";
|
|
13
13
|
import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
|
|
14
|
-
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
|
|
14
|
+
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setAgentDepartment, setMemberDepartment, updateDepartment } from "./departments.js";
|
|
15
15
|
import { addManualMember, canManageGroup, createDepartmentGroup, getDepartmentGroup, groupById, groupTx, groupViewerCan, isGroupMember, listGroupMembers, listVisibleGroups, readGroup, removeManualMember, renameGroup, syncDepartmentGroups } from "./department-groups.js";
|
|
16
16
|
import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
|
|
17
17
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
@@ -86,6 +86,9 @@ import { listTaskLateReplies } from "./task-late-replies.js";
|
|
|
86
86
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
87
87
|
import { startDueReminderTimer } from "./requirement-due-reminders.js";
|
|
88
88
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
89
|
+
import { readHubProviders } from "./hub-provider-config.js";
|
|
90
|
+
import { requestNodeSecrets, getScopedSecrets } from "./scoped-secrets.js";
|
|
91
|
+
import { readEnvTransportLog } from "./env-transport-gate.js";
|
|
89
92
|
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
|
|
90
93
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
91
94
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
@@ -1970,12 +1973,13 @@ return Bun.serve({
|
|
|
1970
1973
|
// 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
|
|
1971
1974
|
const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
|
|
1972
1975
|
const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
|
|
1976
|
+
const nodeDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/nodes\/([^/]+)\/department$/);
|
|
1973
1977
|
const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes|group)$/);
|
|
1974
|
-
if (deptMatch || memberDeptMatch || deptSubMatch) {
|
|
1978
|
+
if (deptMatch || memberDeptMatch || deptSubMatch || nodeDeptMatch) {
|
|
1975
1979
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1976
1980
|
const resolved = token ? resolveToken(token) : null;
|
|
1977
1981
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1978
|
-
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch ?? deptSubMatch)![1]);
|
|
1982
|
+
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch ?? deptSubMatch ?? nodeDeptMatch)![1]);
|
|
1979
1983
|
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1980
1984
|
const hubAdmin = isHubAdminCredential(resolved);
|
|
1981
1985
|
const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
|
|
@@ -2081,6 +2085,19 @@ return Bun.serve({
|
|
|
2081
2085
|
// 组织架构变了,负责人们的「本部门」跟着变:断开他们(和被调动的人)的实时流,按新权限重连。
|
|
2082
2086
|
const leadersBefore = departmentLeaders(netId);
|
|
2083
2087
|
const kick = (extra: string[] = []) => { for (const uid of new Set([...leadersBefore, ...departmentLeaders(netId), ...extra])) closeUserStreamsInNetwork(netId, uid); };
|
|
2088
|
+
// Agent(节点)显式归属部门:只有 owner / admin(以及 Hub 管理员)。
|
|
2089
|
+
// 负责人对自己部门的 Agent 也可调动 —— 和「人」的调动同一条 scope 规则。
|
|
2090
|
+
if (nodeDeptMatch) {
|
|
2091
|
+
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
2092
|
+
const targetNode = decodeURIComponent(nodeDeptMatch[2]);
|
|
2093
|
+
if (!canManage) {
|
|
2094
|
+
const current = db.get<{ department_id: string | null }>("SELECT department_id FROM nodes WHERE network_id = ?1 AND node_id = ?2", netId, targetNode);
|
|
2095
|
+
if (current && (!inManaged(current.department_id) || (!inManaged(body.department_id) && (blank(body.department_id) || exists(body.department_id))))) return scopeDenied();
|
|
2096
|
+
}
|
|
2097
|
+
const r = setAgentDepartment(netId, targetNode, body.department_id);
|
|
2098
|
+
if (r.ok) auditDepartment(resolved.user, "agent_department_set", netId, `${targetNode} → ${r.department_id ?? "(unassigned)"}${via === "leader" ? " via=leader" : ""}`);
|
|
2099
|
+
return reply(r);
|
|
2100
|
+
}
|
|
2084
2101
|
if (memberDeptMatch) {
|
|
2085
2102
|
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
2086
2103
|
const targetUid = decodeURIComponent(memberDeptMatch[2]);
|
|
@@ -2717,6 +2734,148 @@ return Bun.serve({
|
|
|
2717
2734
|
return withCors(req, Response.json({ ok: true, network_id: networkId, since: new Date(sinceMs).toISOString(), mode: nodePermissionsFlag(), total: nodes.reduce((n, e) => n + e.total, 0), nodes }));
|
|
2718
2735
|
}
|
|
2719
2736
|
|
|
2737
|
+
// ── #1473 (#909): read-only Hub catalogs for the desktop client ──
|
|
2738
|
+
//
|
|
2739
|
+
// Skills / network secrets / Providers have existed on this Hub only as
|
|
2740
|
+
// MCP tools (list_skills, list_network_secrets, list_providers). With no
|
|
2741
|
+
// HTTP surface, the client shipped a fixture page instead
|
|
2742
|
+
// (agent-network-app/src/hub-scope-demo.ts: "Hub 这些写接口还没接通 ... 不发请求").
|
|
2743
|
+
// These three endpoints expose the SAME rows to a USER token and never a
|
|
2744
|
+
// secret value: names and timestamps only, plus the review flag, so the
|
|
2745
|
+
// client can stop inventing data.
|
|
2746
|
+
const hubCatalog = url.pathname === "/api/skills" ? "skills"
|
|
2747
|
+
: url.pathname === "/api/secrets" ? "secrets"
|
|
2748
|
+
: url.pathname === "/api/providers" ? "providers" : null;
|
|
2749
|
+
if (hubCatalog && req.method === "GET") {
|
|
2750
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2751
|
+
// A node token is not a person: it may not enumerate the network's
|
|
2752
|
+
// catalogs (same rule as the node-permission report above).
|
|
2753
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2754
|
+
const networkId = singleNetworkId(restScope);
|
|
2755
|
+
if (!networkId) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
2756
|
+
const role = restAuth.userId ? getUserNetworkRole(restAuth.userId, networkId) : null;
|
|
2757
|
+
if (!isAdmin && !role) {
|
|
2758
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network member only" }, { status: 403 }));
|
|
2759
|
+
}
|
|
2760
|
+
const reviewer = isAdmin || role === "owner" || role === "admin";
|
|
2761
|
+
if (hubCatalog === "skills") {
|
|
2762
|
+
// Pending submissions stay invisible to plain members even when they
|
|
2763
|
+
// ask: `include_pending` is a reviewer switch, not a client wish.
|
|
2764
|
+
const includePending = url.searchParams.get("include_pending") === "1" && reviewer;
|
|
2765
|
+
const rawLimit = Number(url.searchParams.get("limit") ?? 100);
|
|
2766
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 100;
|
|
2767
|
+
let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
|
|
2768
|
+
created_at, updated_at, reviewed_at, review_note
|
|
2769
|
+
FROM skillhub_skills WHERE network_id = ?1`;
|
|
2770
|
+
if (!includePending) sql += ` AND status = 'published'`;
|
|
2771
|
+
sql += ` ORDER BY updated_at DESC LIMIT ${limit}`;
|
|
2772
|
+
return withCors(req, Response.json({ ok: true, network_id: networkId, reviewer, skills: db.all(sql, networkId) }));
|
|
2773
|
+
}
|
|
2774
|
+
if (hubCatalog === "secrets") {
|
|
2775
|
+
// Keys and timestamps only. The ciphertext columns are not selected at
|
|
2776
|
+
// all, so a future "just add the value for convenience" edit has to
|
|
2777
|
+
// change this query on purpose instead of leaking by accident.
|
|
2778
|
+
const rows = db.all<{ key: string; updated_at: number | string | null }>(
|
|
2779
|
+
"SELECT key, updated_at FROM network_secrets WHERE network_id = ?1 ORDER BY key", networkId);
|
|
2780
|
+
return withCors(req, Response.json({
|
|
2781
|
+
ok: true, network_id: networkId,
|
|
2782
|
+
keys: rows.map(r => ({ key: r.key, updated_at: r.updated_at ?? null })),
|
|
2783
|
+
}));
|
|
2784
|
+
}
|
|
2785
|
+
const providers = readHubProviders({ userId: restAuth.userId ?? null, isNode: false, networkId });
|
|
2786
|
+
if (!providers.ok) {
|
|
2787
|
+
return withCors(req, Response.json(providers, { status: 403 }));
|
|
2788
|
+
}
|
|
2789
|
+
return withCors(req, Response.json({ ...providers, network_id: networkId }));
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
// ── #1510: the log-only view of the secret transport gate ──
|
|
2793
|
+
//
|
|
2794
|
+
// Answers "how many secret writes WOULD a plain-HTTP refusal have blocked,
|
|
2795
|
+
// and on which node" before anyone turns the gate on. Same audience and
|
|
2796
|
+
// shape as the node-permission report: network owner/admin, aggregated.
|
|
2797
|
+
const envTransportLog = url.pathname.match(/^\/api\/networks\/([^/]+)\/env-transport-log$/);
|
|
2798
|
+
if (envTransportLog && req.method === "GET") {
|
|
2799
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2800
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2801
|
+
const networkId = decodeURIComponent(envTransportLog[1]);
|
|
2802
|
+
const role = restAuth.userId ? getUserNetworkRole(restAuth.userId, networkId) : null;
|
|
2803
|
+
if (!isAdmin && role !== "owner" && role !== "admin") {
|
|
2804
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network owner/admin only" }, { status: 403 }));
|
|
2805
|
+
}
|
|
2806
|
+
const sinceRaw = url.searchParams.get("since");
|
|
2807
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : Date.now() - 7 * 86_400_000;
|
|
2808
|
+
if (!Number.isFinite(sinceMs)) return withCors(req, Response.json({ ok: false, error: "invalid_since" }, { status: 400 }));
|
|
2809
|
+
const rows = readEnvTransportLog(networkId, new Date(sinceMs).toISOString().slice(0, 13));
|
|
2810
|
+
return withCors(req, Response.json({
|
|
2811
|
+
ok: true, network_id: networkId, since: new Date(sinceMs).toISOString(),
|
|
2812
|
+
total: rows.reduce((n, r) => n + r.hits, 0), rows,
|
|
2813
|
+
}));
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// ── #1491 E1: the node-secret write lane over REST ──
|
|
2817
|
+
//
|
|
2818
|
+
// The lane already exists end to end (tools.ts `node_secret_*` →
|
|
2819
|
+
// scoped-secrets.ts → node doorbell → agent-node reconcileNodeSecrets), but
|
|
2820
|
+
// it is MCP-only and request/ack shaped: a caller enqueues a request, the
|
|
2821
|
+
// node applies it at its next start and acks with a snapshot. The client has
|
|
2822
|
+
// no way to drive that, which is the only reason it still "cannot manage a
|
|
2823
|
+
// node's env".
|
|
2824
|
+
//
|
|
2825
|
+
// Two endpoints, deliberately thin: enqueue, then poll. Authorization stays
|
|
2826
|
+
// where it already is — requestNodeSecrets allows owner/admin only, so a
|
|
2827
|
+
// plain member (or any node token) is refused by the lane itself.
|
|
2828
|
+
const nodeSecretReq = url.pathname.match(/^\/api\/nodes\/([^/]+)\/secrets$/);
|
|
2829
|
+
const nodeSecretGet = url.pathname.match(/^\/api\/nodes\/([^/]+)\/secrets\/([^/]+)$/);
|
|
2830
|
+
if ((nodeSecretReq || nodeSecretGet) && !restAuth) {
|
|
2831
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2832
|
+
}
|
|
2833
|
+
if (nodeSecretReq && req.method === "POST") {
|
|
2834
|
+
if (restAuth!.networkId) return userTokenRequired(req);
|
|
2835
|
+
const networkId = singleNetworkId(restScope);
|
|
2836
|
+
if (!networkId) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
2837
|
+
const nodeId = decodeURIComponent(nodeSecretReq[1]);
|
|
2838
|
+
if (!nodeId) return withCors(req, Response.json({ ok: false, error: "node_id_required" }, { status: 400 }));
|
|
2839
|
+
let body: any;
|
|
2840
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
2841
|
+
const action = body?.action;
|
|
2842
|
+
if (action !== "list" && action !== "put" && action !== "delete") {
|
|
2843
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_action", message: "action must be list | put | delete" }, { status: 400 }));
|
|
2844
|
+
}
|
|
2845
|
+
// A `put` without a revision is a client bug, not a server decision: the
|
|
2846
|
+
// node rejects it anyway, and silently inventing 0 would overwrite a file
|
|
2847
|
+
// the operator edited in the meantime.
|
|
2848
|
+
if (action !== "list" && !Number.isSafeInteger(body?.revision)) {
|
|
2849
|
+
return withCors(req, Response.json({ ok: false, error: "revision_required", message: "put/delete need the revision from the last list" }, { status: 400 }));
|
|
2850
|
+
}
|
|
2851
|
+
const result = requestNodeSecrets(
|
|
2852
|
+
{ userId: restAuth!.userId ?? null, isNode: false, networkId },
|
|
2853
|
+
nodeId, action, body?.key, body?.value, body?.revision,
|
|
2854
|
+
);
|
|
2855
|
+
if (!result.ok) {
|
|
2856
|
+
const status = result.error === "secret_admin_required" ? 403
|
|
2857
|
+
: result.error === "node_secrets_upgrade_required" ? 409
|
|
2858
|
+
: result.error === "secret_store_busy" || result.error === "secret_queue_busy" ? 409
|
|
2859
|
+
: 400;
|
|
2860
|
+
return withCors(req, Response.json(result, { status }));
|
|
2861
|
+
}
|
|
2862
|
+
return withCors(req, Response.json(result));
|
|
2863
|
+
}
|
|
2864
|
+
if (nodeSecretGet && req.method === "GET") {
|
|
2865
|
+
if (restAuth!.networkId) return userTokenRequired(req);
|
|
2866
|
+
const networkId = singleNetworkId(restScope);
|
|
2867
|
+
if (!networkId) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
2868
|
+
const result = getScopedSecrets(
|
|
2869
|
+
{ userId: restAuth!.userId ?? null, isNode: false, networkId },
|
|
2870
|
+
decodeURIComponent(nodeSecretGet[2]),
|
|
2871
|
+
);
|
|
2872
|
+
if (!result.ok) {
|
|
2873
|
+
const status = result.error === "secret_admin_required" ? 403 : 404;
|
|
2874
|
+
return withCors(req, Response.json(result, { status }));
|
|
2875
|
+
}
|
|
2876
|
+
return withCors(req, Response.json(result));
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2720
2879
|
if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
|
|
2721
2880
|
if (!restAuth || !requestToken(req).startsWith("utok_")) {
|
|
2722
2881
|
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|