@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent-department-http.test.ts +148 -0
  3. package/src/chain-attempt-dedup.test.ts +447 -0
  4. package/src/create-node-tool-schema.test.ts +1 -0
  5. package/src/create-node-validate.test.ts +6 -0
  6. package/src/create-node-validate.ts +7 -2
  7. package/src/db.ts +190 -21
  8. package/src/departments.ts +16 -0
  9. package/src/env-transport-gate-http.test.ts +146 -0
  10. package/src/env-transport-gate.ts +74 -0
  11. package/src/env-transport-policy.ts +21 -0
  12. package/src/host-supervisor-identity-http.test.ts +20 -4
  13. package/src/hub-catalog-read-http.test.ts +187 -0
  14. package/src/node-secret-rest-http.test.ts +190 -0
  15. package/src/observer-avatar-http.test.ts +13 -2
  16. package/src/report-status-resume-id-handover.test.ts +34 -0
  17. package/src/report-status-secret-keys.test.ts +140 -0
  18. package/src/requirements-http.test.ts +1 -1
  19. package/src/requirements-people.ts +8 -4
  20. package/src/rest-projections.ts +2 -0
  21. package/src/schedule-agent.ts +1 -1
  22. package/src/scheduled-backoff-http.test.ts +193 -0
  23. package/src/scheduled-backoff.ts +64 -0
  24. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  25. package/src/scheduled-outcome-guidance.ts +9 -0
  26. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  27. package/src/scheduled-requirement-wake.ts +125 -0
  28. package/src/scheduled-tasks.ts +78 -23
  29. package/src/scheduled-work-outcome-http.test.ts +154 -0
  30. package/src/scheduled-work-outcome.ts +50 -0
  31. package/src/scoped-secrets.ts +15 -0
  32. package/src/server.ts +162 -3
  33. package/src/tools.ts +99 -16
@@ -56,7 +56,7 @@ export type Runtime = typeof RUNTIMES[number];
56
56
  // FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
57
57
  export const FLAG_KEYS = [
58
58
  "permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout",
59
- "copresence", "opencodeGeneration", "opencodeUnsafeTools",
59
+ "copresence", "opencodeGeneration", "opencodeUnsafeTools", "opencodeNewServer",
60
60
  "approvalPolicy", "sandboxMode", "skipGitRepoCheck", "copresenceFullAccess", "modelReasoningEffort",
61
61
  "daemonProvider",
62
62
  ] as const;
@@ -151,6 +151,7 @@ export function validateFlagValue(k: string, v: unknown): void {
151
151
  if (v !== "v1" && v !== "v2") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be v1 or v2" });
152
152
  return;
153
153
  case "opencodeUnsafeTools":
154
+ case "opencodeNewServer":
154
155
  if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
155
156
  return;
156
157
  case "permissionMode":
@@ -198,7 +199,7 @@ export function validateFlagsForRuntime(runtime: string, flags: Record<string, u
198
199
  }
199
200
  // Strict flags envelope: old Hub/daemon reject these keys rather than dropping
200
201
  // unknown node_spec fields. Opt-in must be explicit on this create request.
201
- for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
202
+ for (const field of ["opencodeGeneration", "opencodeUnsafeTools", "opencodeNewServer"]) {
202
203
  if (!Object.prototype.hasOwnProperty.call(flags, field)) continue;
203
204
  validateFlagValue(field, flags[field]);
204
205
  if (runtime !== "opencode-cli") throw new ValidationError("flag_not_applicable_to_runtime", { field, runtime, applicable: ["opencode-cli"] });
@@ -209,6 +210,9 @@ export function validateFlagsForRuntime(runtime: string, flags: Record<string, u
209
210
  if (flags.opencodeGeneration === "v2" && flags.opencodeUnsafeTools !== true) {
210
211
  throw new ValidationError("opencode_v2_requires_unsafe_opt_in", { reason: "V2 preview enables every local tool; trusted tasks only. Explicit opencodeUnsafeTools=true required." });
211
212
  }
213
+ if (Object.prototype.hasOwnProperty.call(flags, "opencodeNewServer") && flags.opencodeGeneration !== "v2") {
214
+ throw new ValidationError("opencode_new_server_requires_v2", { reason: "opencodeNewServer requires explicit opencodeGeneration=v2" });
215
+ }
212
216
  if (Object.prototype.hasOwnProperty.call(flags, "copresence") && !(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
213
217
  throw new ValidationError("flag_not_applicable_to_runtime", {
214
218
  field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
@@ -408,6 +412,7 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
408
412
  // `anet node create --copresence` 是布尔开关,不吃值。
409
413
  if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
410
414
  if (k === "opencodeUnsafeTools") { if (v === true) args.push("--opencode-unsafe-tools"); continue; }
415
+ if (k === "opencodeNewServer") { if (v === true) args.push("--opencode-new-server"); continue; }
411
416
  if (CODEX_CONFIG_ONLY_FLAG_KEYS.has(k) || k === 'daemonProvider') continue;
412
417
  args.push(`--${kebab(k)}`, String(v));
413
418
  }
package/src/db.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { refreshScheduledBackoff } from "./scheduled-backoff.js";
1
2
  import { createAdapter, type DbAdapter } from "./db-adapter"; import { withStatementHook } from "./status-read-cache"; // #431 写 sessions 的语句让 /api/status 缓存失效
2
3
 
3
4
  export const db: DbAdapter = withStatementHook(createAdapter());
@@ -1710,6 +1711,21 @@ db.exec(`
1710
1711
  CREATE INDEX IF NOT EXISTS idx_scheduled_runs_network
1711
1712
  ON scheduled_task_runs(network_id, created_at DESC);
1712
1713
  `);
1714
+ // #1278 — opt-in state. Existing schedules remain disabled.
1715
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN no_change_backoff INTEGER NOT NULL DEFAULT 0"); } catch {}
1716
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN backoff_streak INTEGER NOT NULL DEFAULT 0"); } catch {}
1717
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN backoff_effective_seconds INTEGER"); } catch {}
1718
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN backoff_last_run_id TEXT"); } catch {}
1719
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN backoff_excluded INTEGER NOT NULL DEFAULT 0"); } catch {}
1720
+ // #1279 — opt-in requirement event policy/cursor; old plans stay disabled.
1721
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN requirement_wake_json TEXT"); } catch {}
1722
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN requirement_wake_cursor INTEGER NOT NULL DEFAULT 0"); } catch {}
1723
+ try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN requirement_wake_last_event_id INTEGER"); } catch {}
1724
+ // #1274 — explicit successful-work signal and dispatch revision, separate
1725
+ // from lifecycle. NULL is unknown for legacy runs; rollback ignores columns.
1726
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN work_outcome TEXT"); } catch {}
1727
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN schedule_revision INTEGER"); } catch {}
1728
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN work_outcome_node_id TEXT"); } catch {}
1713
1729
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
1714
1730
  // Skipped runs (overlap_policy=skip) name the open task that blocked them and
1715
1731
  // whether the node had ever picked it up: not_received | in_progress.
@@ -2027,6 +2043,72 @@ export function chainReplyToParent(
2027
2043
  maxDepth = 5,
2028
2044
  callerNetId?: string | null,
2029
2045
  ): ChainReplyResult {
2046
+ // Node-TMAI#4 (E/F): bind the ORIGIN of the result once, up front.
2047
+ //
2048
+ // The walk below climbs from the child towards the root, so the loop
2049
+ // variable is the CURRENT HOP's child and it changes on every iteration.
2050
+ // That is hop identity, not result identity. Conflating the two was the
2051
+ // defect this key used to carry: keying the de-dup index on the loop
2052
+ // variable let a descendant's upward notice burn an intermediate task's
2053
+ // key, so that task's own later completion looked like a "replay" and was
2054
+ // silently dropped (grandparent <- parent <- leaf, then the parent
2055
+ // finishes for real => 0 notices). Three notions are now kept apart:
2056
+ //
2057
+ // * originChildId / originAlias / originReply — the ONE task whose
2058
+ // result this call carries; fixed for the whole walk.
2059
+ // * originStamp — the DELIVERY of that result (its terminal
2060
+ // transition), kept only as a human-readable audit value.
2061
+ // * attemptKey — the ATTEMPT that produced the result: the inbox
2062
+ // transport row that queued it. See the block below for why the
2063
+ // delivery stamp alone is not usable as the key.
2064
+ // * hopKind — "completion" (the origin's own result reaching its
2065
+ // direct parent) vs "descendant" (a forwarded notice at a higher
2066
+ // ancestor). The two can never share a de-dup slot.
2067
+ const origin = db.get<{
2068
+ to_name: string;
2069
+ completed_at: string | null;
2070
+ delivered_at: string | null;
2071
+ started_at: string | null;
2072
+ }>(
2073
+ "SELECT to_name, completed_at, delivered_at, started_at FROM tasks WHERE task_id = ?1",
2074
+ childTaskId
2075
+ );
2076
+ const originChildId = childTaskId;
2077
+ const originAlias = origin?.to_name ?? childTaskId.slice(0, 8);
2078
+ const originStamp = (origin?.completed_at ?? origin?.delivered_at ?? origin?.started_at ?? "n/a")
2079
+ .replace(/[^0-9A-Za-z]/g, "-");
2080
+ const originReply = replyText;
2081
+
2082
+ // Node-TMAI#4 (F, round 3): the de-dup key must identify the ATTEMPT, and
2083
+ // no delivery timestamp can do that. `completed_at` / `delivered_at` are
2084
+ // written with `datetime('now')` — second granularity — so two legitimate
2085
+ // attempts of one task_id (the second one created by `retry_task`) can
2086
+ // complete inside the same second and carry an identical stamp. The later
2087
+ // result then looked like a replay of the earlier one and was swallowed.
2088
+ //
2089
+ // A retry or a reassign queues a fresh inbox transport row for the same
2090
+ // task_id, inside the same transaction as the task reset: `retry_task`
2091
+ // first runs `UPDATE tasks SET status='delivered', result=NULL,
2092
+ // completed_at=NULL, ...` and only then `INSERT INTO inbox` with a new id;
2093
+ // `reassign_task` acks the old row, updates the task, then inserts the new
2094
+ // one. db.ts #520 keeps transport-row identity separate from logical task
2095
+ // identity for precisely this reason ("runtime evidence/replies survive
2096
+ // those redeliveries"). This candidate therefore takes the newest
2097
+ // transport row of the task as its attempt identity: it changes on every
2098
+ // retry/reassign and survives a re-delivery of the same attempt, and it
2099
+ // needs no schema change.
2100
+ //
2101
+ // That is the identity source THIS CANDIDATE adopts; it is not a verified
2102
+ // account of every asynchronous late-reply or retention path, and those
2103
+ // are out of scope here. A row that was never dispatched (synthetic or
2104
+ // imported) has no transport row and falls back to its delivery stamp,
2105
+ // which preserves the old behaviour for those.
2106
+ const dispatch = db.get<{ transport_id: string | null }>(
2107
+ "SELECT id AS transport_id FROM inbox WHERE task_id = ?1 ORDER BY rowid DESC LIMIT 1",
2108
+ childTaskId
2109
+ );
2110
+ const attemptKey = dispatch?.transport_id ?? `undispatched-${originStamp}`;
2111
+
2030
2112
  let currentChildId: string | null = childTaskId;
2031
2113
  let currentReply = replyText;
2032
2114
  let depth = 0;
@@ -2040,6 +2122,9 @@ export function chainReplyToParent(
2040
2122
 
2041
2123
  while (currentChildId && depth < maxDepth) {
2042
2124
  depth++;
2125
+ // Narrow the loop cursor once. It is the walk position for this hop and
2126
+ // nothing else; the result's origin is pinned outside the loop above.
2127
+ const hopChildId: string = currentChildId;
2043
2128
  type ChildRow = { parent_task_id: string | null; to_name: string; from_name: string; content: string };
2044
2129
  type ParentRow = { task_id: string; from_name: string; to_name: string; status: string; result: string | null; network_id: string | null; parent_task_id: string | null };
2045
2130
  const child: ChildRow | null = db.get<ChildRow>(
@@ -2065,28 +2150,63 @@ export function chainReplyToParent(
2065
2150
  }
2066
2151
  }
2067
2152
 
2153
+ // The origin's own result reaching its direct parent is the "executor
2154
+ // completion" notice; anything higher up is a "descendant notice"
2155
+ // forwarded on the origin's behalf. They are different events and must
2156
+ // never occupy the same de-dup slot.
2157
+ const hopKind = depth === 1 ? "completion" : "descendant";
2068
2158
  const childAlias = child.to_name;
2069
2159
  const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
2070
2160
  const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
2071
2161
 
2162
+ // Node-TMAI#4 (E/F): one ATTEMPT of a child result may be recorded
2163
+ // against a parent at most once. The key binds the ORIGIN of the result
2164
+ // (not the loop variable), the attempt (its inbox transport row), the
2165
+ // parent and the hop kind. A replayed reply therefore cannot fan out
2166
+ // into a second audit row and a second notice, while a fresh attempt of
2167
+ // the same task (a legal retry) still gets through.
2168
+ const recordKey = `auto-chain:${hopKind}:${parent.task_id}:${originChildId}:${attemptKey}`;
2169
+ let recorded = false;
2170
+
2072
2171
  db.transaction(() => {
2073
- // Bump parent status to replied if still open. The task transition and
2074
- // its scheduler-run mirror share this transaction, so a crash cannot
2075
- // leave one terminal while the other remains delivered.
2172
+ const already = db.get<{ c: number }>(
2173
+ "SELECT COUNT(*) AS c FROM task_events WHERE task_id = ?1 AND event_key = ?2",
2174
+ [parent.task_id, recordKey]
2175
+ );
2176
+ if ((already?.c ?? 0) > 0) return;
2177
+
2178
+ // An OPEN parent is completed by the chain: this is the #690 contract —
2179
+ // a scheduled/dispatched parent whose executor answered through a child
2180
+ // must reach a terminal state, and its scheduler-run mirror follows it
2181
+ // in the same transaction.
2076
2182
  if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
2077
2183
  db.run(
2078
2184
  "UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
2079
2185
  [replyStatus, newResult.slice(0, 8000), parent.task_id]
2080
2186
  );
2081
2187
  syncScheduledRunForTask(parent.task_id, parent.network_id);
2082
- logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
2188
+ logTaskEvent(
2189
+ parent.task_id, parent.status, replyStatus, "auto-chain",
2190
+ `from ${childAlias}; origin ${originChildId.slice(0, 8)}, attempt=${attemptKey}`,
2191
+ recordKey,
2192
+ );
2083
2193
  } else {
2084
- db.run(
2085
- "UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
2086
- [newResult.slice(0, 8000), parent.task_id]
2194
+ // A TERMINAL parent is never rewritten. It used to have its `result`
2195
+ // appended and its `completed_at` moved forward long after it had
2196
+ // finished (observed: 22 minutes later), so an answer given to the
2197
+ // parent could silently become part of somebody else's result. The
2198
+ // child result is still recorded — as an audit row plus a notice to
2199
+ // the parent's author — but the parent row stays byte-identical.
2200
+ logTaskEvent(
2201
+ parent.task_id, parent.status, parent.status, "auto-chain-append",
2202
+ `from ${childAlias}; origin ${originChildId.slice(0, 8)}, attempt=${attemptKey}; terminal parent left untouched`,
2203
+ recordKey,
2087
2204
  );
2088
- logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
2089
2205
  }
2206
+ // The audit row for this (parent, child) link now exists. Only a call
2207
+ // that actually recorded one may unlock the caller's SSE push; a
2208
+ // deduped replay returns early above and leaves this false.
2209
+ recorded = true;
2090
2210
 
2091
2211
  if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
2092
2212
  try {
@@ -2098,20 +2218,22 @@ export function chainReplyToParent(
2098
2218
  db.run(
2099
2219
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
2100
2220
  VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
2101
- [notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
2221
+ [notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} ${hopKind === "descendant" ? "后代结果转呈" : "子任务完成"}]\n${originReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
2102
2222
  );
2103
2223
  } catch {}
2104
2224
  }
2105
2225
  });
2106
2226
 
2107
- // This iteration actually wrote a parent row (and possibly an
2108
- // inbox notification). Record that so the caller can gate its
2109
- // SSE push on a real chain.
2110
- chained = true;
2227
+ // An audit row (and possibly an inbox notification) was recorded for
2228
+ // this parent-child link. The caller gates its SSE push on this.
2229
+ if (recorded) chained = true;
2111
2230
 
2112
- // Recurse up the chain.
2113
- currentChildId = parent.task_id;
2231
+ // Recurse up the chain. Only the HOP cursor advances: the origin and
2232
+ // its delivery stay pinned, so no hop can absorb another's identity
2233
+ // and each notice carries the origin reply once instead of nesting
2234
+ // every ancestor's copy of it.
2114
2235
  currentReply = newResult;
2236
+ currentChildId = parent.task_id;
2115
2237
  }
2116
2238
  return { chained };
2117
2239
  }
@@ -2153,6 +2275,10 @@ type ScheduledTaskBinding = { run_id: string; schedule_id: string };
2153
2275
  * the history reflects the current retry rather than the first failed attempt.
2154
2276
  */
2155
2277
  export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
2278
+ return db.transaction(() => syncScheduledRunForTaskInTransaction(taskId, expectedNetworkId));
2279
+ }
2280
+
2281
+ function syncScheduledRunForTaskInTransaction(taskId: string, expectedNetworkId?: string | null): { matched: boolean; status?: string } {
2156
2282
  const task = db.get<ScheduledTaskLifecycleRow>(
2157
2283
  `SELECT task_id, network_id, status, completed_at
2158
2284
  FROM tasks
@@ -2181,18 +2307,23 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
2181
2307
  SET status = ?1,
2182
2308
  error_code = ?2,
2183
2309
  error_message = NULL,
2310
+ work_outcome = CASE WHEN ?1 = 'replied' THEN work_outcome ELSE NULL END,
2311
+ work_outcome_node_id = CASE WHEN ?1 = 'replied' THEN work_outcome_node_id ELSE NULL END,
2184
2312
  completed_at = COALESCE(?3, datetime('now'))
2185
2313
  WHERE run_id = ?4 AND task_id = ?5 AND network_id = ?6 AND schedule_id = ?7`,
2186
2314
  [task.status, errorCode, task.completed_at, binding.run_id, task.task_id, task.network_id, binding.schedule_id],
2187
2315
  );
2188
- if (updated.changes === 1) fireScheduledRunTerminalHook(binding.schedule_id, task.status);
2316
+ if (updated.changes === 1) {
2317
+ refreshScheduledBackoff(binding.schedule_id, task.network_id!);
2318
+ fireScheduledRunTerminalHook(binding.schedule_id, task.status);
2319
+ }
2189
2320
  return { matched: updated.changes === 1, status: task.status };
2190
2321
  }
2191
2322
 
2192
2323
  if (task.status === "delivered") {
2193
2324
  const updated = db.run(
2194
2325
  `UPDATE scheduled_task_runs
2195
- SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL
2326
+ SET status = 'delivered', error_code = NULL, error_message = NULL, completed_at = NULL, work_outcome = NULL, work_outcome_node_id = NULL, backoff_excluded = 1
2196
2327
  WHERE run_id = ?1 AND task_id = ?2 AND network_id = ?3 AND schedule_id = ?4`,
2197
2328
  [binding.run_id, task.task_id, task.network_id, binding.schedule_id],
2198
2329
  );
@@ -2215,12 +2346,12 @@ function taskEventTypeForStatus(toStatus: string): string {
2215
2346
  }
2216
2347
  }
2217
2348
 
2218
- export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
2349
+ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string, eventKey?: string) {
2219
2350
  try {
2220
2351
  db.run(
2221
- `INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
2222
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
2223
- [taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), actor, detail ?? null]
2352
+ `INSERT INTO task_events (task_id, from_status, to_status, event_type, event_key, actor, detail, network_id)
2353
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
2354
+ [taskId, fromStatus, toStatus, taskEventTypeForStatus(toStatus), eventKey ?? null, actor, detail ?? null]
2224
2355
  );
2225
2356
  } catch {}
2226
2357
  }
@@ -2250,3 +2381,41 @@ db.exec(`
2250
2381
  CREATE INDEX IF NOT EXISTS idx_ndb_daemon_network
2251
2382
  ON node_daemon_bindings(daemon_node_id,network_id,status);
2252
2383
  `);
2384
+
2385
+ // #1198 — OS account the node process runs as. Appended at the end of this file
2386
+ // on purpose: docs/test846 pin line anchors in db.ts, and an in-place insertion
2387
+ // would drift every anchor below it.
2388
+ try { db.exec("ALTER TABLE sessions ADD COLUMN os_user TEXT"); } catch {}
2389
+
2390
+ // #1474 — env key NAMES the node reports having (JSON array of strings, never
2391
+ // values). Appended after os_user for the same reason: doc/test anchors pin
2392
+ // line numbers in this file, so new migrations go at EOF, never in the middle.
2393
+ try { db.exec("ALTER TABLE sessions ADD COLUMN secret_keys TEXT"); } catch {}
2394
+
2395
+ // #1510 — how the node reaches the hub (loopback / https / plain), reported by
2396
+ // the node itself. Appended at EOF like the other post-1.0 columns so the doc
2397
+ // and test846 line anchors below it never drift.
2398
+ try { db.exec("ALTER TABLE sessions ADD COLUMN env_transport TEXT"); } catch {}
2399
+
2400
+ // #1510 — hourly count of secret writes that a transport gate WOULD have refused
2401
+ // (nodes reporting a plain-HTTP hub link). Log-only until an operator decides.
2402
+ db.exec(`
2403
+ CREATE TABLE IF NOT EXISTS env_transport_would_refuse (
2404
+ network_id TEXT NOT NULL,
2405
+ node_id TEXT NOT NULL,
2406
+ route TEXT NOT NULL,
2407
+ hour TEXT NOT NULL,
2408
+ hits INTEGER NOT NULL DEFAULT 0,
2409
+ sample TEXT,
2410
+ PRIMARY KEY (network_id, node_id, route, hour)
2411
+ );
2412
+ `);
2413
+
2414
+ // 组织架构:Agent(节点)**显式**归属部门。
2415
+ //
2416
+ // 在这条之前,「某个部门有几个 Agent」只能由「负责人(人)在哪个部门」推出来
2417
+ // (`/api/networks/:net/departments/:dept/nodes`)。所以没有负责人、或者负责人不在部门树里的
2418
+ // Agent 只能落进「未归属」——2026-10-19 现网就是 328 个 Agent 全在未归属里,
2419
+ // 而部门 0 个 Agent。这一列让 Agent 可以被直接放进部门,和人的归属互不覆盖:
2420
+ // 显式值优先,没有显式值时仍按负责人的部门推断。
2421
+ try { db.exec("ALTER TABLE nodes ADD COLUMN department_id TEXT"); } catch {}
@@ -248,6 +248,22 @@ export function setMemberDepartment(networkId: string, userId: string, departmen
248
248
  return { ok: true, user_id: userId, department_id: dept as string | null };
249
249
  }
250
250
 
251
+ /** Agent(节点)显式归属部门。`null` / "" = 清掉,回到「按负责人的部门推断」。 */
252
+ export function setAgentDepartment(
253
+ networkId: string,
254
+ nodeId: string,
255
+ departmentId: unknown,
256
+ ): Ok<{ node_id: string; department_id: string | null }> | Fail {
257
+ const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND node_id = ?2", networkId, nodeId);
258
+ if (!node) return fail(404, "node_not_found");
259
+ const dept = departmentId === null || departmentId === "" ? null : departmentId;
260
+ if (dept !== null && (typeof dept !== "string" || !one(networkId, dept))) return fail(400, "department_not_found");
261
+ db.run("UPDATE nodes SET department_id = ?3, updated_at = datetime('now') WHERE network_id = ?1 AND node_id = ?2", [networkId, nodeId, dept]);
262
+ // 部门群(RFC-042)按成员归属同步:Agent 不是群成员,这里只保证不把群带歪。
263
+ syncDepartmentGroups(networkId);
264
+ return { ok: true, node_id: nodeId, department_id: dept as string | null };
265
+ }
266
+
251
267
  /** 网络删掉时一起清(和项目授权同一时机调用)。 */
252
268
  export function deleteDepartmentsForNetwork(networkId: string): void {
253
269
  db.run("DELETE FROM network_departments WHERE network_id = ?1", [networkId]);
@@ -0,0 +1,146 @@
1
+ // #1510 — the secret-transport gate, shipped as log-only first.
2
+ //
3
+ // The load-bearing property is symmetric: with the gate in log mode a write to a
4
+ // `plain` node MUST still succeed (nobody's workflow breaks on upgrade) but MUST
5
+ // leave a hit behind, and with `enforce` the same write MUST be refused. A
6
+ // half-implemented gate that logs but also refuses would break every plain-HTTP
7
+ // node on the next deploy; one that refuses but never logs leaves nothing to
8
+ // decide with.
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-transport-gate-"));
15
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
16
+
17
+ let net = "", ownerId = "", ownerToken = "";
18
+ let server: ReturnType<typeof Bun.serve>;
19
+ let base = "";
20
+ const PLAIN = "n_gate_plain";
21
+ const TLS = "n_gate_tls";
22
+
23
+ const post = async (path: string, token: string, body: unknown) => {
24
+ const r = await fetch(`${base}${path}`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
27
+ body: JSON.stringify(body),
28
+ });
29
+ return { status: r.status, body: await r.json() as any };
30
+ };
31
+ const get = async (path: string, token: string) => {
32
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
33
+ return { status: r.status, body: await r.json() as any };
34
+ };
35
+
36
+ beforeAll(async () => {
37
+ const { db } = await import("./db.js");
38
+ const { register } = await import("./auth.js");
39
+ const stamp = Date.now();
40
+ const owner = register(`gate_owner_${stamp}`, "GateOwner-Strong-1!");
41
+ expect(owner.ok).toBe(true);
42
+ ownerToken = owner.token!;
43
+ net = owner.network_id!;
44
+ ownerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `gate_owner_${stamp}`)!.user_id;
45
+
46
+ const seed = (nodeId: string, transport: string) => db.run(
47
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, lifecycle_state, config_snapshot)
48
+ VALUES (?1, ?1, ?1, ?2, 'active', ?3)`,
49
+ [nodeId, net, JSON.stringify({ node_secrets_capable: true })],
50
+ );
51
+ seed(PLAIN, "plain");
52
+ seed(TLS, "https");
53
+ // The transport is reported on the session row (that is where report_status
54
+ // writes it).
55
+ const session = (nodeId: string, transport: string) => db.run(
56
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, env_transport, updated_at, last_seen_at)
57
+ VALUES (?1, ?1, 'idle', ?1, ?2, ?3, datetime('now'), datetime('now'))`,
58
+ [nodeId, net, transport],
59
+ );
60
+ session(PLAIN, "plain");
61
+ session(TLS, "https");
62
+
63
+ const mod = await import("./server.js");
64
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
65
+ base = `http://127.0.0.1:${server.port}`;
66
+ });
67
+
68
+ afterAll(() => {
69
+ try { server?.stop(true); } catch {}
70
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ // The lane allows one pending request per node; drain whatever the previous
75
+ // case left so the next one starts clean.
76
+ const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
77
+ for (const nodeId of [PLAIN, TLS]) {
78
+ const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
79
+ const pulled = pullNodeSecrets(daemon) as any;
80
+ for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
81
+ }
82
+ process.env.ANET_SECRET_TRANSPORT_GATE = "log";
83
+ });
84
+
85
+ describe("log mode (default)", () => {
86
+ test("a put to a plain node still succeeds AND leaves a hit", async () => {
87
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "A_KEY", value: "v", revision: 1 });
88
+ expect(r.status).toBe(200);
89
+ expect(r.body.ok).toBe(true); // not refused: that is the whole point of log mode
90
+
91
+ const log = await get(`/api/networks/${net}/env-transport-log`, ownerToken);
92
+ expect(log.status).toBe(200);
93
+ expect(log.body.total).toBe(1);
94
+ expect(log.body.rows[0]).toMatchObject({ node_id: PLAIN, route: "node_secret_put", hits: 1 });
95
+ });
96
+
97
+ test("a put to a TLS node leaves no hit", async () => {
98
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
99
+ const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "B_KEY", value: "v", revision: 1 });
100
+ expect(r.status).toBe(200);
101
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
102
+ expect(after).toBe(before);
103
+ });
104
+
105
+ test("list is not observed (it carries no secret)", async () => {
106
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
107
+ await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
108
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
109
+ expect(after).toBe(before);
110
+ });
111
+ });
112
+
113
+ describe("enforce mode", () => {
114
+ test("the same put is refused, and nothing is queued", async () => {
115
+ process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
116
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "C_KEY", value: "v", revision: 1 });
117
+ expect(r.status).toBe(400);
118
+ expect(r.body.error).toBe("secret_transport_plain");
119
+ });
120
+
121
+ test("a TLS node is unaffected", async () => {
122
+ process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
123
+ const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
124
+ expect(r.status).toBe(200);
125
+ });
126
+ });
127
+
128
+ describe("off mode", () => {
129
+ test("records nothing at all", async () => {
130
+ process.env.ANET_SECRET_TRANSPORT_GATE = "off";
131
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
132
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "E_KEY", value: "v", revision: 1 });
133
+ expect(r.status).toBe(200);
134
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
135
+ expect(after).toBe(before);
136
+ });
137
+ });
138
+
139
+ describe("the log endpoint is owner/admin only", () => {
140
+ test("an unrelated user gets 403", async () => {
141
+ const { register } = await import("./auth.js");
142
+ const other = register(`gate_other_${Date.now()}`, "GateOther-Strong-1!");
143
+ const r = await get(`/api/networks/${net}/env-transport-log`, other.token!);
144
+ expect(r.status).toBe(403);
145
+ });
146
+ });
@@ -0,0 +1,74 @@
1
+ // #1510 (log-only) — record the secret writes that a future transport gate would
2
+ // have refused, without refusing any of them.
3
+ //
4
+ // Why log-only first: refusing makes an existing workflow fail on upgrade, and
5
+ // the nodes that talk plain HTTP today are exactly the ones we cannot see from
6
+ // the Hub. This table answers "how many writes would have been blocked, on which
7
+ // node" *before* anyone decides to turn the gate on — the same rollout shape as
8
+ // #519's stale-open sweep and #525's human_only log mode.
9
+ //
10
+ // Hourly aggregation, like node_permission_log: a chatty node must not turn this
11
+ // into one row per write.
12
+ import { db } from "./db.js";
13
+ import { getEnvTransportMode } from "./env-transport-policy.js";
14
+
15
+ const HOURS_RETAINED = 30 * 24;
16
+ const MAX_ROWS = 20_000;
17
+
18
+ function hourOf(now = new Date()): string {
19
+ return now.toISOString().slice(0, 13);
20
+ }
21
+
22
+ /** Record one would-refuse hit. Never throws: logging must not break a write. */
23
+ export function noteEnvTransportWouldRefuse(networkId: string, nodeId: string, route: string, sample?: string): void {
24
+ try {
25
+ const now = new Date();
26
+ if (Math.random() < 0.02) {
27
+ db.run("DELETE FROM env_transport_would_refuse WHERE hour < ?1",
28
+ [hourOf(new Date(now.getTime() - HOURS_RETAINED * 86_400_000))]);
29
+ }
30
+ const count = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM env_transport_would_refuse")?.n ?? 0);
31
+ const hour = hourOf(now);
32
+ const s = sample ? sample.slice(0, 120) : null;
33
+ if (count >= MAX_ROWS) {
34
+ db.run(
35
+ "UPDATE env_transport_would_refuse SET hits = hits + 1, sample = COALESCE(sample, ?5) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND hour = ?4",
36
+ [networkId, nodeId, route, hour, s],
37
+ );
38
+ return;
39
+ }
40
+ db.run(
41
+ `INSERT INTO env_transport_would_refuse (network_id, node_id, route, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, 1, ?5)
42
+ ON CONFLICT(network_id, node_id, route, hour) DO UPDATE SET hits = env_transport_would_refuse.hits + 1`,
43
+ [networkId, nodeId, route, hour, s],
44
+ );
45
+ } catch (e) {
46
+ console.error(`[env-transport] would-refuse log failed: ${(e as Error).message}`);
47
+ }
48
+ }
49
+
50
+ /** Where a secret write is aimed: the node's own row, or a daemon's. Returns the
51
+ * transport the node last reported, or null when it is unknown. */
52
+ export function transportOf(networkId: string, nodeId: string): string | null {
53
+ const row = db.get<{ env_transport: string | null }>(
54
+ "SELECT env_transport FROM sessions WHERE network_id = ?1 AND (node_id = ?2 OR alias = ?2) ORDER BY updated_at DESC LIMIT 1",
55
+ [networkId, nodeId],
56
+ );
57
+ return row?.env_transport ?? null;
58
+ }
59
+
60
+ /** Called from the write lanes. Logs when the current policy would refuse. */
61
+ export function observeSecretWrite(networkId: string, nodeId: string, route: string): void {
62
+ if (getEnvTransportMode() !== "log") return;
63
+ const transport = transportOf(networkId, nodeId);
64
+ if (transport !== "plain") return;
65
+ noteEnvTransportWouldRefuse(networkId, nodeId, route, `transport=${transport}`);
66
+ }
67
+
68
+ export function readEnvTransportLog(networkId: string, sinceHour: string) {
69
+ return db.all<{ node_id: string; route: string; hour: string; hits: number | string; sample: string | null }>(
70
+ `SELECT node_id, route, hour, hits, sample FROM env_transport_would_refuse
71
+ WHERE network_id = ?1 AND hour >= ?2 ORDER BY hour DESC, node_id, route`,
72
+ [networkId, sinceHour],
73
+ ).map(r => ({ ...r, hits: Number(r.hits) }));
74
+ }
@@ -0,0 +1,21 @@
1
+ // #1510 — the switch for the secret-transport gate.
2
+ //
3
+ // (unset) / "log" — record what a gate WOULD refuse; refuse nothing.
4
+ // "enforce" — refuse secret writes to nodes that report `plain`.
5
+ // "off" — do nothing at all (no recording either).
6
+ //
7
+ // Reading the env on every call (like the stale-open sweep) means an operator can
8
+ // change the mode without a code change and tests can flip it per case.
9
+ export type EnvTransportMode = "off" | "log" | "enforce";
10
+
11
+ export function getEnvTransportMode(env: Record<string, string | undefined> = process.env): EnvTransportMode {
12
+ const raw = (env.ANET_SECRET_TRANSPORT_GATE ?? "").trim().toLowerCase();
13
+ if (raw === "off") return "off";
14
+ if (raw === "enforce") return "enforce";
15
+ return "log";
16
+ }
17
+
18
+ /** True when a write to a node reporting `plain` must be refused. */
19
+ export function refusesPlainTransport(transport: string | null, env: Record<string, string | undefined> = process.env): boolean {
20
+ return getEnvTransportMode(env) === "enforce" && transport === "plain";
21
+ }
@@ -100,10 +100,26 @@ for (const entry of ["MCP", "REST"] as const) {
100
100
  test(`${entry}: null-network legacy heartbeat is accepted only for globally unique aliases`, async () => {
101
101
  const unique = node("globalunique"), shared = node("globalshared"); node("globalshared", foreign);
102
102
  credential(unique); credential(shared);
103
- heartbeat(unique, { bound: null, network: null }); heartbeat(shared, { bound: null, network: null });
104
- const rows = await list(entry);
105
- expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
106
- expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
103
+ // Fresh sessions is NOT NULL. Exercise the historical nullable shape in
104
+ // an actual temporary SQL table, without changing the production schema
105
+ // or mocking returned rows. Only the heartbeat read uses this table;
106
+ // the production node/token/scoped SQL and both HTTP/MCP paths stay real.
107
+ db.exec("CREATE TEMP TABLE lhs1109_legacy_sessions AS SELECT * FROM sessions WHERE 0=1");
108
+ for (const id of [unique, shared]) {
109
+ const n = db.get<any>("SELECT alias FROM nodes WHERE node_id=?1", id)!;
110
+ db.run(`INSERT INTO lhs1109_legacy_sessions(resume_id,alias,network_id,node_id,last_seen_at,updated_at,status)
111
+ VALUES(?1,?2,NULL,NULL,?3,?3,'idle')`, [`s_lhs1109_${++sequence}`, n.alias, new Date().toISOString()]);
112
+ }
113
+ const original = db.all.bind(db);
114
+ db.all = ((sql: string, ...params: any[]) => original(sql.replace("FROM sessions s", "FROM lhs1109_legacy_sessions s"), ...params)) as any;
115
+ try {
116
+ const rows = await list(entry);
117
+ expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
118
+ expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
119
+ } finally {
120
+ db.all = original;
121
+ db.exec("DROP TABLE lhs1109_legacy_sessions");
122
+ }
107
123
  });
108
124
  test(`${entry}: bound heartbeat ignores stale alias but refuses wrong network and stale timestamps`, async () => {
109
125
  const renamed = node("newalias"), wrong = node("wrongnet"), stale = node("stale");