@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1
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 +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
package/src/db.ts
CHANGED
|
@@ -117,12 +117,43 @@ db.exec(`
|
|
|
117
117
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
118
118
|
);
|
|
119
119
|
|
|
120
|
-
CREATE INDEX IF NOT EXISTS idx_agent_telemetry_host_time
|
|
121
|
-
ON agent_telemetry(network_id, hostname, ip, created_at);
|
|
122
120
|
CREATE INDEX IF NOT EXISTS idx_agent_telemetry_alias_time
|
|
123
121
|
ON agent_telemetry(network_id, alias, created_at);
|
|
124
122
|
`);
|
|
125
123
|
|
|
124
|
+
// Round-2/4 review ② — server-health index split.
|
|
125
|
+
//
|
|
126
|
+
// The /api/server-health/:host query (index.ts) filters
|
|
127
|
+
// WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
|
|
128
|
+
// The old combined index (network_id, hostname, ip, created_at) only
|
|
129
|
+
// covers an equality on hostname AND ip (not OR), so SQLite couldn't
|
|
130
|
+
// use it for the disjunction and fell back to a full scan of
|
|
131
|
+
// agent_telemetry on every server-health page load. At a 30s heartbeat
|
|
132
|
+
// × N agents, the table grows fast and the scan dominates.
|
|
133
|
+
//
|
|
134
|
+
// Split into two narrow indexes so SQLite's OR optimizer can run an
|
|
135
|
+
// index UNION on (hostname-path ∪ ip-path), each path covered by an
|
|
136
|
+
// index that also lets the created_at range be range-scanned.
|
|
137
|
+
//
|
|
138
|
+
// **Honest trade-off** (corrected after reviewer flag): secondary
|
|
139
|
+
// index count goes from 2 (host_time + alias_time) to 3 (hostname_time
|
|
140
|
+
// + ip_time + alias_time). Every agent_telemetry insert now maintains
|
|
141
|
+
// ONE MORE B-tree, so per-insert write-amp is +50%. The trade-off is
|
|
142
|
+
// deliberate: write-amp is bounded by the heartbeat cadence (30s ×
|
|
143
|
+
// N agents) and is small in absolute terms, while the read-path win
|
|
144
|
+
// is going from O(n) full-scan-per-dashboard-poll to O(log n) two-
|
|
145
|
+
// branch index union. For the public-facing hub's expected load
|
|
146
|
+
// shape (many readers, few writers), that's a clear net gain.
|
|
147
|
+
try { db.exec("DROP INDEX IF EXISTS idx_agent_telemetry_host_time"); } catch {}
|
|
148
|
+
try {
|
|
149
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_hostname_time
|
|
150
|
+
ON agent_telemetry(network_id, hostname, created_at)`);
|
|
151
|
+
} catch {}
|
|
152
|
+
try {
|
|
153
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_telemetry_ip_time
|
|
154
|
+
ON agent_telemetry(network_id, ip, created_at)`);
|
|
155
|
+
} catch {}
|
|
156
|
+
|
|
126
157
|
// inbox: add in_reply_to, requires_response, expires_at, scope, node identity
|
|
127
158
|
for (const col of [
|
|
128
159
|
{ name: "in_reply_to", def: "TEXT" },
|
|
@@ -231,6 +262,86 @@ db.exec(`
|
|
|
231
262
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
232
263
|
`);
|
|
233
264
|
|
|
265
|
+
// #261 P0-2 (2026-06-28): must_change_password flag. Added via ALTER
|
|
266
|
+
// (not in the CREATE block above) so it's a no-op on fresh DBs that
|
|
267
|
+
// already get the column via the CREATE path's full definition AND a
|
|
268
|
+
// pure additive migration on existing prod DBs — column defaults 0
|
|
269
|
+
// for every existing row, so old `admin/anethub` deployments are NOT
|
|
270
|
+
// locked or nudged on upgrade (back-compat per 通信龙 spec). Wrapped
|
|
271
|
+
// in try/catch because SQLite throws "duplicate column" if the column
|
|
272
|
+
// already exists on subsequent restarts (no IF NOT EXISTS syntax for
|
|
273
|
+
// ALTER ADD COLUMN until SQLite 3.35+, can't assume that everywhere).
|
|
274
|
+
try {
|
|
275
|
+
db.exec(`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`);
|
|
276
|
+
} catch (e: any) {
|
|
277
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// RFC-024 (2026-06-28): node config-apply foundation.
|
|
281
|
+
//
|
|
282
|
+
// `nodes` table gains two columns:
|
|
283
|
+
// - `config_revision`: monotonically-incrementing per-node revision
|
|
284
|
+
// that gates write conflicts (dashboard sends base_revision, hub
|
|
285
|
+
// rejects 409 if it doesn't match current). Promoted by hub when
|
|
286
|
+
// the node ACKs `applied` from a successful apply.
|
|
287
|
+
// - `config_snapshot`: masked JSON of the node's current effective
|
|
288
|
+
// config, posted by the node via report_status. Dashboard reads
|
|
289
|
+
// this for the GET snapshot path (never touches per-node files).
|
|
290
|
+
//
|
|
291
|
+
// Both ALTERs wrapped in try/catch for the same reason as
|
|
292
|
+
// must_change_password above (no IF NOT EXISTS on SQLite < 3.35).
|
|
293
|
+
try {
|
|
294
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN config_revision INTEGER DEFAULT 0`);
|
|
295
|
+
} catch (e: any) {
|
|
296
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
db.exec(`ALTER TABLE nodes ADD COLUMN config_snapshot TEXT`);
|
|
300
|
+
} catch (e: any) {
|
|
301
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
305
|
+
// At most one row per node may be in a non-terminal state at a time
|
|
306
|
+
// (single-flight enforced at the tool layer; this table just stores).
|
|
307
|
+
//
|
|
308
|
+
// network_id is denormalized from nodes(network_id) at insert time so
|
|
309
|
+
// queries don't need to JOIN through nodes every time + so a node
|
|
310
|
+
// deletion doesn't orphan the history (audit retention).
|
|
311
|
+
db.exec(`
|
|
312
|
+
CREATE TABLE IF NOT EXISTS node_config_updates (
|
|
313
|
+
update_id TEXT PRIMARY KEY,
|
|
314
|
+
node_id TEXT NOT NULL,
|
|
315
|
+
network_id TEXT NOT NULL,
|
|
316
|
+
patch_json TEXT NOT NULL,
|
|
317
|
+
apply_mode TEXT NOT NULL,
|
|
318
|
+
base_revision INTEGER NOT NULL,
|
|
319
|
+
status TEXT NOT NULL,
|
|
320
|
+
error TEXT,
|
|
321
|
+
created_at INTEGER NOT NULL,
|
|
322
|
+
created_by_token TEXT NOT NULL,
|
|
323
|
+
acked_at INTEGER,
|
|
324
|
+
new_revision INTEGER
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
CREATE INDEX IF NOT EXISTS idx_ncu_node_status ON node_config_updates(node_id, status);
|
|
328
|
+
CREATE INDEX IF NOT EXISTS idx_ncu_network ON node_config_updates(network_id);
|
|
329
|
+
`);
|
|
330
|
+
|
|
331
|
+
// F-C (CHANGE_REQ): partial unique index on non-terminal rows. The
|
|
332
|
+
// single-flight check in tools.ts is an app-level check-then-INSERT —
|
|
333
|
+
// safe under single-process Bun, but two hub workers could race and
|
|
334
|
+
// double-insert. This index makes that race impossible at the DB
|
|
335
|
+
// layer regardless of process count. Wrapped in try/catch because
|
|
336
|
+
// SQLite < 3.8.0 doesn't support partial indexes (rare on modern
|
|
337
|
+
// systems; if missing, app-level check is the fallback).
|
|
338
|
+
try {
|
|
339
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncu_node_inflight ON node_config_updates(node_id) WHERE status IN ('pending', 'restarting')`);
|
|
340
|
+
} catch (e: any) {
|
|
341
|
+
// Older SQLite — fall back to app-level single-flight only.
|
|
342
|
+
console.warn(`[commhub] partial unique index uniq_ncu_node_inflight skipped: ${e?.message || e}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
234
345
|
// ── V3: networks table ──
|
|
235
346
|
db.exec(`
|
|
236
347
|
CREATE TABLE IF NOT EXISTS networks (
|
|
@@ -692,8 +803,179 @@ export function generateId(prefix: string): string {
|
|
|
692
803
|
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
693
804
|
}
|
|
694
805
|
|
|
695
|
-
|
|
696
|
-
|
|
806
|
+
// ──────────────────────────────────────────────────────────────────
|
|
807
|
+
// Round-6 A1 — Password hashing: salted scrypt with lazy migration
|
|
808
|
+
//
|
|
809
|
+
// Format: `scrypt$<N>$<salt-b64>$<hash-b64>`
|
|
810
|
+
// N = log2(cost). E.g. 14 → 2^14 = 16384 (~50ms on modern hw).
|
|
811
|
+
// salt-b64 = base64 of 16 random bytes.
|
|
812
|
+
// hash-b64 = base64 of 64-byte scrypt output.
|
|
813
|
+
//
|
|
814
|
+
// Why scrypt: Node built-in crypto.scryptSync — zero new deps.
|
|
815
|
+
// Memory-hard, ASIC-resistant. Better than bcrypt under modern GPU
|
|
816
|
+
// attack. argon2id would be marginally better but pulls a native
|
|
817
|
+
// dep (argon2 npm package) we don't want on the hub.
|
|
818
|
+
//
|
|
819
|
+
// Why "$" delimiter: legacy hashes are bare hex (0-9a-f only). Any
|
|
820
|
+
// "$" in stored hash means new format. Trivial detect with zero
|
|
821
|
+
// false-positives.
|
|
822
|
+
//
|
|
823
|
+
// Lazy migration:
|
|
824
|
+
// - register/bootstrap/changePassword/resetUserPassword always
|
|
825
|
+
// write the new format.
|
|
826
|
+
// - login + changePassword (old-verify) accept BOTH formats.
|
|
827
|
+
// - on successful login against a legacy hash, the caller rehashes
|
|
828
|
+
// in place. Zero downtime, no forced password change.
|
|
829
|
+
//
|
|
830
|
+
// See feedback_assume_unit_before_threshold.md: explicit-set
|
|
831
|
+
// crypto parameters; don't rely on library defaults.
|
|
832
|
+
// ──────────────────────────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
835
|
+
|
|
836
|
+
// Default cost parameter (N = 2^14 = 16384). Tunable via env for
|
|
837
|
+
// test suites that don't want to spend ~50ms per scrypt call.
|
|
838
|
+
// N=10 → 2^10 = 1024 iter, ~5ms (test-only)
|
|
839
|
+
// N=14 → 2^14 = 16384 iter, ~50ms (production)
|
|
840
|
+
// N=15 → 2^15 = 32768 iter, ~100ms (if hw improves and ops want headroom)
|
|
841
|
+
const DEFAULT_SCRYPT_N = 14;
|
|
842
|
+
const SCRYPT_R = 8;
|
|
843
|
+
const SCRYPT_P = 1;
|
|
844
|
+
const SCRYPT_KEYLEN = 64;
|
|
845
|
+
const SCRYPT_SALT_BYTES = 16;
|
|
846
|
+
// Bun's scrypt defaults maxmem to ~32 MiB which is too tight for
|
|
847
|
+
// N ≥ 14 (128 * r * 2^N = ~16 MiB at r=8, N=14; default has no
|
|
848
|
+
// headroom for the internal buffers). Set explicitly so a higher-N
|
|
849
|
+
// upgrade later doesn't suddenly throw at runtime.
|
|
850
|
+
const SCRYPT_MAXMEM = 128 * 1024 * 1024; // 128 MiB
|
|
851
|
+
|
|
852
|
+
function getScryptN(): number {
|
|
853
|
+
const raw = process.env.COMMHUB_SCRYPT_N;
|
|
854
|
+
if (!raw) return DEFAULT_SCRYPT_N;
|
|
855
|
+
const n = parseInt(raw, 10);
|
|
856
|
+
// Bound: 8 (way-too-weak, only for unit tests) — 20 (DoS risk).
|
|
857
|
+
if (!Number.isFinite(n) || n < 8 || n > 20) return DEFAULT_SCRYPT_N;
|
|
858
|
+
return n;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function hashPassword(plain: string): string {
|
|
862
|
+
const N = getScryptN();
|
|
863
|
+
const salt = randomBytes(SCRYPT_SALT_BYTES);
|
|
864
|
+
const hash = scryptSync(plain, salt, SCRYPT_KEYLEN, {
|
|
865
|
+
N: 1 << N,
|
|
866
|
+
r: SCRYPT_R,
|
|
867
|
+
p: SCRYPT_P,
|
|
868
|
+
maxmem: SCRYPT_MAXMEM,
|
|
869
|
+
});
|
|
870
|
+
return `scrypt$${N}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Verify a plaintext password against the stored hash. Accepts BOTH
|
|
875
|
+
* the new scrypt format and the legacy bare-hex SHA-256 format
|
|
876
|
+
* (back-compat for pre-A1 deployments).
|
|
877
|
+
*
|
|
878
|
+
* Returns:
|
|
879
|
+
* ok — true iff the password matches.
|
|
880
|
+
* needsRehash — true iff the stored hash was legacy format AND the
|
|
881
|
+
* password matched. The caller MUST rehash and write
|
|
882
|
+
* the new format back to disk so the legacy hash
|
|
883
|
+
* stops existing. Returned false for already-new
|
|
884
|
+
* hashes (no rehash needed even on N upgrade —
|
|
885
|
+
* follow-up if/when we bump default N).
|
|
886
|
+
*/
|
|
887
|
+
export function verifyPassword(plain: string, stored: string): { ok: boolean; needsRehash: boolean } {
|
|
888
|
+
// Format detection: new format always contains "$". Legacy bare
|
|
889
|
+
// sha256 hex never does (0-9a-f only).
|
|
890
|
+
if (stored.includes("$")) {
|
|
891
|
+
const parts = stored.split("$");
|
|
892
|
+
if (parts.length !== 4 || parts[0] !== "scrypt") {
|
|
893
|
+
// Unknown new-style scheme — be conservative, reject.
|
|
894
|
+
return { ok: false, needsRehash: false };
|
|
895
|
+
}
|
|
896
|
+
const N = parseInt(parts[1], 10);
|
|
897
|
+
if (!Number.isFinite(N) || N < 8 || N > 20) return { ok: false, needsRehash: false };
|
|
898
|
+
let saltBuf: Buffer;
|
|
899
|
+
let expectedBuf: Buffer;
|
|
900
|
+
try {
|
|
901
|
+
saltBuf = Buffer.from(parts[2], "base64");
|
|
902
|
+
expectedBuf = Buffer.from(parts[3], "base64");
|
|
903
|
+
} catch {
|
|
904
|
+
return { ok: false, needsRehash: false };
|
|
905
|
+
}
|
|
906
|
+
if (expectedBuf.length === 0) return { ok: false, needsRehash: false };
|
|
907
|
+
let actualBuf: Buffer;
|
|
908
|
+
try {
|
|
909
|
+
actualBuf = scryptSync(plain, saltBuf, expectedBuf.length, {
|
|
910
|
+
N: 1 << N,
|
|
911
|
+
r: SCRYPT_R,
|
|
912
|
+
p: SCRYPT_P,
|
|
913
|
+
maxmem: SCRYPT_MAXMEM,
|
|
914
|
+
});
|
|
915
|
+
} catch {
|
|
916
|
+
return { ok: false, needsRehash: false };
|
|
917
|
+
}
|
|
918
|
+
// timingSafeEqual requires equal-length buffers; we sized actual
|
|
919
|
+
// to expected.length above so this is safe.
|
|
920
|
+
const ok = timingSafeEqual(actualBuf, expectedBuf);
|
|
921
|
+
return { ok, needsRehash: false };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Legacy bare-hex SHA-256 format (pre-A1).
|
|
925
|
+
const legacy = new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
|
|
926
|
+
// Use timingSafeEqual on byte buffers (hex strings would be
|
|
927
|
+
// compared via JS === which is variable-time on first-differing
|
|
928
|
+
// char — the timing leak is tiny for fixed-format hex but pin the
|
|
929
|
+
// habit). Both are exactly 64 hex chars when sha256 produced.
|
|
930
|
+
if (stored.length !== legacy.length) {
|
|
931
|
+
// Length mismatch is itself indistinguishable from a parse miss
|
|
932
|
+
// — but we still pay the scrypt to keep this branch's timing
|
|
933
|
+
// peer to the verify branches below.
|
|
934
|
+
burnEqualTimeScrypt(plain);
|
|
935
|
+
return { ok: false, needsRehash: false };
|
|
936
|
+
}
|
|
937
|
+
let ok = false;
|
|
938
|
+
try {
|
|
939
|
+
ok = timingSafeEqual(Buffer.from(stored, "hex"), Buffer.from(legacy, "hex"));
|
|
940
|
+
} catch {
|
|
941
|
+
burnEqualTimeScrypt(plain);
|
|
942
|
+
return { ok: false, needsRehash: false };
|
|
943
|
+
}
|
|
944
|
+
// Timing-oracle close (round-6 round-2 — independent reviewer flag):
|
|
945
|
+
// legacy-OK path callers (auth.ts:login) do `hashPassword(password)`
|
|
946
|
+
// immediately after to rehash → one scrypt cost on top of
|
|
947
|
+
// this function's return. ~50ms.
|
|
948
|
+
// legacy-WRONG path callers reject and return → ZERO additional cost.
|
|
949
|
+
//
|
|
950
|
+
// That asymmetry leaks "is this user on the legacy hash" via wall-
|
|
951
|
+
// clock difference (sub-ms wrong vs ~50ms ok). To close it, burn an
|
|
952
|
+
// equal-time scrypt on the wrong-password branch too. Now both
|
|
953
|
+
// legacy paths cost SHA-256 + one scrypt, matching new-format paths
|
|
954
|
+
// and the missing-user dummy-scrypt path.
|
|
955
|
+
if (!ok) {
|
|
956
|
+
burnEqualTimeScrypt(plain);
|
|
957
|
+
}
|
|
958
|
+
return { ok, needsRehash: ok };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Round-6 round-2 hardening — throwaway scrypt to equalize timing on
|
|
962
|
+
// failure paths. Burns the same wall-clock cost as a real
|
|
963
|
+
// hashPassword() call. Result discarded; salt is a fresh random so
|
|
964
|
+
// the work is real (not optimised away). Cost ~N=14 → 50ms,
|
|
965
|
+
// COMMHUB_SCRYPT_N controlled like the real path.
|
|
966
|
+
function burnEqualTimeScrypt(plain: string): void {
|
|
967
|
+
try {
|
|
968
|
+
const N = getScryptN();
|
|
969
|
+
scryptSync(plain, randomBytes(SCRYPT_SALT_BYTES), SCRYPT_KEYLEN, {
|
|
970
|
+
N: 1 << N,
|
|
971
|
+
r: SCRYPT_R,
|
|
972
|
+
p: SCRYPT_P,
|
|
973
|
+
maxmem: SCRYPT_MAXMEM,
|
|
974
|
+
});
|
|
975
|
+
} catch {
|
|
976
|
+
// Even on failure to scrypt (shouldn't happen), don't surface —
|
|
977
|
+
// this is a timing pacer, not a correctness path.
|
|
978
|
+
}
|
|
697
979
|
}
|
|
698
980
|
|
|
699
981
|
export function hashToken(token: string): string {
|
|
@@ -734,10 +1016,59 @@ export function logAudit(userId: string | null, username: string | null, action:
|
|
|
734
1016
|
// 'replied' if it's still open, and post an inbox notification to the parent's
|
|
735
1017
|
// originator (so an upstream agent or the dashboard sees the chained answer).
|
|
736
1018
|
// Recurse up the chain (in case of N hops).
|
|
737
|
-
|
|
1019
|
+
/**
|
|
1020
|
+
* Auto-chain a sub-task reply up to the parent task lineage.
|
|
1021
|
+
*
|
|
1022
|
+
* `callerNetId` (round5 F2 fix): the network the *caller* (send_reply
|
|
1023
|
+
* / report_completion) is operating in. When supplied, the chain
|
|
1024
|
+
* refuses to traverse into a parent whose `network_id` doesn't match —
|
|
1025
|
+
* blocks cross-tenant write where network B replies to a task they've
|
|
1026
|
+
* named as parent but which actually lives in network A.
|
|
1027
|
+
*
|
|
1028
|
+
* Callers that legitimately need the legacy network-blind behavior
|
|
1029
|
+
* (e.g. server-internal bookkeeping that doesn't have a tenant
|
|
1030
|
+
* context) can omit `callerNetId`, but every MCP tool path MUST
|
|
1031
|
+
* supply it.
|
|
1032
|
+
*
|
|
1033
|
+
* Returns `{ chained, stoppedReason? }` so the caller can gate its
|
|
1034
|
+
* follow-up SSE push on whether the chain actually wrote (round-2
|
|
1035
|
+
* fix per #275 follow-up, 通信牛 catch): even when the WRITE was
|
|
1036
|
+
* refused, callers were still running `SELECT parent.from_name;
|
|
1037
|
+
* pushEvent(parent.from_name, { parent_task_id }, callerNetId)` —
|
|
1038
|
+
* leaking the foreign parent's task_id into the caller's network
|
|
1039
|
+
* via the SSE payload. Now the caller checks `result.chained` before
|
|
1040
|
+
* pushing the chained_reply event.
|
|
1041
|
+
*/
|
|
1042
|
+
export type ChainReplyResult = {
|
|
1043
|
+
/** True iff this call actually wrote into at least one parent row.
|
|
1044
|
+
* When false, callers MUST NOT push SSE events for parent
|
|
1045
|
+
* listeners — the chain would otherwise leak the foreign parent's
|
|
1046
|
+
* identifiers into the caller's network. */
|
|
1047
|
+
chained: boolean;
|
|
1048
|
+
/** Optional reason the chain stopped short. Today the only enforced
|
|
1049
|
+
* reason is `cross_network`; future reasons (max-depth, parent-
|
|
1050
|
+
* archived, etc.) can land here without breaking callers. */
|
|
1051
|
+
stoppedReason?: "cross_network";
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
export function chainReplyToParent(
|
|
1055
|
+
childTaskId: string,
|
|
1056
|
+
replyText: string,
|
|
1057
|
+
replyStatus: "replied" | "failed" | "cancelled" = "replied",
|
|
1058
|
+
maxDepth = 5,
|
|
1059
|
+
callerNetId?: string | null,
|
|
1060
|
+
): ChainReplyResult {
|
|
738
1061
|
let currentChildId: string | null = childTaskId;
|
|
739
1062
|
let currentReply = replyText;
|
|
740
1063
|
let depth = 0;
|
|
1064
|
+
let chained = false;
|
|
1065
|
+
// Normalize so undefined ("don't enforce") stays distinct from
|
|
1066
|
+
// null ("explicit default network"). Caller passes undefined to
|
|
1067
|
+
// opt out of the cross-tenant check; null to require the parent
|
|
1068
|
+
// also live in the default (null) network.
|
|
1069
|
+
const enforce = callerNetId !== undefined;
|
|
1070
|
+
const callerNorm = callerNetId ?? null;
|
|
1071
|
+
|
|
741
1072
|
while (currentChildId && depth < maxDepth) {
|
|
742
1073
|
depth++;
|
|
743
1074
|
type ChildRow = { parent_task_id: string | null; to_name: string; from_name: string; content: string };
|
|
@@ -746,12 +1077,24 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
|
|
|
746
1077
|
"SELECT parent_task_id, to_name, from_name, content FROM tasks WHERE task_id = ?1",
|
|
747
1078
|
currentChildId
|
|
748
1079
|
);
|
|
749
|
-
if (!child?.parent_task_id) return;
|
|
1080
|
+
if (!child?.parent_task_id) return { chained };
|
|
750
1081
|
const parent: ParentRow | null = db.get<ParentRow>(
|
|
751
1082
|
"SELECT task_id, from_name, to_name, status, result, network_id, parent_task_id FROM tasks WHERE task_id = ?1",
|
|
752
1083
|
child.parent_task_id
|
|
753
1084
|
);
|
|
754
|
-
if (!parent) return;
|
|
1085
|
+
if (!parent) return { chained };
|
|
1086
|
+
|
|
1087
|
+
// round5 F2 fix: refuse to write into a parent that lives in a
|
|
1088
|
+
// different network than the caller. Stops chain-reply being a
|
|
1089
|
+
// cross-tenant write primitive. Logs once per attempt so ops can
|
|
1090
|
+
// see the rejection in the access log.
|
|
1091
|
+
if (enforce) {
|
|
1092
|
+
const parentNet = parent.network_id ?? null;
|
|
1093
|
+
if (parentNet !== callerNorm) {
|
|
1094
|
+
console.log(`[commhub] 🚫 chainReplyToParent cross-network blocked: parent=${parent.task_id.slice(0, 8)} parent-net=${parentNet ?? "null"} caller-net=${callerNorm ?? "null"}`);
|
|
1095
|
+
return { chained, stoppedReason: "cross_network" };
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
755
1098
|
|
|
756
1099
|
const childAlias = child.to_name;
|
|
757
1100
|
const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
@@ -790,10 +1133,16 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
|
|
|
790
1133
|
} catch {}
|
|
791
1134
|
}
|
|
792
1135
|
|
|
1136
|
+
// This iteration actually wrote a parent row (and possibly an
|
|
1137
|
+
// inbox notification). Record that so the caller can gate its
|
|
1138
|
+
// SSE push on a real chain.
|
|
1139
|
+
chained = true;
|
|
1140
|
+
|
|
793
1141
|
// Recurse up the chain.
|
|
794
1142
|
currentChildId = parent.task_id;
|
|
795
1143
|
currentReply = newResult;
|
|
796
1144
|
}
|
|
1145
|
+
return { chained };
|
|
797
1146
|
}
|
|
798
1147
|
|
|
799
1148
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
} from "./uploads.js";
|
|
25
25
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
26
26
|
import { dirname as pathDirname } from "path";
|
|
27
|
+
import { startRetentionSweeper } from "./retention.js";
|
|
28
|
+
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
27
29
|
|
|
28
30
|
const PORT = Number(process.env.PORT) || 9200;
|
|
29
31
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -1066,11 +1068,9 @@ Bun.serve({
|
|
|
1066
1068
|
|
|
1067
1069
|
// ── REST: all sessions status ──
|
|
1068
1070
|
if (url.pathname === "/api/status") {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1073
|
-
db.run(staleSql, staleParams);
|
|
1071
|
+
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
1072
|
+
// (background timer, ~60s cadence). Read paths no longer fire UPDATE.
|
|
1073
|
+
// Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
|
|
1074
1074
|
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
1075
1075
|
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
1076
1076
|
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
@@ -1138,12 +1138,7 @@ Bun.serve({
|
|
|
1138
1138
|
|
|
1139
1139
|
// ── REST: aggregate agents by physical server ──
|
|
1140
1140
|
if (url.pathname === "/api/servers") {
|
|
1141
|
-
|
|
1142
|
-
const staleParams: any[] = [cutoff];
|
|
1143
|
-
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
1144
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1145
|
-
db.run(staleSql, staleParams);
|
|
1146
|
-
|
|
1141
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1147
1142
|
const params: any[] = [];
|
|
1148
1143
|
let sql = `
|
|
1149
1144
|
SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
|
|
@@ -1220,12 +1215,7 @@ Bun.serve({
|
|
|
1220
1215
|
const detailKind = serverDetailMatch[2];
|
|
1221
1216
|
if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
|
|
1222
1217
|
|
|
1223
|
-
|
|
1224
|
-
const staleParams: any[] = [cutoff];
|
|
1225
|
-
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
1226
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1227
|
-
db.run(staleSql, staleParams);
|
|
1228
|
-
|
|
1218
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1229
1219
|
if (detailKind === "agents") {
|
|
1230
1220
|
const params: any[] = [host, host];
|
|
1231
1221
|
let sql = `
|
|
@@ -1927,6 +1917,38 @@ Bun.serve({
|
|
|
1927
1917
|
}));
|
|
1928
1918
|
}
|
|
1929
1919
|
|
|
1920
|
+
// ── REST: single node config snapshot (RFC-024 B5) ──
|
|
1921
|
+
// Dashboard calls this for the snapshot path. Returns the masked
|
|
1922
|
+
// {model, flags, config_revision} the node last posted via
|
|
1923
|
+
// report_status (B6). NEVER reads per-node files. Network-scoped
|
|
1924
|
+
// via addNetworkScope so a netA viewer can't read a netB node.
|
|
1925
|
+
const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
|
|
1926
|
+
if (nodeConfigMatch && req.method === "GET") {
|
|
1927
|
+
const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
|
|
1928
|
+
const params: any[] = [nodeId];
|
|
1929
|
+
let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
|
|
1930
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1931
|
+
sql += " LIMIT 1";
|
|
1932
|
+
const node = db.get<any>(sql, ...params);
|
|
1933
|
+
if (!node) {
|
|
1934
|
+
return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
|
|
1935
|
+
}
|
|
1936
|
+
let snapshot: any = {};
|
|
1937
|
+
if (node.config_snapshot) {
|
|
1938
|
+
try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
|
|
1939
|
+
}
|
|
1940
|
+
return withCors(req, Response.json({
|
|
1941
|
+
ok: true,
|
|
1942
|
+
node_id: node.node_id,
|
|
1943
|
+
alias: node.alias,
|
|
1944
|
+
network_id: node.network_id,
|
|
1945
|
+
config_revision: node.config_revision || 0,
|
|
1946
|
+
model: snapshot.model ?? null,
|
|
1947
|
+
flags: snapshot.flags ?? {},
|
|
1948
|
+
config_update_capable: snapshot.config_update_capable ?? false,
|
|
1949
|
+
}));
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1930
1952
|
// ── REST: nodes table (V2 Sprint 2) ──
|
|
1931
1953
|
if (url.pathname === "/api/nodes") {
|
|
1932
1954
|
const nodeId = url.searchParams.get("node_id");
|
|
@@ -2105,9 +2127,31 @@ Security: ${SECURITY_LABEL}
|
|
|
2105
2127
|
},
|
|
2106
2128
|
});
|
|
2107
2129
|
|
|
2130
|
+
// Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
|
|
2131
|
+
// Sweeps every hour by default. Operators can disable any single table
|
|
2132
|
+
// by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
|
|
2133
|
+
// the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
|
|
2134
|
+
const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
|
|
2135
|
+
const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
|
|
2136
|
+
? sweepIntervalMinutes * 60 * 1000
|
|
2137
|
+
: 60 * 60 * 1000;
|
|
2138
|
+
const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
|
|
2139
|
+
|
|
2140
|
+
// Round-2/4 review ③ — stale session sweeper (coexists with the
|
|
2141
|
+
// retention sweeper above; different concerns + different cadences).
|
|
2142
|
+
// Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
|
|
2143
|
+
// /api/server-detail/*, MCP get_all_status). Each of those endpoints
|
|
2144
|
+
// used to run UPDATE on the sessions table just to maintain the
|
|
2145
|
+
// derived `offline` status; with the dashboard polling fast that was
|
|
2146
|
+
// 99% no-op write-amp under hot read paths. Now done globally once
|
|
2147
|
+
// every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
|
|
2148
|
+
const staleSweeperTimer = startStaleSessionSweeper();
|
|
2149
|
+
|
|
2108
2150
|
// ── Graceful shutdown ───────────────────────────────
|
|
2109
2151
|
function shutdown() {
|
|
2110
2152
|
console.log("[commhub] shutting down...");
|
|
2153
|
+
clearInterval(retentionSweeperTimer);
|
|
2154
|
+
clearInterval(staleSweeperTimer);
|
|
2111
2155
|
db.close();
|
|
2112
2156
|
process.exit(0);
|
|
2113
2157
|
}
|