@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
|
@@ -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
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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(
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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${
|
|
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
|
-
//
|
|
2108
|
-
//
|
|
2109
|
-
|
|
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
|
-
|
|
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)
|
|
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 {}
|
package/src/departments.ts
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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");
|