@sema-agent/server 7.98.0 → 7.100.0
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/MIGRATION.md +19 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +86 -60
- package/deploy/sema-up/lib/envf.sh +110 -42
- package/deploy/sema-up/sema-up.sh +76 -34
- package/dist/approval-ask-machine.d.ts +10 -0
- package/dist/approval-ask-machine.js +3 -0
- package/dist/approval-card.d.ts +74 -1
- package/dist/approval-card.js +41 -8
- package/dist/approval.d.ts +44 -20
- package/dist/approval.js +14 -6
- package/dist/boot/config-center.d.ts +16 -8
- package/dist/boot/config-center.js +206 -103
- package/dist/boot/execution-env.d.ts +1 -2
- package/dist/boot/execution-env.js +2 -2
- package/dist/boot/leader.js +12 -9
- package/dist/boot/memory-consolidation.d.ts +38 -0
- package/dist/boot/memory-consolidation.js +35 -0
- package/dist/boot/org-memory.d.ts +2 -1
- package/dist/boot/org-memory.js +9 -5
- package/dist/boot/resolve-spec.d.ts +0 -1
- package/dist/boot/resolve-spec.js +4 -3
- package/dist/boot/runner-deps.d.ts +2 -2
- package/dist/boot/runtime-caps.js +4 -4
- package/dist/boot/stage-05-execution-env.d.ts +1 -1
- package/dist/boot/stage-06-runners.d.ts +2 -2
- package/dist/boot/stage-06-runners.js +3 -3
- package/dist/boot/stage-07-capability-layer.d.ts +2 -3
- package/dist/boot/stage-07-capability-layer.js +6 -6
- package/dist/boot/stage-08-reapers.d.ts +3 -4
- package/dist/boot/stage-08-reapers.js +2 -2
- package/dist/boot/stage-09-leader.d.ts +2 -3
- package/dist/boot/stage-10-http-server.d.ts +2 -3
- package/dist/boot/stage-10-http-server.js +2 -2
- package/dist/capabilities/center-plugins.d.ts +14 -2
- package/dist/capabilities/center-plugins.js +13 -5
- package/dist/capabilities/center-prompts.d.ts +3 -0
- package/dist/capabilities/center-prompts.js +1 -1
- package/dist/capabilities/hands-lane.js +2 -1
- package/dist/capabilities/scenarios.js +18 -5
- package/dist/capabilities/tool-defer.d.ts +2 -2
- package/dist/center-credential.d.ts +112 -0
- package/dist/center-credential.js +131 -0
- package/dist/config-catalog.d.ts +2 -2
- package/dist/config-catalog.js +15 -15
- package/dist/config-center/apply-effective.d.ts +34 -39
- package/dist/config-center/apply-effective.js +100 -64
- package/dist/config-center/apply-ledger.d.ts +35 -16
- package/dist/config-center/apply-ledger.js +25 -18
- package/dist/config-center/center-request.d.ts +60 -0
- package/dist/config-center/center-request.js +71 -0
- package/dist/config-center/facade.d.ts +11 -5
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/hot-keys-registry.d.ts +11 -4
- package/dist/config-center/hot-keys-registry.js +11 -7
- package/dist/config-center/http-client.d.ts +17 -9
- package/dist/config-center/http-client.js +27 -36
- package/dist/config-center/restart-signal.d.ts +21 -14
- package/dist/config-center/restart-signal.js +16 -22
- package/dist/config-center/skills-mcp.d.ts +4 -1
- package/dist/config-center/skills-mcp.js +2 -2
- package/dist/config-center/types.d.ts +3 -3
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +0 -8
- package/dist/config-lkg.d.ts +47 -8
- package/dist/config-lkg.js +107 -20
- package/dist/config-provider.d.ts +8 -7
- package/dist/config-provider.js +5 -5
- package/dist/config-types.d.ts +30 -11
- package/dist/config.js +11 -13
- package/dist/cross-session-settings.js +1 -1
- package/dist/fleet-client.d.ts +8 -8
- package/dist/fleet-client.js +7 -7
- package/dist/fleet-lease.d.ts +11 -11
- package/dist/fleet-lease.js +17 -13
- package/dist/host-lsp-manager.d.ts +29 -0
- package/dist/host-lsp-manager.js +14 -0
- package/dist/hosted-posture.d.ts +49 -93
- package/dist/hosted-posture.js +1 -25
- package/dist/http/active-run-conflict.d.ts +2 -12
- package/dist/http/active-run-conflict.js +1 -6
- package/dist/http/admission.js +8 -0
- package/dist/http/resume-legs.d.ts +1 -1
- package/dist/http/resume-legs.js +14 -10
- package/dist/http/route-ctx.d.ts +2 -2
- package/dist/http/routes/approvals-assistant.d.ts +1 -1
- package/dist/http/routes/approvals-assistant.js +2 -2
- package/dist/http/routes/memory-compliance.js +5 -0
- package/dist/http/routes/memory-origin.js +5 -0
- package/dist/http/routes/sessions.js +6 -0
- package/dist/http/routes/tasks.js +7 -6
- package/dist/http/server.d.ts +6 -5
- package/dist/http/wire-types.d.ts +6 -3
- package/dist/leader/wire.d.ts +20 -12
- package/dist/leader/wire.js +7 -6
- package/dist/memory-layer-preflight.d.ts +46 -0
- package/dist/memory-layer-preflight.js +121 -0
- package/dist/memory-operator-faces.js +17 -1
- package/dist/observability/corrupt-read-seat.d.ts +21 -4
- package/dist/observability/corrupt-read-seat.js +31 -0
- package/dist/observability/fail-open.d.ts +8 -4
- package/dist/observability/fail-open.js +8 -4
- package/dist/observability/run-terminal.js +1 -0
- package/dist/observability/secret-env-scrub.d.ts +3 -26
- package/dist/observability/secret-env-scrub.js +3 -2
- package/dist/orchestration/workflow-completion-inbox.d.ts +31 -23
- package/dist/orchestration/workflow-completion-inbox.js +8 -3
- package/dist/plugins/approval-ask-store-file.d.ts +20 -2
- package/dist/plugins/approval-ask-store-file.js +21 -3
- package/dist/plugins/approval-ask-store-memory.d.ts +2 -0
- package/dist/plugins/approval-ask-store-memory.js +39 -13
- package/dist/plugins/approval-ask-store-sql.d.ts +24 -5
- package/dist/plugins/approval-ask-store-sql.js +27 -3
- package/dist/plugins/checkpoint-store-sql.d.ts +9 -3
- package/dist/plugins/checkpoint-store-sql.js +19 -2
- package/dist/plugins/local-checkpoint-store.js +2 -0
- package/dist/plugins/mailbox-store-sql.d.ts +7 -5
- package/dist/plugins/mailbox-store-sql.js +6 -4
- package/dist/plugins/permission-rule-store-file.js +3 -2
- package/dist/plugins/remote-env-host.d.ts +3 -1
- package/dist/plugins/remote-env-host.js +3 -2
- package/dist/plugins/sql-json-column.d.ts +16 -3
- package/dist/plugins/sql-json-column.js +15 -4
- package/dist/plugins/web-search.d.ts +42 -22
- package/dist/plugins/web-search.js +141 -49
- package/dist/project-memory.d.ts +28 -1
- package/dist/project-memory.js +22 -22
- package/dist/run-local.js +6 -7
- package/dist/runs.js +6 -5
- package/dist/runtime-caps-resolver.d.ts +8 -8
- package/dist/runtime-caps-resolver.js +16 -14
- package/dist/runtime-governance.d.ts +50 -3
- package/dist/runtime-governance.js +5 -0
- package/dist/sealed-key.js +3 -7
- package/dist/server-secret-env.d.ts +43 -0
- package/dist/server-secret-env.js +18 -0
- package/dist/task-settings.d.ts +1 -1
- package/dist/tool-approval.d.ts +144 -9
- package/dist/tool-approval.js +74 -18
- package/dist/trace/core-keyset-guard.d.ts +5 -5
- package/dist/trace/ledger-sink.js +2 -2
- package/dist/trace/project.d.ts +13 -0
- package/dist/trace/project.js +22 -5
- package/dist/trace/projection-drop.d.ts +2 -0
- package/dist/trace/projection-drop.js +6 -0
- package/dist/trace/sema-provenance.d.ts +4 -1
- package/dist/trace/sema-provenance.js +1 -1
- package/dist/trace/task-notification-facets.d.ts +65 -0
- package/dist/trace/task-notification-facets.js +31 -0
- package/dist/trace/wire-projection-faces.d.ts +3 -3
- package/dist/trace/wire-projection-faces.js +3 -3
- package/package.json +5 -4
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
4
4
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, ExecutionOutcomeConflictError, executionRecordDisposition, executionVerdict, } from "@sema-agent/core";
|
|
5
5
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
6
|
-
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, screenUncoveredDetailSeat } from "../approval-card.js";
|
|
6
|
+
import { MAX_RULE_OFFERS, MAX_RULE_OFFER_BATCH_MEMBERS, RuleOfferRawSchema, backfillLegacyBatchMemberKind, parkRowFacts, screenUncoveredDetailSeat } from "../approval-card.js";
|
|
7
7
|
import { assertJsonColumnWritable, createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonArrayColumn, readJsonObjectColumn, readNonNullJsonValueColumn, } from "./sql-json-column.js";
|
|
8
8
|
import { recordFailOpen } from "../observability/fail-open.js";
|
|
9
9
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
@@ -168,6 +168,21 @@ function readRiskDescriptorCell(cell) {
|
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
+
function readParkRowFactsCell(cell) {
|
|
172
|
+
let pendingAction;
|
|
173
|
+
try {
|
|
174
|
+
pendingAction = decodeNullableJsonColumn(cell, readPendingActionMemberBody, "checkpoint.checkpoint $.pendingAction (operator queue row)");
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
if (!isJsonColumnBodyProblem(e))
|
|
178
|
+
throw e;
|
|
179
|
+
recordFailOpen("server.approvals.park-row-blob-unreadable");
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
return parkRowFacts(pendingAction);
|
|
183
|
+
}
|
|
184
|
+
const PARK_ROW_PENDING_ACTION = { key: "pendingAction", as: "pending_action" };
|
|
185
|
+
const readPendingActionMemberBody = createObjectColumnReader();
|
|
171
186
|
export function boundedToolInput(args) {
|
|
172
187
|
if (args === undefined)
|
|
173
188
|
return null;
|
|
@@ -516,7 +531,8 @@ export class SqlCheckpointStore {
|
|
|
516
531
|
async listPending(scope) {
|
|
517
532
|
const d = this.db.dialect;
|
|
518
533
|
const base = `SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, ${jsonColumnText(d, "tool_input")}, c.bound_input_hash, ` +
|
|
519
|
-
`${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id ` +
|
|
534
|
+
`${jsonColumnText(d, "risk_descriptor")}, ${jsonColumnText(d, "rule_suggestions")}, c.has_bidi_controls, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id, ` +
|
|
535
|
+
`${jsonColumnText(d, "checkpoint", PARK_ROW_PENDING_ACTION)} ` +
|
|
520
536
|
"FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
|
|
521
537
|
const { rows } = scope
|
|
522
538
|
? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
|
|
@@ -543,6 +559,7 @@ export class SqlCheckpointStore {
|
|
|
543
559
|
...(ruleOffers !== undefined ? { ruleOffers } : {}),
|
|
544
560
|
...(Number(r.has_bidi_controls) === 1 ? { hasBidiControls: true } : {}),
|
|
545
561
|
...(contentKindOf(toolName) !== undefined ? { contentKind: "content_ask" } : {}),
|
|
562
|
+
...readParkRowFactsCell(r[PARK_ROW_PENDING_ACTION.as]),
|
|
546
563
|
};
|
|
547
564
|
});
|
|
548
565
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ASK_USER_QUESTION_TOOL_NAME, contentKindOf } from "../approval-content-kind.js";
|
|
3
|
+
import { parkRowFacts } from "../approval-card.js";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from "node:fs";
|
|
5
6
|
import { join } from "node:path";
|
|
@@ -184,6 +185,7 @@ export class LocalCheckpointStore {
|
|
|
184
185
|
})(),
|
|
185
186
|
...(pa?.hasBidiControls === true ? { hasBidiControls: true } : {}),
|
|
186
187
|
...(contentKindOf(pa?.toolName ?? null) !== undefined ? { contentKind: "content_ask" } : {}),
|
|
188
|
+
...parkRowFacts(cp.pendingAction),
|
|
187
189
|
});
|
|
188
190
|
}
|
|
189
191
|
return rows.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* 2026-07-25)起不再与 drop 同句**:reap 是收件人活着时的龄扫,**清箱保高水位**(删消息、清租约、盒行与
|
|
13
13
|
* next_seq 留着)——本文件 2026-09-03(车BF,codex r1 [high])才跟上;此前 reap 删盒行 ⇒ 下一条 append 从 1
|
|
14
14
|
* 重铸,陈旧 ack 可删掉一条没人见过的新消息。跨进程试剂盒明钉此条。
|
|
15
|
-
* 3. **ack
|
|
16
|
-
* 时顺带清 lease(全量 ack = 释放)
|
|
15
|
+
* 3. **ack 带 owner 围栏**(MB1,core 1.452 起;原「无守卫、信任 claim 赢者链」已被推翻):当前持租者才删
|
|
16
|
+
* `seq <= upToSeq` 的行;lease 的 `maxSeq <= upToSeq` 时顺带清 lease(全量 ack = 释放)。**围栏的判决报出来**
|
|
17
|
+
* (core 7.30.0 #1036,S-616):放行 ⇒ `{ acked: true }`(删 0 行也是 true —— 契约是「围栏放行」不是「删了几行」),
|
|
18
|
+
* 盒不在 / 无人持租 / 非持租者 ⇒ `{ acked: false }`;逐格与 core `InMemoryMailboxStore.ack` 同答。
|
|
17
19
|
* 4. **lease 过期判 = 严格 `expiresAt > now` 才挡**;`releaseLease` 只在 owner 匹配时清。
|
|
18
20
|
* 5. `peekCount` 含已 lease 消息(可见性 ≠ lease 态);空盒 claim = null(box 行在而消息 0 也 null)。
|
|
19
21
|
* 6. **reap = SQL 全量真扫**(core File 实现只扫已加载盒 = 进程内 backstop,[ref] 确认 SQL 是全量
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
*/
|
|
45
47
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
46
48
|
import type { Pool as PgPool } from "pg";
|
|
47
|
-
import type { MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
|
|
49
|
+
import type { MailboxAckResult, MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
|
|
48
50
|
import { type IndexSpec } from "./ensure-index.js";
|
|
49
51
|
import type { MailboxRecipientLifecycle, MailboxRecipientTombstone } from "./mailbox-recipient-lifecycle.js";
|
|
50
52
|
export declare const MAILBOX_TABLE = "mailbox";
|
|
@@ -82,7 +84,7 @@ export declare class TiDBMailboxStore implements MailboxStore, MailboxRecipientL
|
|
|
82
84
|
private tx;
|
|
83
85
|
append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
|
|
84
86
|
claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
|
|
85
|
-
ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<
|
|
87
|
+
ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
|
|
86
88
|
releaseLease(scope: string, handle: string, owner: string): Promise<void>;
|
|
87
89
|
peekCount(scope: string, handle: string): Promise<number>;
|
|
88
90
|
drop(scope: string, handle: string): Promise<void>;
|
|
@@ -108,7 +110,7 @@ export declare class PgMailboxStore implements MailboxStore, MailboxRecipientLif
|
|
|
108
110
|
private assertIdentity;
|
|
109
111
|
append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
|
|
110
112
|
claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
|
|
111
|
-
ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<
|
|
113
|
+
ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
|
|
112
114
|
releaseLease(scope: string, handle: string, owner: string): Promise<void>;
|
|
113
115
|
peekCount(scope: string, handle: string): Promise<number>;
|
|
114
116
|
drop(scope: string, handle: string): Promise<void>;
|
|
@@ -263,13 +263,14 @@ export class TiDBMailboxStore {
|
|
|
263
263
|
});
|
|
264
264
|
}
|
|
265
265
|
async ack(scope, handle, owner, upToSeq) {
|
|
266
|
-
|
|
266
|
+
return this.tx(async (c) => {
|
|
267
267
|
const [rows] = (await c.query(`SELECT lease_owner FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
|
|
268
268
|
if (rows.length === 0 || rows[0].lease_owner == null || bufToStr(rows[0].lease_owner) !== owner)
|
|
269
|
-
return;
|
|
269
|
+
return { acked: false };
|
|
270
270
|
await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? AND seq <= ?`, [scope, handle, upToSeq]);
|
|
271
271
|
await c.query(`UPDATE ${MAILBOX_TABLE} SET lease_owner = NULL, lease_expires_at_ms = NULL, lease_max_seq = NULL
|
|
272
272
|
WHERE scope_key = ? AND handle = ? AND lease_max_seq IS NOT NULL AND lease_max_seq <= ?`, [scope, handle, upToSeq]);
|
|
273
|
+
return { acked: true };
|
|
273
274
|
});
|
|
274
275
|
}
|
|
275
276
|
async releaseLease(scope, handle, owner) {
|
|
@@ -410,13 +411,14 @@ export class PgMailboxStore {
|
|
|
410
411
|
});
|
|
411
412
|
}
|
|
412
413
|
async ack(scope, handle, owner, upToSeq) {
|
|
413
|
-
|
|
414
|
+
return this.tx(async (c) => {
|
|
414
415
|
const r = (await c.query(`SELECT lease_owner FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]));
|
|
415
416
|
if (r.rows.length === 0 || r.rows[0].lease_owner == null || bufToStr(r.rows[0].lease_owner) !== owner)
|
|
416
|
-
return;
|
|
417
|
+
return { acked: false };
|
|
417
418
|
await c.query(`DELETE FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 AND seq <= $3`, [scope, handle, upToSeq]);
|
|
418
419
|
await c.query(`UPDATE ${MAILBOX_TABLE} SET lease_owner = NULL, lease_expires_at_ms = NULL, lease_max_seq = NULL
|
|
419
420
|
WHERE scope_key = $1 AND handle = $2 AND lease_max_seq IS NOT NULL AND lease_max_seq <= $3`, [scope, handle, upToSeq]);
|
|
421
|
+
return { acked: true };
|
|
420
422
|
});
|
|
421
423
|
}
|
|
422
424
|
async releaseLease(scope, handle, owner) {
|
|
@@ -5,6 +5,7 @@ import { performance } from "node:perf_hooks";
|
|
|
5
5
|
import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
|
|
8
|
+
import { createSkippedRecordSink } from "../observability/corrupt-read-seat.js";
|
|
8
9
|
import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
9
10
|
import { RULE_BEHAVIORS } from "@sema-agent/core";
|
|
10
11
|
const RuleScopeSchema = z.union([
|
|
@@ -140,7 +141,7 @@ export class FileRuleApprovalRecordStore {
|
|
|
140
141
|
ensureDir(dir);
|
|
141
142
|
const path = join(dir, "approvals.jsonl");
|
|
142
143
|
this.gate = new CorruptionGate("permission-rule approval log", path, onError);
|
|
143
|
-
for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
|
|
144
|
+
for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
|
|
144
145
|
const parsed = ApprovalLineSchema.safeParse(raw);
|
|
145
146
|
if (!parsed.success) {
|
|
146
147
|
const stale = StaleApprovalLineSchema.safeParse(raw);
|
|
@@ -209,7 +210,7 @@ export class FileRuleImportTicketStore {
|
|
|
209
210
|
ensureDir(dir);
|
|
210
211
|
const path = join(dir, "import-tickets.jsonl");
|
|
211
212
|
this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
|
|
212
|
-
for (const raw of readJsonlRecords(path, (info) => this.gate.trip(info.reason))) {
|
|
213
|
+
for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
|
|
213
214
|
const parsed = TicketLineSchema.safeParse(raw);
|
|
214
215
|
if (!parsed.success) {
|
|
215
216
|
this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
|
|
@@ -33,6 +33,8 @@ export interface HostEnvConfig {
|
|
|
33
33
|
commandTimeoutMs?: number;
|
|
34
34
|
/** Shell-child env policy (D1; mirrors core's `NodeExecutionEnv`). Default `"scrub"`: inherit the worker's
|
|
35
35
|
* `process.env` but DROP secret-pattern keys (core's `scrubSecretEnv` — `*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`…)
|
|
36
|
+
* AND every env name the server's config catalog types as `secret` (`server-secret-env.ts`; 7.99.0 F7 —
|
|
37
|
+
* `SERVICE_AUTH_TOKENS` / `APPROVAL_HMAC_KEYS` slip the shape rule)
|
|
36
38
|
* so a model-driven shell can't `printenv DEEPSEEK_API_KEY` and exfiltrate orchestrator secrets — keeps PATH/HOME/
|
|
37
39
|
* locale/build (low breakage). `"all"` = inherit everything (explicit opt-out, trusted local dev). A `string[]` =
|
|
38
40
|
* strict allowlist. `env`/per-command `options.env` ALWAYS pass through verbatim. */
|
|
@@ -386,7 +388,7 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
386
388
|
private fsReady;
|
|
387
389
|
/** Base env (process.env when inheriting) merged with the adapter's secret env, then per-command overrides win. */
|
|
388
390
|
/** D1 secure default = `"scrub"`: inherit `process.env` minus secret-pattern keys (core's `scrubSecretEnv`, single
|
|
389
|
-
* source of
|
|
391
|
+
* source of the shape rule) minus the config catalog's `secret`-typed names (`scrubServerSecrets`, 7.99.0 F7). `"all"` = full inherit (opt-out); a `string[]` = strict allowlist. Legacy `inheritProcessEnv`
|
|
390
392
|
* maps `true`→`"all"` / `false`→`[]`. */
|
|
391
393
|
private inheritMode;
|
|
392
394
|
private mergeEnv;
|
|
@@ -9,8 +9,9 @@ import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-
|
|
|
9
9
|
import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
|
|
10
10
|
import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
|
|
11
11
|
import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
|
|
12
|
-
import { FileError, ExecutionError, RemoteExecutionError,
|
|
12
|
+
import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
|
|
13
13
|
import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
|
|
14
|
+
import { scrubServerSecrets } from "../server-secret-env.js";
|
|
14
15
|
import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
|
|
15
16
|
const PROVIDER = "host";
|
|
16
17
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
@@ -1312,7 +1313,7 @@ export class RemoteHostExecutionEnv {
|
|
|
1312
1313
|
}
|
|
1313
1314
|
else {
|
|
1314
1315
|
const findings = [];
|
|
1315
|
-
base =
|
|
1316
|
+
base = scrubServerSecrets(process.env, findings);
|
|
1316
1317
|
recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings);
|
|
1317
1318
|
}
|
|
1318
1319
|
if (this.cfg.env)
|
|
@@ -46,11 +46,24 @@ import type { SqlDialect } from "./sql-driver.js";
|
|
|
46
46
|
*
|
|
47
47
|
* 🔴 **别名是产物的一部分,不是调用点的自由**:MySQL 协议下 `CAST(rules AS CHAR)` 的回报字段名是
|
|
48
48
|
* `CAST(rules AS CHAR)` 而不是 `rules` —— 把别名留给调用点写,就等于给每一处读腿留一个「忘了写别名,
|
|
49
|
-
* 行上于是没有这个键、读回恒 `undefined
|
|
49
|
+
* 行上于是没有这个键、读回恒 `undefined`」的静默洞。别名在这里一次钉死,行的键恒等于列名(成员形
|
|
50
|
+
* {@link JsonColumnMember}:恒等于声明的小写别名)。
|
|
50
51
|
*
|
|
51
52
|
* 🔴 **`CAST` 不影响索引**:投影不在 `WHERE` 里;大体量列多一次文本拷贝,量级与现状同(驱动本来也解一次)。
|
|
52
53
|
*/
|
|
53
|
-
export declare function jsonColumnText(dialect: SqlDialect, column: string): string;
|
|
54
|
+
export declare function jsonColumnText(dialect: SqlDialect, column: string, member?: JsonColumnMember): string;
|
|
55
|
+
/**
|
|
56
|
+
* 一列 JSON 的**一个顶层成员**(NP-3,7.100.0)—— 读腿只要文档里的一棵子树时用它,整只文档不出引擎。
|
|
57
|
+
* · `key` = 成员名(裸标识符;进 MySQL 路径 `'$.<key>'` 与 PG 字面 `'<key>'`,所以只认裸标识符,不转义不猜);
|
|
58
|
+
* · `as` = 行上的键(**小写**裸标识符:PG 把不带引号的别名折成小写,别的拼法会让两方言的行键不同)。
|
|
59
|
+
* 产物仍是**文本**(同一条「按文本投影、恰好解一次」),成员缺席 / 文档不是对象 ⇒ SQL NULL(两引擎同答),
|
|
60
|
+
* 所以读腿用 {@link decodeNullableJsonColumn}:NULL = 「这份文档没有这棵子树」,JSON `null` 字面 = 一个值(交形读者判)。
|
|
61
|
+
* 首个读者:`checkpoint-store-sql.ts` 的 `listPending`(运维队列 / `/v1/approvals/stream` 热读)只取 `pendingAction`。
|
|
62
|
+
*/
|
|
63
|
+
export interface JsonColumnMember {
|
|
64
|
+
readonly key: string;
|
|
65
|
+
readonly as: string;
|
|
66
|
+
}
|
|
54
67
|
/**
|
|
55
68
|
* 同一只投影的**裸表达式**(不带别名)—— 给 `WHERE` / `UPDATE … WHERE` 这种**没有别名位**的地方用。
|
|
56
69
|
*
|
|
@@ -68,7 +81,7 @@ export declare function jsonColumnText(dialect: SqlDialect, column: string): str
|
|
|
68
81
|
* 子树,本函数的插值对它是一个不透明表达式。所以「误用进 SELECT 投影且没给别名」这一形没有机器门 ——
|
|
69
82
|
* 误用的落点是真库上当场的 `SqlJsonColumnError("not-text")` 响亮抛(行上没有那个键 ⇒ `undefined`),不是静默。
|
|
70
83
|
*/
|
|
71
|
-
export declare function jsonColumnTextExpr(dialect: SqlDialect, column: string): string;
|
|
84
|
+
export declare function jsonColumnTextExpr(dialect: SqlDialect, column: string, memberKey?: string): string;
|
|
72
85
|
/**
|
|
73
86
|
* 一次读回失败的**种别**(店层码,**闭集**;运行期词表在这里,类型从它推导 —— 一张词表只有一个写者):
|
|
74
87
|
* · `not-text` —— 拿到的不是串 = **读腿漏了投影**,那是**码的问题不是数据的问题** ⇒ 响亮的程序错,
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
const COLUMN_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
-
export function jsonColumnText(dialect, column) {
|
|
4
|
-
|
|
3
|
+
export function jsonColumnText(dialect, column, member) {
|
|
4
|
+
if (member === undefined)
|
|
5
|
+
return `${jsonColumnTextExpr(dialect, column)} AS ${column}`;
|
|
6
|
+
if (!MEMBER_ALIAS_RE.test(member.as)) {
|
|
7
|
+
throw new Error(`jsonColumnText: member alias ${JSON.stringify(member.as)} must be a lower-case bare identifier — PostgreSQL folds unquoted aliases to lower case, so any other spelling would give the two dialects different row keys`);
|
|
8
|
+
}
|
|
9
|
+
return `${jsonColumnTextExpr(dialect, column, member.key)} AS ${member.as}`;
|
|
5
10
|
}
|
|
6
|
-
|
|
11
|
+
const MEMBER_ALIAS_RE = /^[a-z_][a-z0-9_]*$/;
|
|
12
|
+
export function jsonColumnTextExpr(dialect, column, memberKey) {
|
|
7
13
|
if (!COLUMN_NAME_RE.test(column)) {
|
|
8
14
|
throw new Error(`jsonColumnTextExpr: ${JSON.stringify(column)} is not a bare column identifier — refusing to splice it into SQL text`);
|
|
9
15
|
}
|
|
10
|
-
|
|
16
|
+
if (memberKey === undefined)
|
|
17
|
+
return dialect === "pg" ? `${column}::text` : `CAST(${column} AS CHAR)`;
|
|
18
|
+
if (!COLUMN_NAME_RE.test(memberKey)) {
|
|
19
|
+
throw new Error(`jsonColumnTextExpr: member ${JSON.stringify(memberKey)} is not a bare identifier — refusing to splice it into a JSON path`);
|
|
20
|
+
}
|
|
21
|
+
return dialect === "pg" ? `(${column}::jsonb -> '${memberKey}')::text` : `CAST(JSON_EXTRACT(${column}, '$.${memberKey}') AS CHAR)`;
|
|
11
22
|
}
|
|
12
23
|
export const SQL_JSON_COLUMN_PROBLEMS = ["not-text", "not-json", "malformed"];
|
|
13
24
|
export class SqlJsonColumnError extends Error {
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* backend here is what makes `assembleFullBodyTools({ webSearch })` actually mount the WebSearch tool.
|
|
5
5
|
*
|
|
6
6
|
* Multi-provider, picked by `WEB_SEARCH_PROVIDER`:
|
|
7
|
-
* - `brave` — Brave Web Search API (GET, `X-Subscription-Token` header)
|
|
7
|
+
* - `brave` — Brave Web Search API (GET, `X-Subscription-Token` header); with `WEB_SEARCH_ENDPOINT` set to a
|
|
8
|
+
* Brave-compatible gateway's full search URL (e.g. a self-hosted Nólë gateway, S-669) the same leg
|
|
9
|
+
* serves that backend — no new provider word, `capabilities.webSearch.backend` still reads `brave`
|
|
8
10
|
* - `tavily` — Tavily Search API (POST, `Authorization: Bearer`, native include/exclude_domains)
|
|
9
11
|
* - `searxng` — a self-hosted SearXNG instance (GET `?format=json`, no auth)
|
|
10
12
|
*
|
|
@@ -140,20 +142,6 @@ export declare function setWebSearchBadPayloadObserver(fn: ((provider: string) =
|
|
|
140
142
|
* stays captured in this closure — it is never surfaced to the model or the tool args.
|
|
141
143
|
*/
|
|
142
144
|
export declare function createWebSearchBackend(cfg: WebSearchBackendConfig): WebSearchBackend;
|
|
143
|
-
/**
|
|
144
|
-
* `WEB_SEARCH_SEARXNG_PARAMS` / `settings.webSearch.searxngParams` 的解析 —— SearXNG 实例侧参数
|
|
145
|
-
* (`engines=` / `language=` 等)。
|
|
146
|
-
*
|
|
147
|
-
* 🔴 [ref] cli 逐串直证:3.18.0 我加了 `searxngParams` 字段和消费点,**却没开任何配置入口** ——
|
|
148
|
-
* 两条门都没有位置填它,于是我在 [ref] 说的「这条是给你们的」是句空话。单测直接构造
|
|
149
|
-
* `WebSearchBackendConfig` 传进去,**天然跳过了配置入口这一层**,于是「能力在」与「配得进」之间
|
|
150
|
-
* 断开,而两边的绿都是真的。判据:**按消费端实际能填的那个入口验,不是按自己构造的对象验。**
|
|
151
|
-
*
|
|
152
|
-
* 形状:`k=v` 用 `;` 分隔(`engines=bing,duckduckgo;language=zh-CN`)——值里本来就常含逗号,
|
|
153
|
-
* 所以分隔符取 `;` 而不是 `,`。整串解析不出任何一对 ⇒ **返回 undefined,键整个不铸**
|
|
154
|
-
* (不产出空对象:空对象会让下游以为「配了但是空的」,与本仓「缺席就要缺席得干净」同源)。
|
|
155
|
-
*/
|
|
156
|
-
export declare function parseSearxngParams(raw: unknown): Record<string, string> | undefined;
|
|
157
145
|
/**
|
|
158
146
|
* #81④ 探活门控 —— **默认 OFF**,显式开才探。
|
|
159
147
|
*
|
|
@@ -165,14 +153,46 @@ export declare function parseSearxngParams(raw: unknown): Record<string, string>
|
|
|
165
153
|
* 而猜错的方向是「凭空出网」。缺席要缺席得干净,含糊也一样。
|
|
166
154
|
*/
|
|
167
155
|
export declare function shouldProbeWebSearchOnBoot(env?: NodeJS.ProcessEnv): boolean;
|
|
168
|
-
/**
|
|
156
|
+
/** env 载体形错的机读码(拼法随 `config.<族>_invalid` 既有族,如 `config.model_provider_invalid`)。码按**规则**
|
|
157
|
+
* 命名、不按键名 —— 键名逐字在拒因里。 */
|
|
158
|
+
export declare const WEB_SEARCH_CONFIG_INVALID_CODE = "config.web_search_invalid";
|
|
159
|
+
/**
|
|
160
|
+
* `WEB_SEARCH_PROVIDER` 这一格的判官(provider 字段的 env 载体):缺席 / 词表外 ⇒ `undefined`(= 不装配,缺席语义)。
|
|
161
|
+
*
|
|
162
|
+
* 🔴 **读面只问这一只**(codex R1-F2):`config-catalog` 的 `WEB_SEARCH_PROVIDER` 行要的是「装配了哪只后端」这一格,
|
|
163
|
+
* 不是整份配置。首版它调的是 {@link webSearchConfigFromEnv} —— 那只对旁的 `WEB_SEARCH_*` 形错**抛**(拒启);嵌入式
|
|
164
|
+
* `createHttpServer` 不走 stage-07,形错的环境可以活在一只跑着的 server 里,于是读 provider 这一格就把整张目录打成
|
|
165
|
+
* 500。形错拒启归装配点(stage-07 / run-local 调整份解析),读面不替装配点再判一遍。
|
|
166
|
+
*/
|
|
167
|
+
export declare function webSearchProviderFromEnv(env?: NodeJS.ProcessEnv): WebSearchProvider | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* 部署 env ⇒ 后端配置。`WEB_SEARCH_PROVIDER` 缺席 / 词表外 ⇒ `undefined`(WebSearch 不装配,**不**读其余旋钮 ——
|
|
170
|
+
* 不消费的键不判);其余 `WEB_SEARCH_*` 任一形错 ⇒ **抛**(拒启,见 {@link envValue})。
|
|
171
|
+
*/
|
|
169
172
|
export declare function webSearchConfigFromEnv(env?: NodeJS.ProcessEnv): WebSearchBackendConfig | undefined;
|
|
170
173
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
174
|
+
* 每请求 `body.settings.webSearch` 的判决 —— 受理面与装配点**同问这一只**:
|
|
175
|
+
* · `absent` —— 没带(或 `null`)⇒ 用部署后端;
|
|
176
|
+
* · `honored` —— 每个字段形对,且 `provider` 点名了一只本部署会说的后端 ⇒ 这次调用用它;
|
|
177
|
+
* · `malformed` —— 任一字段形错 ⇒ 受理面 400 `request.field_invalid`(`issue` 逐字点名字段)。
|
|
178
|
+
* **`provider` 缺席或不在词表里也是形错**(S-199 主会话改判,codex 验修 V1 [high]):一段 `settings.webSearch` 就是
|
|
179
|
+
* 调用方在点名目的地(`endpoint` / `apiKey` 都挂在它点名的那只后端上),认不出就拒,不静默换成部署后端 —— 改判前
|
|
180
|
+
* 这一形回落部署后端,`{provider:"searx", endpoint:<私有地址>}` 的查询于是发到了部署配的后端。env 腿的同一只
|
|
181
|
+
* {@link isWebSearchProvider} 守卫对**部署**的词表外答「不装配」,那是部署自己的缺席,不是换别人的目的地。
|
|
182
|
+
* 规则集因此只剩三态,没有「形对但不采纳」这一臂。
|
|
183
|
+
*
|
|
184
|
+
* 🔒 采纳与否是**调用方**的事:装配点把整条腿闸在单用户车道(每请求 `endpoint`/`apiKey` 是能力配置;多租上一个
|
|
185
|
+
* 租户把 `searxng` 指向内网地址就是 SSRF,所以多租只认部署 env 的后端)。受理面的形判**不分车道**(与
|
|
186
|
+
* `settings.hooks` 同姿势:形在受理面判,采不采纳在装配点决定)。
|
|
176
187
|
*/
|
|
177
|
-
export
|
|
188
|
+
export type RequestWebSearchVerdict = {
|
|
189
|
+
readonly kind: "absent";
|
|
190
|
+
} | {
|
|
191
|
+
readonly kind: "honored";
|
|
192
|
+
readonly config: WebSearchBackendConfig;
|
|
193
|
+
} | {
|
|
194
|
+
readonly kind: "malformed";
|
|
195
|
+
readonly issue: string;
|
|
196
|
+
};
|
|
197
|
+
export declare function judgeRequestWebSearch(raw: unknown): RequestWebSearchVerdict;
|
|
178
198
|
//# sourceMappingURL=web-search.d.ts.map
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createSearxngSearchBackend } from "@sema-agent/core";
|
|
2
|
+
import { isHierarchicalUrl } from "../trace/redact.js";
|
|
2
3
|
export const WEB_SEARCH_PROVIDERS = ["brave", "tavily", "searxng"];
|
|
3
4
|
function isWebSearchProvider(word) {
|
|
4
5
|
return WEB_SEARCH_PROVIDERS.includes(word);
|
|
5
6
|
}
|
|
7
|
+
const providerWord = (raw) => raw.trim().toLowerCase();
|
|
6
8
|
export function projectWebSearchCapability(provider) {
|
|
7
9
|
return provider === undefined ? { backend: "none" } : { backend: provider };
|
|
8
10
|
}
|
|
@@ -26,6 +28,10 @@ function withTimeout(signal, ms) {
|
|
|
26
28
|
async function errorText(res) {
|
|
27
29
|
return (await res.text().catch(() => "")).slice(0, 200);
|
|
28
30
|
}
|
|
31
|
+
async function httpFailure(provider, res) {
|
|
32
|
+
const body = await errorText(res);
|
|
33
|
+
return new Error(`${provider} search failed: HTTP ${res.status}${body ? ` — ${body}` : ""}`);
|
|
34
|
+
}
|
|
29
35
|
let onBadPayload;
|
|
30
36
|
export function setWebSearchBadPayloadObserver(fn) {
|
|
31
37
|
onBadPayload = fn;
|
|
@@ -62,7 +68,7 @@ async function braveSearch(fetchImpl, cfg, query, max, signal) {
|
|
|
62
68
|
url.searchParams.set("count", String(Math.min(20, max)));
|
|
63
69
|
const res = await fetchImpl(url, { headers: { Accept: "application/json", "X-Subscription-Token": cfg.apiKey }, signal });
|
|
64
70
|
if (!res.ok)
|
|
65
|
-
throw
|
|
71
|
+
throw await httpFailure("brave", res);
|
|
66
72
|
const data = (await res.json());
|
|
67
73
|
return normalize(data.web?.results, (r) => ({ title: r.title, url: r.url, snippet: r.description }), max, "brave");
|
|
68
74
|
}
|
|
@@ -81,7 +87,7 @@ async function tavilySearch(fetchImpl, cfg, query, max, signal, opts) {
|
|
|
81
87
|
signal,
|
|
82
88
|
});
|
|
83
89
|
if (!res.ok)
|
|
84
|
-
throw
|
|
90
|
+
throw await httpFailure("tavily", res);
|
|
85
91
|
const data = (await res.json());
|
|
86
92
|
return normalize(data.results, (r) => ({ title: r.title, url: r.url, snippet: r.content }), max, "tavily");
|
|
87
93
|
}
|
|
@@ -132,64 +138,150 @@ export function createWebSearchBackend(cfg) {
|
|
|
132
138
|
};
|
|
133
139
|
return { search, maxResults };
|
|
134
140
|
}
|
|
135
|
-
export function parseSearxngParams(raw) {
|
|
136
|
-
if (typeof raw === "object" && raw !== null) {
|
|
137
|
-
const entries = Object.entries(raw);
|
|
138
|
-
if (entries.length === 0 || entries.some(([, v]) => typeof v !== "string"))
|
|
139
|
-
return undefined;
|
|
140
|
-
return Object.fromEntries(entries);
|
|
141
|
-
}
|
|
142
|
-
if (typeof raw !== "string")
|
|
143
|
-
return undefined;
|
|
144
|
-
const out = {};
|
|
145
|
-
for (const pair of raw.split(";")) {
|
|
146
|
-
const i = pair.indexOf("=");
|
|
147
|
-
if (i <= 0)
|
|
148
|
-
continue;
|
|
149
|
-
const k = pair.slice(0, i).trim();
|
|
150
|
-
const v = pair.slice(i + 1).trim();
|
|
151
|
-
if (k && v)
|
|
152
|
-
out[k] = v;
|
|
153
|
-
}
|
|
154
|
-
return Object.keys(out).length > 0 ? out : undefined;
|
|
155
|
-
}
|
|
156
141
|
export function shouldProbeWebSearchOnBoot(env = process.env) {
|
|
157
142
|
const raw = env.WEB_SEARCH_PROBE_ON_BOOT?.trim().toLowerCase();
|
|
158
143
|
return raw === "true" || raw === "1";
|
|
159
144
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
145
|
+
const ABSENT = { ok: true };
|
|
146
|
+
const valueOf = (j) => (j.ok ? j.value : undefined);
|
|
147
|
+
const isBlank = (s) => s.trim() === "";
|
|
148
|
+
function judgeText(raw) {
|
|
149
|
+
if (raw === undefined || raw === null)
|
|
150
|
+
return ABSENT;
|
|
151
|
+
if (typeof raw !== "string")
|
|
152
|
+
return { ok: false, issue: "must be a string" };
|
|
153
|
+
return isBlank(raw) ? ABSENT : { ok: true, value: raw };
|
|
154
|
+
}
|
|
155
|
+
const ENDPOINT_FORM = "must be an absolute URL with a scheme and a host, such as https://search.example.com (a bare host or host:port is not one)";
|
|
156
|
+
function judgeEndpoint(raw) {
|
|
157
|
+
if (raw === undefined || raw === null)
|
|
158
|
+
return ABSENT;
|
|
159
|
+
if (typeof raw !== "string")
|
|
160
|
+
return { ok: false, issue: ENDPOINT_FORM };
|
|
161
|
+
if (isBlank(raw))
|
|
162
|
+
return ABSENT;
|
|
163
|
+
return isHierarchicalUrl(raw) ? { ok: true, value: raw } : { ok: false, issue: ENDPOINT_FORM };
|
|
164
|
+
}
|
|
165
|
+
const COUNT_FORM = "must be a number ≥ 1";
|
|
166
|
+
function judgeCount(raw) {
|
|
167
|
+
if (raw === undefined || raw === null)
|
|
168
|
+
return ABSENT;
|
|
169
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw >= 1 ? { ok: true, value: Math.floor(raw) } : { ok: false, issue: COUNT_FORM };
|
|
170
|
+
}
|
|
171
|
+
function envNumber(raw) {
|
|
172
|
+
return raw === undefined || isBlank(raw) ? undefined : Number(raw.trim());
|
|
173
|
+
}
|
|
174
|
+
const SEARXNG_PARAM_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
175
|
+
const SEARXNG_PARAMS_FORM = 'must be an object of parameter name → non-empty string, or a "name=value;name=value" string';
|
|
176
|
+
function judgeSearxngParams(raw) {
|
|
177
|
+
if (raw === undefined || raw === null)
|
|
178
|
+
return ABSENT;
|
|
179
|
+
const pairs = [];
|
|
180
|
+
if (typeof raw === "string") {
|
|
181
|
+
const segments = raw.split(";");
|
|
182
|
+
for (const [i, seg] of segments.entries()) {
|
|
183
|
+
if (isBlank(seg))
|
|
184
|
+
continue;
|
|
185
|
+
const eq = seg.indexOf("=");
|
|
186
|
+
if (eq < 0)
|
|
187
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — segment ${i + 1} has no "="` };
|
|
188
|
+
pairs.push([seg.slice(0, eq).trim(), seg.slice(eq + 1).trim()]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
192
|
+
const entries = Object.entries(raw);
|
|
193
|
+
for (const [k, v] of entries) {
|
|
194
|
+
if (typeof v !== "string")
|
|
195
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — the value of ${JSON.stringify(k)} is not a string` };
|
|
196
|
+
pairs.push([k, v.trim()]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — got ${Array.isArray(raw) ? "an array" : `a ${typeof raw}`}` };
|
|
201
|
+
}
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
for (const [k, v] of pairs) {
|
|
204
|
+
if (!SEARXNG_PARAM_NAME.test(k))
|
|
205
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${JSON.stringify(k)} is not a parameter name (letters, digits, "_", "-", ".")` };
|
|
206
|
+
if (v === "")
|
|
207
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${k} has an empty value` };
|
|
208
|
+
if (seen.has(k))
|
|
209
|
+
return { ok: false, issue: `${SEARXNG_PARAMS_FORM} — ${k} is given more than once` };
|
|
210
|
+
seen.add(k);
|
|
211
|
+
}
|
|
212
|
+
return pairs.length === 0 ? ABSENT : { ok: true, value: Object.fromEntries(pairs) };
|
|
213
|
+
}
|
|
214
|
+
function assembleBackendConfig(provider, f) {
|
|
167
215
|
return {
|
|
168
216
|
provider,
|
|
169
|
-
...(
|
|
170
|
-
...(
|
|
171
|
-
...(searxngParams ? { searxngParams } : {}),
|
|
172
|
-
...(
|
|
173
|
-
...(
|
|
217
|
+
...(f.apiKey !== undefined ? { apiKey: f.apiKey } : {}),
|
|
218
|
+
...(f.endpoint !== undefined ? { endpoint: f.endpoint } : {}),
|
|
219
|
+
...(f.searxngParams !== undefined ? { searxngParams: f.searxngParams } : {}),
|
|
220
|
+
...(f.maxResults !== undefined ? { maxResults: f.maxResults } : {}),
|
|
221
|
+
...(f.timeoutMs !== undefined ? { timeoutMs: f.timeoutMs } : {}),
|
|
174
222
|
};
|
|
175
223
|
}
|
|
176
|
-
export
|
|
177
|
-
|
|
224
|
+
export const WEB_SEARCH_CONFIG_INVALID_CODE = "config.web_search_invalid";
|
|
225
|
+
function envValue(key, j) {
|
|
226
|
+
if (!j.ok) {
|
|
227
|
+
throw new Error(`env ${key} ${j.issue} [${WEB_SEARCH_CONFIG_INVALID_CODE}] — fix it or unset it (an empty value counts as unset). ` +
|
|
228
|
+
"WebSearch reads its WEB_SEARCH_* knobs only when WEB_SEARCH_PROVIDER names a backend, and every knob it reads must be well-formed: " +
|
|
229
|
+
"a malformed value used to be absorbed silently (garbage parameters sent to the search instance, or a quiet fall-back to a default nobody chose).");
|
|
230
|
+
}
|
|
231
|
+
return j.value;
|
|
232
|
+
}
|
|
233
|
+
export function webSearchProviderFromEnv(env = process.env) {
|
|
234
|
+
const raw = env.WEB_SEARCH_PROVIDER;
|
|
235
|
+
const word = raw === undefined ? undefined : providerWord(raw);
|
|
236
|
+
return word !== undefined && isWebSearchProvider(word) ? word : undefined;
|
|
237
|
+
}
|
|
238
|
+
export function webSearchConfigFromEnv(env = process.env) {
|
|
239
|
+
const provider = webSearchProviderFromEnv(env);
|
|
240
|
+
if (provider === undefined)
|
|
178
241
|
return undefined;
|
|
242
|
+
return assembleBackendConfig(provider, {
|
|
243
|
+
apiKey: envValue("WEB_SEARCH_API_KEY", judgeText(env.WEB_SEARCH_API_KEY)),
|
|
244
|
+
endpoint: envValue("WEB_SEARCH_ENDPOINT", judgeEndpoint(env.WEB_SEARCH_ENDPOINT)),
|
|
245
|
+
searxngParams: envValue("WEB_SEARCH_SEARXNG_PARAMS", judgeSearxngParams(env.WEB_SEARCH_SEARXNG_PARAMS)),
|
|
246
|
+
maxResults: envValue("WEB_SEARCH_MAX_RESULTS", judgeCount(envNumber(env.WEB_SEARCH_MAX_RESULTS))),
|
|
247
|
+
timeoutMs: envValue("WEB_SEARCH_TIMEOUT_MS", judgeCount(envNumber(env.WEB_SEARCH_TIMEOUT_MS))),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const SETTINGS_WEB_SEARCH_FORM = "settings.webSearch must be an object { provider, apiKey?, endpoint?, searxngParams?, maxResults? }";
|
|
251
|
+
function judgeRequestProvider(raw) {
|
|
252
|
+
const got = raw === undefined || raw === null ? "it is missing" : typeof raw === "string" ? `got ${JSON.stringify(raw.slice(0, 40))}` : `got a ${Array.isArray(raw) ? "array" : typeof raw}`;
|
|
253
|
+
const word = typeof raw === "string" ? providerWord(raw) : undefined;
|
|
254
|
+
return word !== undefined && isWebSearchProvider(word)
|
|
255
|
+
? { ok: true, value: word }
|
|
256
|
+
: { ok: false, issue: `must name one of ${WEB_SEARCH_PROVIDERS.join(" | ")} (${got}) — it names where this request's searches go, so an unknown or missing provider is refused rather than replaced by this deployment's own backend` };
|
|
257
|
+
}
|
|
258
|
+
export function judgeRequestWebSearch(raw) {
|
|
259
|
+
if (raw === undefined || raw === null)
|
|
260
|
+
return { kind: "absent" };
|
|
261
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
262
|
+
return { kind: "malformed", issue: SETTINGS_WEB_SEARCH_FORM };
|
|
179
263
|
const s = raw;
|
|
180
|
-
const provider =
|
|
181
|
-
if (!
|
|
182
|
-
return
|
|
183
|
-
const
|
|
264
|
+
const provider = judgeRequestProvider(s.provider);
|
|
265
|
+
if (!provider.ok)
|
|
266
|
+
return { kind: "malformed", issue: `settings.webSearch.provider ${provider.issue}` };
|
|
267
|
+
const fields = {
|
|
268
|
+
apiKey: judgeText(s.apiKey),
|
|
269
|
+
endpoint: judgeEndpoint(s.endpoint),
|
|
270
|
+
searxngParams: judgeSearxngParams(s.searxngParams),
|
|
271
|
+
maxResults: judgeCount(s.maxResults),
|
|
272
|
+
};
|
|
273
|
+
for (const [field, j] of Object.entries(fields)) {
|
|
274
|
+
if (!j.ok)
|
|
275
|
+
return { kind: "malformed", issue: `settings.webSearch.${field} ${j.issue}` };
|
|
276
|
+
}
|
|
184
277
|
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
})
|
|
192
|
-
...(max ? { maxResults: max } : {}),
|
|
278
|
+
kind: "honored",
|
|
279
|
+
config: assembleBackendConfig(provider.value, {
|
|
280
|
+
apiKey: valueOf(fields.apiKey),
|
|
281
|
+
endpoint: valueOf(fields.endpoint),
|
|
282
|
+
searxngParams: valueOf(fields.searxngParams),
|
|
283
|
+
maxResults: valueOf(fields.maxResults),
|
|
284
|
+
}),
|
|
193
285
|
};
|
|
194
286
|
}
|
|
195
287
|
//# sourceMappingURL=web-search.js.map
|