@sema-agent/server 7.43.0 → 7.44.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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/plugins/scheduler-support.ts — the server-side `SchedulerCapability` backend (R7 self-wake).
|
|
3
|
-
*
|
|
4
|
-
* core 1.165.0 ships the `SchedulerCapability` seam (design/105): core 出接口 + 工具壳 (现名 CronCreate/CronDelete/
|
|
5
|
-
* CronList——L8 订正:CronCancel 旧名、Sleep 已 design/136 撤除、挂载在 core prepare-task 非 createHandsToolkit,
|
|
6
|
-
* `hasScheduler(env)` 门不变) + the `hasScheduler` gate. **持久化 /
|
|
7
|
-
* cron 时钟 / idle 触发 / 起新 task 全在 backend** — core is request-driven, not resident. R7 splits the backend:
|
|
8
|
-
* - THIS file (server) = the SchedulerCapability impl: the WRITE side — schedule/cancel/list, persisted to the
|
|
9
|
-
* SHARED on-disk store. Injected into `RemoteHostExecutionEnv` (the TOC host lane). deps `@sema-agent/core` (fine
|
|
10
|
-
* server-side).
|
|
11
|
-
* - The TOC shell `SchedulerDaemon` (shell-host, design R7 part3b) = the READ side: a resident process that
|
|
12
|
-
* polls the SAME store, resolves cron, fires due intents via the SDK (principal pinned), and reaps.
|
|
13
|
-
*
|
|
14
|
-
* The shared on-disk FORMAT is the dep-free `@sema-agent/registry-core/scheduler-store` contract; the fs binding is
|
|
15
|
-
* `@sema-agent/registry-core/node`. server (writer) + shell daemon (reader) share ONE impl ⇒ zero format drift.
|
|
16
|
-
*
|
|
17
|
-
* Logic ported from the shell-host's canonical `scheduler/schedulerBackend.ts` (design doc
|
|
18
|
-
* `R7-scheduler-rewind.md` §1: the server reuses the same schedulerBackend logic verbatim).
|
|
19
|
-
*
|
|
20
|
-
* 🔴 红线 (design/105): `principal` is `ctx` (Runner-held) NOT `intent` (model-controllable) → pinned into the
|
|
21
|
-
* record, passed through to runTask unchanged by the daemon (no privilege escalation). cron via core
|
|
22
|
-
* `isValidCronExpr` (no shell-exec / RCE). scope isolation: cancel/list touch only the caller's scope. durable:
|
|
23
|
-
* the store is on disk (survives a daemon restart). upsert: same (scope, when, label) overwrites (idempotent on a
|
|
24
|
-
* tool retry).
|
|
25
|
-
*/
|
|
26
1
|
import { join } from "node:path";
|
|
27
2
|
import { homedir } from "node:os";
|
|
28
3
|
import { randomBytes, createHash } from "node:crypto";
|
|
@@ -30,27 +5,15 @@ import { SchedulerError, isValidCronExpr } from "@sema-agent/core";
|
|
|
30
5
|
import { loadSchedulerStore, saveSchedulerStore } from "@sema-agent/registry-core/node";
|
|
31
6
|
const ok = (value) => ({ ok: true, value });
|
|
32
7
|
const err = (error) => ({ ok: false, error });
|
|
33
|
-
/** Default caps (fail-closed bounds; design/105 §3.9). Override per deployment. */
|
|
34
8
|
export const DEFAULT_SCHEDULER_CAPS = {
|
|
35
9
|
supported: true,
|
|
36
10
|
maxScheduledPerScope: 32,
|
|
37
11
|
minDelaySec: 10,
|
|
38
12
|
minCronIntervalSec: 60,
|
|
39
|
-
maxScheduledHorizonSec: 60 * 60 * 24 * 30,
|
|
40
|
-
// core 1.301 CronCreate durable/session 分层 ([950]): session-scoped jobs are persisted with
|
|
41
|
-
// lifetime:"session" and reaped by the resident shell daemon when the session ends. True here because
|
|
42
|
-
// this backend faithfully persists the lifetime field (schedule() below) — without the flag core's
|
|
43
|
-
// CronCreate refuses durable:false up front (fail-closed at the tool layer).
|
|
13
|
+
maxScheduledHorizonSec: 60 * 60 * 24 * 30,
|
|
44
14
|
supportsSessionLifetime: true,
|
|
45
|
-
// core 1.309 ([1000]③ ScheduleWakeup 治本位): true = a resident daemon reads this store and fires
|
|
46
|
-
// session-wakeup records for its own sessions (the normal co-resident TOC shape). The DEPLOYMENT flips
|
|
47
|
-
// this to false via SCHEDULER_SESSION_WAKEUP=false when the spawning shell knows no daemon will honor
|
|
48
|
-
// them (headless spawn without a resident daemon) — core's ScheduleWakeup then refuses up front instead
|
|
49
|
-
// of parking a wakeup that never fires. Capability = instance-lifetime snapshot (core TOCTOU 契约条款),
|
|
50
|
-
// so the signal rides construction, never per-call.
|
|
51
15
|
supportsSessionWakeup: true,
|
|
52
16
|
};
|
|
53
|
-
/** Human-readable `when` summary for list(). */
|
|
54
17
|
function whenSummary(when) {
|
|
55
18
|
if (when.kind === "cron")
|
|
56
19
|
return `cron ${when.expr}`;
|
|
@@ -58,30 +21,16 @@ function whenSummary(when) {
|
|
|
58
21
|
return `at ${new Date(when.atMs).toISOString()}`;
|
|
59
22
|
return `delay ${when.delaySec}s`;
|
|
60
23
|
}
|
|
61
|
-
/** Upsert dedup key — same (scope, LANE, when, label) overwrites (idempotent on tool retry).
|
|
62
|
-
* The lane term is the lifetime identity (adversarial review F1): without it a session job and a durable
|
|
63
|
-
* job — or two different sessions' jobs — with the same (when, label) collide, and the daemon's
|
|
64
|
-
* session-end reap would then silently delete what USED to be a durable/other-session job. core's cron
|
|
65
|
-
* shorthand defaults to session lifetime with no label, so the collision is normal usage, not an edge. */
|
|
66
24
|
function dedupKey(scope, intent, sessionId) {
|
|
67
25
|
const lane = intent.lifetime === "session" ? `session:${sessionId ?? ""}` : "durable";
|
|
68
26
|
return createHash("sha256").update(JSON.stringify([scope, lane, intent.when, intent.label ?? ""])).digest("hex").slice(0, 16);
|
|
69
27
|
}
|
|
70
|
-
/**
|
|
71
|
-
* PURE validation of an intent against caps + now. Returns a SchedulerError or null (ok). No IO. Testable.
|
|
72
|
-
* cron via core `isValidCronExpr` (RCE-safe, no shell-exec); delay/at bounded by minDelaySec + horizon.
|
|
73
|
-
*/
|
|
74
28
|
export function validateIntent(intent, caps, nowMs) {
|
|
75
29
|
if (!intent.prompt || typeof intent.prompt !== "string")
|
|
76
30
|
return new SchedulerError("invalid_schedule", "prompt is required");
|
|
77
31
|
const w = intent.when;
|
|
78
32
|
const horizonMs = caps.maxScheduledHorizonSec * 1000;
|
|
79
33
|
if (w.kind === "cron") {
|
|
80
|
-
// Validate the cron is WELL-FORMED (RCE-safe — the expr never reaches a shell). The `minCronIntervalSec` FLOOR is
|
|
81
|
-
// NOT enforced here: realizing a cron's actual fire interval requires cron-next, which the design assigns to the
|
|
82
|
-
// resident daemon (it computes nextRunMs + can skip/floor a sub-interval fire). A 5-field cron's minimum interval
|
|
83
|
-
// is 60s, so the default 60s floor is naturally satisfied; a deployment that TIGHTENS minCronIntervalSec relies on
|
|
84
|
-
// the daemon to honor it. (Adversarial-review LOW: the advertised cap is daemon-enforced, not writer-enforced.)
|
|
85
34
|
if (!isValidCronExpr(w.expr))
|
|
86
35
|
return new SchedulerError("invalid_schedule", `invalid cron expression: ${w.expr}`);
|
|
87
36
|
}
|
|
@@ -103,7 +52,6 @@ export function validateIntent(intent, caps, nowMs) {
|
|
|
103
52
|
}
|
|
104
53
|
return null;
|
|
105
54
|
}
|
|
106
|
-
/** nextRunMs for at/delay (direct); cron left undefined here (the resident daemon computes via a cron lib). */
|
|
107
55
|
function computeNextRunMs(when, nowMs) {
|
|
108
56
|
if (when.kind === "at")
|
|
109
57
|
return when.atMs;
|
|
@@ -111,15 +59,9 @@ function computeNextRunMs(when, nowMs) {
|
|
|
111
59
|
return nowMs + when.delaySec * 1000;
|
|
112
60
|
return undefined;
|
|
113
61
|
}
|
|
114
|
-
/** The default shared store path: `SEMA_CONFIG_DIR` → `~/.sema`, file `scheduled_tasks.json` (server + shell agree). */
|
|
115
62
|
export function defaultSchedulerStorePath() {
|
|
116
63
|
return join(process.env.SEMA_CONFIG_DIR ?? join(homedir(), ".sema"), "scheduled_tasks.json");
|
|
117
64
|
}
|
|
118
|
-
/**
|
|
119
|
-
* File-backed SchedulerCapability (durable across daemon restarts). Store = a single JSON file (atomic write via
|
|
120
|
-
* the registry-core /node binding). The resident shell daemon reads the same store to fire intents. `now`
|
|
121
|
-
* injectable for tests.
|
|
122
|
-
*/
|
|
123
65
|
export class FileSchedulerBackend {
|
|
124
66
|
schedulerCapabilities;
|
|
125
67
|
storePath;
|
|
@@ -140,14 +82,9 @@ export class FileSchedulerBackend {
|
|
|
140
82
|
const bad = validateIntent(intent, this.schedulerCapabilities, nowMs);
|
|
141
83
|
if (bad)
|
|
142
84
|
return err(bad);
|
|
143
|
-
// A session-scoped job with no session to bind to could never be reaped (a zombie the daemon would
|
|
144
|
-
// fire forever) — core's CronCreate refuses this at the tool layer; refuse here too for non-tool callers.
|
|
145
85
|
if (intent.lifetime === "session" && !ctx.sessionId) {
|
|
146
86
|
return err(new SchedulerError("invalid_schedule", 'lifetime:"session" requires a session to bind to (ctx.sessionId missing)'));
|
|
147
87
|
}
|
|
148
|
-
// [2436] 纵深(5.6.0):能力面已声明无 daemon 收割 session-lifetime(headless 部署形,
|
|
149
|
-
// SCHEDULER_SESSION_LIFETIME=false)⇒ 落盘一条即必然孤儿([2425] 外溢病)。core 的 CronCreate 读
|
|
150
|
-
// 能力面在工具层前门拒是第一道;这里是第二道——错装配宿主绕过工具层直调也不许落盘。
|
|
151
88
|
if (intent.lifetime === "session" && this.schedulerCapabilities.supportsSessionLifetime === false) {
|
|
152
89
|
return err(new SchedulerError("invalid_schedule", 'lifetime:"session" is not supported on this deployment (no resident daemon reaps session-lifetime records — the row would be an orphan)'));
|
|
153
90
|
}
|
|
@@ -157,7 +94,6 @@ export class FileSchedulerBackend {
|
|
|
157
94
|
const rr = r;
|
|
158
95
|
return r.scope === ctx.scope && dedupKey(r.scope, { when: r.when, label: r.label, lifetime: rr.lifetime }, r.sessionId) === key;
|
|
159
96
|
});
|
|
160
|
-
// Per-scope cap (excluding the upsert target — overwriting an existing one doesn't grow the count).
|
|
161
97
|
const scopeCount = records.filter((r) => r.scope === ctx.scope).length;
|
|
162
98
|
if (existingIdx < 0 && scopeCount >= this.schedulerCapabilities.maxScheduledPerScope) {
|
|
163
99
|
return err(new SchedulerError("limit_exceeded", `scope at cap (${this.schedulerCapabilities.maxScheduledPerScope})`));
|
|
@@ -166,20 +102,12 @@ export class FileSchedulerBackend {
|
|
|
166
102
|
const rec = {
|
|
167
103
|
id,
|
|
168
104
|
scope: ctx.scope,
|
|
169
|
-
...(ctx.principal ? { principal: ctx.principal } : {}),
|
|
105
|
+
...(ctx.principal ? { principal: ctx.principal } : {}),
|
|
170
106
|
...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}),
|
|
171
107
|
prompt: intent.prompt,
|
|
172
108
|
when: intent.when,
|
|
173
109
|
...(intent.label ? { label: intent.label } : {}),
|
|
174
|
-
// core 1.265 ScheduledIntent.mode(缺省 "task" 向后兼容):persist the execution shape —
|
|
175
|
-
// ScheduleWakeup's stop:true keys off the LIST face's mode echo (CC 206 D9); a dropped mode forced the
|
|
176
|
-
// shell onto its label fallback. registry-core SchedulerRecord optional fields are additive by contract.
|
|
177
110
|
...(intent.mode ? { mode: intent.mode } : {}),
|
|
178
|
-
// core 1.301 lifetime/recurring ([950]/[954]): persist BOTH so the daemon can reap session-scoped
|
|
179
|
-
// jobs and delete a recurring:false cron after its first firing. Dropping them here was [954]-(b):
|
|
180
|
-
// the tool answered "once" while the record said nothing → the daemon fired forever. Additive
|
|
181
|
-
// SchedulerRecord fields (registry-core contract), same posture as `mode` above. recurring is a
|
|
182
|
-
// tri-state (undefined = cron default "fire every match") — persist only when explicitly set.
|
|
183
111
|
...(intent.lifetime ? { lifetime: intent.lifetime } : {}),
|
|
184
112
|
...(intent.recurring !== undefined ? { recurring: intent.recurring } : {}),
|
|
185
113
|
createdMs: nowMs,
|
|
@@ -200,9 +128,6 @@ export class FileSchedulerBackend {
|
|
|
200
128
|
async cancel(id, ctx) {
|
|
201
129
|
const records = this.load();
|
|
202
130
|
const idx = records.findIndex((r) => r.id === id);
|
|
203
|
-
// 🔴 scope isolation + 假肯定病修([2425]/[2209],5.6.0):missing 与 non-own-scope **同折**
|
|
204
|
-
// err(not_found)——存在性仍不泄露(错 id/别人的任务/已删的重试三条到达路径一个答案),但不再
|
|
205
|
-
// ok(undefined) 谎报「删掉了」。旧形的代价是真实的:模型删错 id 得到成功回执,真任务继续按点起跑。
|
|
206
131
|
if (idx < 0 || records[idx].scope !== ctx.scope) {
|
|
207
132
|
return err(new SchedulerError("not_found", `no scheduled task ${id} in your scope (wrong id, already cancelled, or not yours)`));
|
|
208
133
|
}
|
|
@@ -217,15 +142,13 @@ export class FileSchedulerBackend {
|
|
|
217
142
|
}
|
|
218
143
|
async list(ctx) {
|
|
219
144
|
const summaries = this.load()
|
|
220
|
-
.filter((r) => r.scope === ctx.scope)
|
|
145
|
+
.filter((r) => r.scope === ctx.scope)
|
|
221
146
|
.map((r) => ({
|
|
222
147
|
id: r.id,
|
|
223
148
|
when: whenSummary(r.when),
|
|
224
149
|
...(r.label ? { label: r.label } : {}),
|
|
225
150
|
...(r.nextRunMs !== undefined ? { nextRunMs: r.nextRunMs } : {}),
|
|
226
|
-
// echo the persisted mode (additive; pre-1.159.x records lack it → undefined = shell label fallback).
|
|
227
151
|
...(r.mode ? { mode: r.mode } : {}),
|
|
228
|
-
// echo lifetime/recurring (ScheduledTaskSummary 1.302 fields; absent on pre-1.215 records).
|
|
229
152
|
...(r.lifetime ? { lifetime: r.lifetime } : {}),
|
|
230
153
|
...(r.recurring !== undefined ? { recurring: r.recurring } : {}),
|
|
231
154
|
}));
|
|
@@ -1,30 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SendUserFile server-side LEDGER (multi-tenant governance face, clay ruling 2026-07-14 — the second half of
|
|
3
|
-
* [768]②a: the object key carries a HASHED scope segment, so the store alone can't answer "which tenant sent
|
|
4
|
-
* what". This ledger is the scope↔object mapping the hash deliberately hides from URLs): one row per issued
|
|
5
|
-
* link, keyed by a uuidv7 `id` (time-ordered → keyset pagination), indexed by `scope` (the VERIFIED principal
|
|
6
|
-
* verbatim; "_" = the single-user sentinel, same as the checkpoint scope column).
|
|
7
|
-
*
|
|
8
|
-
* WRITE SEMANTICS (enforced by {@link withLedgerRecording}, wrapped around BOTH send lanes in main.ts): record
|
|
9
|
-
* AFTER upload+verify succeed and BEFORE the file_link frame is emitted. A record failure is FAIL-LOUD for that
|
|
10
|
-
* file (the governance face MUST have the row — a link the ledger can't see can't be listed or revoked) and the
|
|
11
|
-
* already-uploaded object is DELETED with one retry (a public-track object with no ledger row would be an
|
|
12
|
-
* unmanageable orphan); the thrown message reports the TRUE reclaim outcome (reclaimed vs orphan) but — 修4 —
|
|
13
|
-
* NEVER the coordinates or the cause (it is model-visible via the tool result; a bucket/key leak there would
|
|
14
|
-
* let the model rebuild the capability URL): those go to the operator log under a shared incident id.
|
|
15
|
-
*
|
|
16
|
-
* The ledger does NOT store URLs: the public-track URL is reconstructible from bucket+key, and a presigned-track
|
|
17
|
-
* URL is a decaying capability that would be expired by the time anyone lists it. Reads return metadata + key.
|
|
18
|
-
*
|
|
19
|
-
* Four twins behind one interface (the approval-exemption pattern): TiDB/PG = durable cross-replica SQL rows
|
|
20
|
-
* (`sendfile_link` table, DDL in tidb-pool.ts / pg-pool.ts); File = append-only JSONL hydrated on boot
|
|
21
|
-
* (fsync — a torn tail must not fabricate a row); Memory = tests / ephemeral.
|
|
22
|
-
*/
|
|
23
1
|
import { join } from "node:path";
|
|
24
2
|
import { AppendLog, ensureDir, readJsonlRecords, uuidv7 } from "@sema-agent/core";
|
|
25
|
-
/** Ledger scope sentinel for single-user issuance (no principal) — mirrors scopeSegment's "_" key segment. */
|
|
26
3
|
export const SENDFILE_SINGLE_USER_SCOPE = "_";
|
|
27
|
-
// ─── TiDB / MySQL twin ───────────────────────────────────────────────────────────────────────────────────
|
|
28
4
|
export class TiDBSendFileLedger {
|
|
29
5
|
pool;
|
|
30
6
|
constructor(pool) {
|
|
@@ -32,8 +8,7 @@ export class TiDBSendFileLedger {
|
|
|
32
8
|
}
|
|
33
9
|
async record(entry) {
|
|
34
10
|
await this.pool.query("INSERT INTO sendfile_link (id, scope, bucket, object_key, track, filename, byte_len, ttl_sec, task_id, session_id, created_at) " +
|
|
35
|
-
"VALUES (?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE id = id",
|
|
36
|
-
[entry.id, entry.scope, entry.bucket, entry.objectKey, entry.track, entry.filename, entry.size, entry.ttlSec, entry.taskId ?? null, entry.sessionId ?? null, new Date(entry.createdAt)]);
|
|
11
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE id = id", [entry.id, entry.scope, entry.bucket, entry.objectKey, entry.track, entry.filename, entry.size, entry.ttlSec, entry.taskId ?? null, entry.sessionId ?? null, new Date(entry.createdAt)]);
|
|
37
12
|
}
|
|
38
13
|
async listByScope(scope, limit, beforeId) {
|
|
39
14
|
const [rows] = await this.pool.query("SELECT id, scope, bucket, object_key, track, filename, byte_len, ttl_sec, task_id, session_id, created_at FROM sendfile_link " +
|
|
@@ -41,7 +16,6 @@ export class TiDBSendFileLedger {
|
|
|
41
16
|
return rows.map((r) => rowToEntry(r));
|
|
42
17
|
}
|
|
43
18
|
}
|
|
44
|
-
// ─── PG twin (dialect twin — $n placeholders + ON CONFLICT) ──────────────────────────────────────────────
|
|
45
19
|
export class PgSendFileLedger {
|
|
46
20
|
pool;
|
|
47
21
|
constructor(pool) {
|
|
@@ -72,7 +46,6 @@ function rowToEntry(r) {
|
|
|
72
46
|
createdAt: new Date(r.created_at).getTime(),
|
|
73
47
|
};
|
|
74
48
|
}
|
|
75
|
-
// ─── Memory twin (tests / ephemeral) ─────────────────────────────────────────────────────────────────────
|
|
76
49
|
export class MemorySendFileLedger {
|
|
77
50
|
byScope = new Map();
|
|
78
51
|
async record(entry) {
|
|
@@ -82,7 +55,7 @@ export class MemorySendFileLedger {
|
|
|
82
55
|
this.byScope.set(entry.scope, m);
|
|
83
56
|
}
|
|
84
57
|
if (!m.has(entry.id))
|
|
85
|
-
m.set(entry.id, { ...entry });
|
|
58
|
+
m.set(entry.id, { ...entry });
|
|
86
59
|
}
|
|
87
60
|
async listByScope(scope, limit, beforeId) {
|
|
88
61
|
const m = this.byScope.get(scope);
|
|
@@ -95,7 +68,6 @@ export class MemorySendFileLedger {
|
|
|
95
68
|
.map((e) => ({ ...e }));
|
|
96
69
|
}
|
|
97
70
|
}
|
|
98
|
-
// ─── File twin (local lane — single append-only JSONL + boot hydrate, torn-tail-safe) ───────────────────
|
|
99
71
|
export class FileSendFileLedger {
|
|
100
72
|
path;
|
|
101
73
|
mem = new MemorySendFileLedger();
|
|
@@ -105,7 +77,6 @@ export class FileSendFileLedger {
|
|
|
105
77
|
ensureDir(dir);
|
|
106
78
|
this.path = join(dir, "links.jsonl");
|
|
107
79
|
for (const r of readJsonlRecords(this.path)) {
|
|
108
|
-
// hydrate defensively: a record without the identity triple can't be listed/revoked — skip, don't fabricate
|
|
109
80
|
if (!r || typeof r.id !== "string" || typeof r.scope !== "string" || typeof r.objectKey !== "string")
|
|
110
81
|
continue;
|
|
111
82
|
void this.mem.record(r);
|
|
@@ -113,29 +84,21 @@ export class FileSendFileLedger {
|
|
|
113
84
|
}
|
|
114
85
|
async record(entry) {
|
|
115
86
|
this.log ??= new AppendLog(this.path);
|
|
116
|
-
this.log.append(entry, true);
|
|
87
|
+
this.log.append(entry, true);
|
|
117
88
|
await this.mem.record(entry);
|
|
118
89
|
}
|
|
119
90
|
listByScope(scope, limit, beforeId) {
|
|
120
91
|
return this.mem.listByScope(scope, limit, beforeId);
|
|
121
92
|
}
|
|
122
|
-
/** Release the append-log fd (LocalBackend.close → graceful-restart re-open). */
|
|
123
93
|
dispose() {
|
|
124
94
|
try {
|
|
125
95
|
this.log?.close();
|
|
126
96
|
}
|
|
127
97
|
catch {
|
|
128
|
-
/* best-effort */
|
|
129
98
|
}
|
|
130
99
|
this.log = undefined;
|
|
131
100
|
}
|
|
132
101
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Wrap a lane `send` with the ledger write point: upload+verify succeed (the inner send resolved) → record →
|
|
135
|
-
* only then return (the tool emits the file_link frame strictly after). Record failure ⇒ fail-loud for THIS
|
|
136
|
-
* file + delete of the uploaded object with ONE retry; the throw is the contract either way, and its message
|
|
137
|
-
* states the true reclaim outcome (F2: a failed reclaim = permanent orphan, warned with coordinates).
|
|
138
|
-
*/
|
|
139
102
|
export function withLedgerRecording(send, deps) {
|
|
140
103
|
return async (path, ctx) => {
|
|
141
104
|
const link = await send(path, ctx);
|
|
@@ -155,18 +118,6 @@ export function withLedgerRecording(send, deps) {
|
|
|
155
118
|
});
|
|
156
119
|
}
|
|
157
120
|
catch (e) {
|
|
158
|
-
// Governance face MUST have the row — reclaim the object (an unledgered public object is unmanageable),
|
|
159
|
-
// then fail loud for this file. F2 (codex scope-ledger audit): the DELETE gets ONE retry (S3 DELETE is
|
|
160
|
-
// idempotent, so a transient blip degrades to a second attempt), and the thrown message reports the TRUE
|
|
161
|
-
// reclaim outcome — a failed reclaim is a PERMANENT ORPHAN (record failed + object still live) and must
|
|
162
|
-
// never be described as "reclaimed"; the warns carry the coordinates for manual cleanup.
|
|
163
|
-
// 修4 (三路复审): the THROWN message is MODEL-VISIBLE (the tool splices per-file errors into its result,
|
|
164
|
-
// and capabilities already expose the public endpoint) — bucket/key/underlying cause in it would let the
|
|
165
|
-
// model reconstruct the permanent capability URL of an object the governance face can't see. The message
|
|
166
|
-
// therefore carries only a fixed error code + a short incident id; coordinates and cause go to the
|
|
167
|
-
// OPERATOR log (warn) keyed by the same incident id.
|
|
168
|
-
// Backlog (deliberately not built here): a durable reclaim queue that retries beyond this request's
|
|
169
|
-
// lifetime — today the orphan is surfaced loudly (incident-tagged warns) and cleaned up by hand.
|
|
170
121
|
const incident = (deps.newId ? deps.newId() : uuidv7()).replace(/-/g, "").slice(-12);
|
|
171
122
|
let reclaimed = true;
|
|
172
123
|
try {
|
|
@@ -174,7 +125,7 @@ export function withLedgerRecording(send, deps) {
|
|
|
174
125
|
await deps.deleteObject(link.bucket, link.key);
|
|
175
126
|
}
|
|
176
127
|
catch {
|
|
177
|
-
await deps.deleteObject(link.bucket, link.key);
|
|
128
|
+
await deps.deleteObject(link.bucket, link.key);
|
|
178
129
|
}
|
|
179
130
|
}
|
|
180
131
|
catch (de) {
|
|
@@ -1,74 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SendUserFile — the dual-track public-link ISSUER (design: sema-deploy docs/design/S3-PUBLIC-LINKS.md,
|
|
3
|
-
* settled cross-repo 2026-07-14; tool name SendUserFile is final).
|
|
4
|
-
*
|
|
5
|
-
* The LLM hands a user a file over the web session as a URL the browser fetches DIRECTLY from S3/MinIO
|
|
6
|
-
* (server relay was rejected: double bandwidth on third-party S3 + loses CDN). Two tracks, keyed on ttl:
|
|
7
|
-
*
|
|
8
|
-
* - ttl == 0 (PERMANENT, the default): upload to the anonymous-GET public bucket under an unguessable
|
|
9
|
-
* `<scopeSeg>/<uuidv7>/<name>` key (capability URL — GET-only bucket policy, List disabled, so the prefix IS
|
|
10
|
-
* the secret; scopeSeg = hashed tenant segment, see {@link scopeSegment} — multi-tenant list/revoke handle,
|
|
11
|
-
* 1.190.0 [768]②a). URL = plain `<publicEndpoint>/<bucket>/<key>` — no signature, never expires, CDN-cacheable.
|
|
12
|
-
* Revocation = delete the object.
|
|
13
|
-
* ⚠️ Bucket-policy footgun (live-verified 2026-07-14): `mc anonymous set download` ALSO enables
|
|
14
|
-
* ListBucket — an anonymous `GET /<bucket>/?list-type=2` then enumerates every uuidv7 key and the
|
|
15
|
-
* capability design collapses. Deployments MUST use a GetObject-only JSON policy
|
|
16
|
-
* (`mc anonymous set-json` with Action=["s3:GetObject"] on `arn:aws:s3:::<bucket>/*`): GET 200, LIST 403.
|
|
17
|
-
* - 0 < ttl ≤ 7d: upload to the PRIVATE bucket and return a SigV4 presigned GET. The signature is
|
|
18
|
-
* computed against the PUBLIC endpoint (SigV4 binds Host — signing with the internal host would 403
|
|
19
|
-
* the user's browser; the reverse proxy must pass Host through).
|
|
20
|
-
* - ttl > 7d: fail-loud — SigV4 caps presign at 7 days (physical limit); the caller should use ttl=0.
|
|
21
|
-
*
|
|
22
|
-
* Uploads always go through the INTERNAL endpoint (server-side PUT, its own signed request — Host binding
|
|
23
|
-
* is per-request, so internal-for-write + public-for-read coexist). Zero-dependency posture: presignS3Url
|
|
24
|
-
* + fetch, same as MinioBlobBackend / the k8s adapter.
|
|
25
|
-
*/
|
|
26
1
|
import { createHmac } from "node:crypto";
|
|
27
2
|
import { uuidv7 } from "@sema-agent/core";
|
|
28
3
|
import { presignS3CopyUrl, presignS3Url } from "./s3-presign.js";
|
|
29
4
|
import { OBJECT_STORE_IO_TIMEOUT_MS } from "./blob-backend.js";
|
|
30
|
-
/** SigV4 physical ceiling (AWS: X-Amz-Expires max 604800s = 7 days). */
|
|
31
5
|
export const PRESIGN_MAX_TTL_SEC = 604_800;
|
|
32
|
-
/**
|
|
33
|
-
* Multi-tenant governance (clay ruling 2026-07-14, blackboard [768]② variant a with a HASHED segment): every
|
|
34
|
-
* object key carries a per-tenant SCOPE SEGMENT so tenants can be listed/revoked by prefix on the store itself,
|
|
35
|
-
* WITHOUT leaking the principal into the (user-visible, forwardable) URL. The segment is
|
|
36
|
-
* `HMAC-SHA256(secretKey, "sendfile-scope:" + scope)` hex, truncated to 32 chars:
|
|
37
|
-
* - keyed on the S3 SECRET KEY — server-held and stable, so the mapping is deterministic per deployment but
|
|
38
|
-
* unforgeable/unenumerable from outside (a URL holder cannot test principal guesses without the key);
|
|
39
|
-
* - the "sendfile-scope:" DOMAIN SEPARATOR pins this HMAC use — the same secret signs SigV4 requests, and a
|
|
40
|
-
* cross-protocol collision between "scope segment" and any other HMAC-of-attacker-controlled-string use is
|
|
41
|
-
* exactly what domain separation exists to prevent;
|
|
42
|
-
* - 32 hex chars (128 bits) is a NAMESPACE label, not a secret: the cross-tenant BIRTHDAY bound at even a
|
|
43
|
-
* billion tenants is ~n²/2^129 ≈ 2^-69 — negligible. (The earlier 16-hex/64-bit cut had a ~2.7% birthday
|
|
44
|
-
* collision probability at that scale — n²/2^65, NOT the per-pair 2^-32 the old note claimed; codex audit
|
|
45
|
-
* F4, widened pre-release at zero cost.) Capability secrecy still lives in the uuidv7 segment. Key budget
|
|
46
|
-
* stays comfortable: worst-case private-track key = 9 ("sendfile/") + 32 + 1 + 36 + 1 + 128 = 207 chars,
|
|
47
|
-
* under both sendfile_link.object_key VARCHAR(512) and S3's 1024-byte key ceiling.
|
|
48
|
-
* scope undefined/"" (single-user deployments have no principal) → the literal "_" sentinel (same sentinel the
|
|
49
|
-
* checkpoint scope column uses for anonymous), so the key SHAPE is uniform across deployments.
|
|
50
|
-
* ⚠️ Objects issued before 1.190.0 have NO scope segment (`<uuidv7>/<name>`); they stay valid — the segment is
|
|
51
|
-
* purely additive (URLs are capability-complete either way), zero migration.
|
|
52
|
-
*/
|
|
53
6
|
export function scopeSegment(secretKey, scope) {
|
|
54
7
|
if (scope === undefined || scope === "")
|
|
55
8
|
return "_";
|
|
56
9
|
return createHmac("sha256", secretKey).update(`sendfile-scope:${scope}`).digest("hex").slice(0, 32);
|
|
57
10
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Collapse an arbitrary client-supplied name to ONE safe path segment: strip directories (both
|
|
60
|
-
* separators), control/bidi characters (the session-title sanitize class), leading dots (no hidden
|
|
61
|
-
* files / no "." ".."), and cap the length (S3 key segment budget; keep the extension when truncating).
|
|
62
|
-
* Empty in ⇒ "file".
|
|
63
|
-
*/
|
|
64
11
|
export function sanitizeSendFileName(name) {
|
|
65
12
|
const base = (name ?? "").split(/[/\\]/).pop() ?? "";
|
|
66
13
|
let clean = base.replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").replace(/^\.+/, "").trim();
|
|
67
14
|
if (clean.length > 128) {
|
|
68
15
|
const dot = clean.lastIndexOf(".");
|
|
69
16
|
const ext = dot > 0 && clean.length - dot <= 16 ? clean.slice(dot) : "";
|
|
70
|
-
// code-point 安全截断:UTF-16 slice 会切断代理对,留下 lone surrogate → encodeURIComponent 抛
|
|
71
|
-
// URIError(此时对象已上传,链接却铸不出来)。按 code point 截,预算=128-ext 的 UTF-16 units。
|
|
72
17
|
const budget = 128 - ext.length;
|
|
73
18
|
let head = "";
|
|
74
19
|
for (const ch of clean) {
|
|
@@ -81,8 +26,6 @@ export function sanitizeSendFileName(name) {
|
|
|
81
26
|
return clean.length > 0 ? clean : "file";
|
|
82
27
|
}
|
|
83
28
|
export function createSendUserFileIssuer(cfg, deps) {
|
|
84
|
-
// C3:六站点统一 abort 上界(半开 S3 端点此前把用户可见的工具调用挂满 undici ~300s,
|
|
85
|
-
// reclaim 也挂住 ⇒ staging 孤儿+误报「reclaim FAILED」)。上界常量与 blob-backend 同源。
|
|
86
29
|
const f = deps?.fetchImpl ?? fetch;
|
|
87
30
|
const bounded = () => ({ signal: AbortSignal.timeout(OBJECT_STORE_IO_TIMEOUT_MS) });
|
|
88
31
|
const upload = deps?.upload ??
|
|
@@ -111,11 +54,9 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
111
54
|
throw new Error(`SendUserFile ttl must be a non-negative INTEGER of seconds (0 = permanent; SigV4 X-Amz-Expires is integer-only); got ${ttl}`);
|
|
112
55
|
const name = sanitizeSendFileName(filename);
|
|
113
56
|
const publicBase = cfg.publicEndpoint.replace(/\/+$/, "");
|
|
114
|
-
const seg = scopeSegment(cfg.secretKey, scope);
|
|
57
|
+
const seg = scopeSegment(cfg.secretKey, scope);
|
|
115
58
|
const scopeField = scope !== undefined && scope !== "" ? { scope } : {};
|
|
116
59
|
if (ttl === 0) {
|
|
117
|
-
// Permanent track: capability URL on the anonymous-GET bucket. Key = <scopeSeg>/<uuidv7>/<name>
|
|
118
|
-
// (pre-1.190 objects have no scope segment — additive, both shapes stay valid).
|
|
119
60
|
const key = `${seg}/${uuidv7()}/${name}`;
|
|
120
61
|
await upload(cfg.publicBucket, key, bytes);
|
|
121
62
|
const url = `${publicBase}/${cfg.publicBucket}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
@@ -124,7 +65,6 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
124
65
|
if (ttl > PRESIGN_MAX_TTL_SEC) {
|
|
125
66
|
throw new Error(`SendUserFile ttl ${ttl}s exceeds the SigV4 presign ceiling (${PRESIGN_MAX_TTL_SEC}s = 7 days) — use ttl=0 for a permanent link`);
|
|
126
67
|
}
|
|
127
|
-
// Presigned track: private bucket upload, GET signed against the PUBLIC endpoint (Host-bound).
|
|
128
68
|
const key = `${cfg.privateKeyPrefix}${seg}/${uuidv7()}/${name}`;
|
|
129
69
|
await upload(cfg.privateBucket, key, bytes);
|
|
130
70
|
const url = presignS3Url({
|
|
@@ -140,9 +80,6 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
140
80
|
});
|
|
141
81
|
return { url, filename: name, size: bytes.byteLength, ttlSec: ttl, track: "presigned", bucket: cfg.privateBucket, key, ...scopeField };
|
|
142
82
|
};
|
|
143
|
-
// v2 (sandbox direct-upload): same key/track/URL algebra as issue(), split into sign-then-finalize so
|
|
144
|
-
// the BYTES move sandbox→S3 directly (server never relays; creds never enter the sandbox — only a
|
|
145
|
-
// single-object, method-bound, 120s PUT capability on a STAGING key does; see PreparedDirectUpload).
|
|
146
83
|
const prepareDirectUpload = (filename, ttlSec, scope) => {
|
|
147
84
|
if (cfg.publicEndpoint === undefined) {
|
|
148
85
|
throw new Error("SendUserFile is not configured for public links: set S3_PUBLIC_ENDPOINT (the S3/MinIO base URL users can reach; falls back to S3_ENDPOINT when that is already public)");
|
|
@@ -157,21 +94,11 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
157
94
|
const publicBase = cfg.publicEndpoint.replace(/\/+$/, "");
|
|
158
95
|
const putBase = (cfg.sandboxPutEndpoint ?? cfg.publicEndpoint).replace(/\/+$/, "");
|
|
159
96
|
const bucket = ttl === 0 ? cfg.publicBucket : cfg.privateBucket;
|
|
160
|
-
// Same key algebra as issue() incl. the hashed tenant segment — prepare/finalize share ONE key, so the
|
|
161
|
-
// segment is fixed here (the PUT capability is already scoped to it).
|
|
162
97
|
const seg = scopeSegment(cfg.secretKey, scope);
|
|
163
98
|
const scopeField = scope !== undefined && scope !== "" ? { scope } : {};
|
|
164
99
|
const key = ttl === 0 ? `${seg}/${uuidv7()}/${name}` : `${cfg.privateKeyPrefix}${seg}/${uuidv7()}/${name}`;
|
|
165
|
-
// 修3 staging two-phase: the sandbox-visible PUT capability is scoped to a STAGING key in the PRIVATE
|
|
166
|
-
// bucket (never anonymous-readable, never handed out as a URL) — the final key above gets its bytes via
|
|
167
|
-
// a server-side CopyObject only, so no /proc reader of the exec argv can ever rewrite what the user's
|
|
168
|
-
// link points at. The staging key keeps the tenant segment (orphan attribution on manual cleanup).
|
|
169
100
|
const stagingBucket = cfg.privateBucket;
|
|
170
101
|
const stagingKey = `${cfg.privateKeyPrefix}staging/${seg}/${uuidv7()}`;
|
|
171
|
-
// TTL 120s, not the uploader default 600: the URL rides the sandbox exec argv (visible to the sandbox's
|
|
172
|
-
// own processes / possibly an apiserver audit log) — anyone who reads it gains nothing they don't already
|
|
173
|
-
// have DURING the upload (the sandbox owns the file), and since 修3 the writable object is only the
|
|
174
|
-
// staging key anyway. Keep the window as short as a slow 100MiB upload allows.
|
|
175
102
|
const putUrl = presignS3Url({
|
|
176
103
|
endpoint: putBase,
|
|
177
104
|
bucket: stagingBucket,
|
|
@@ -196,21 +123,16 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
196
123
|
...(cfg.region !== undefined ? { region: cfg.region } : {}),
|
|
197
124
|
expiresSec: 60,
|
|
198
125
|
});
|
|
199
|
-
// 修2: best-effort reclaim — any failure AFTER the object landed must delete it before throwing
|
|
200
|
-
// (a transient HEAD 5xx used to strand an unledgered, undelivered orphan in the bucket). Returns the
|
|
201
|
-
// TRUE outcome so error messages never claim "reclaimed" for a delete that itself failed (F2 posture).
|
|
202
126
|
const reclaim = async (b, k) => {
|
|
203
127
|
try {
|
|
204
128
|
const r = await f(sign("DELETE", b, k), { method: "DELETE", ...bounded() });
|
|
205
|
-
return r.ok || r.status === 404;
|
|
129
|
+
return r.ok || r.status === 404;
|
|
206
130
|
}
|
|
207
131
|
catch {
|
|
208
132
|
return false;
|
|
209
133
|
}
|
|
210
134
|
};
|
|
211
135
|
const outcome = (ok, what) => (ok ? `${what} reclaimed` : `${what} reclaim FAILED (orphan; operator cleanup needed)`);
|
|
212
|
-
// ① HEAD the STAGING object (internal endpoint, adapter creds): existence + a cheap early cap gate
|
|
213
|
-
// (fail before copying a huge object). Advisory only for size — the authoritative reading is ④.
|
|
214
136
|
let head;
|
|
215
137
|
try {
|
|
216
138
|
head = await f(sign("HEAD", stagingBucket, stagingKey), { method: "HEAD", ...bounded() });
|
|
@@ -222,7 +144,7 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
222
144
|
if (head.status === 404)
|
|
223
145
|
throw new Error("SendUserFile upload verification failed: object not found after upload (HEAD 404)");
|
|
224
146
|
if (!head.ok) {
|
|
225
|
-
const r = await reclaim(stagingBucket, stagingKey);
|
|
147
|
+
const r = await reclaim(stagingBucket, stagingKey);
|
|
226
148
|
throw new Error(`SendUserFile upload verification failed: store error (HEAD ${head.status}) — ${outcome(r, "staged object")}`);
|
|
227
149
|
}
|
|
228
150
|
const staged = Number.parseInt(head.headers.get("content-length") ?? "", 10);
|
|
@@ -234,9 +156,6 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
234
156
|
await reclaim(stagingBucket, stagingKey);
|
|
235
157
|
throw new Error(`file exceeds the ${maxBytes >= 1024 * 1024 ? `${Math.floor(maxBytes / (1024 * 1024))} MiB` : `${maxBytes} byte`} per-file cap (uploaded object was ${staged} bytes — deleted)`);
|
|
236
158
|
}
|
|
237
|
-
// ② Server-side CopyObject staging→final (internal endpoint, signed x-amz-copy-source): the ONLY
|
|
238
|
-
// write the final key ever receives. NB: S3 can return 200 with an error BODY on slow copies — require
|
|
239
|
-
// the CopyObjectResult marker, not just the status.
|
|
240
159
|
const copy = presignS3CopyUrl({
|
|
241
160
|
endpoint: cfg.endpoint,
|
|
242
161
|
bucket,
|
|
@@ -257,15 +176,10 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
257
176
|
}
|
|
258
177
|
catch (e) {
|
|
259
178
|
const r1 = await reclaim(stagingBucket, stagingKey);
|
|
260
|
-
const r2 = await reclaim(bucket, key);
|
|
179
|
+
const r2 = await reclaim(bucket, key);
|
|
261
180
|
throw new Error(`SendUserFile upload verification failed: staging promotion failed (${e instanceof Error ? e.message : String(e)}) — ${outcome(r1 && r2, "uploaded object")}`);
|
|
262
181
|
}
|
|
263
|
-
// ③ Staging object served its purpose — reclaim (best-effort: a leftover is a private-bucket blob
|
|
264
|
-
// whose write capability expires in ≤120s; it can't become a public link).
|
|
265
182
|
await reclaim(stagingBucket, stagingKey);
|
|
266
|
-
// ④ Authoritative HEAD of the FINAL object — immutable to the sandbox, so THIS size is what the copy
|
|
267
|
-
// pinned and what the card must carry (re-checked against the cap: a /proc reader could have re-PUT
|
|
268
|
-
// staging between ① and ②, swapping in different bytes).
|
|
269
183
|
let fin;
|
|
270
184
|
try {
|
|
271
185
|
fin = await f(sign("HEAD", bucket, key), { method: "HEAD", ...bounded() });
|
|
@@ -309,10 +223,6 @@ export function createSendUserFileIssuer(cfg, deps) {
|
|
|
309
223
|
};
|
|
310
224
|
return { putUrl, verifySize, finalize };
|
|
311
225
|
};
|
|
312
|
-
// Ledger reclaim (fail-loud record path): delete an already-uploaded object whose ledger record failed —
|
|
313
|
-
// a public-track object with no ledger row would be unmanageable (no per-tenant list/revoke handle). Signed
|
|
314
|
-
// DELETE against the INTERNAL endpoint (adapter creds, same posture as verifySize). S3 DELETE is idempotent
|
|
315
|
-
// (204 on a missing key), so callers may retry safely.
|
|
316
226
|
const deleteObject = async (bucket, key) => {
|
|
317
227
|
if (deps?.remove)
|
|
318
228
|
return deps.remove(bucket, key);
|