@edgehero/pi-dispatch 1.10.2 → 2.0.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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +32 -5
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +387 -17
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1395 -268
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Which of a provider's credential variables is the one to write, and to name (issue #311).
3
+ *
4
+ * pi's list for a provider is pi's to give: `providerKeyCandidates` in env-allowlist.mjs recovers it from
5
+ * `findEnvKeys` and no copy of it exists anywhere. What pi's list does NOT say is which of its entries is a
6
+ * subscription login rather than a service credential, and that distinction is needed in two places at once:
7
+ * `doctor` has to NAME a variable for an operator to set, and the worker has to CHOOSE one to inject an
8
+ * `auth.json` api-key login under. Those two must never answer differently -- a doctor line naming a
9
+ * variable the worker does not write is exactly the drift issue #286 was about.
10
+ *
11
+ * A SEPARATE MODULE with no imports at all, on the reserved-env.mjs precedent, because the import
12
+ * direction rules out both of the obvious homes. `env-allowlist.mjs` cannot import `doctor.mjs`: that
13
+ * would drag doctor's whole graph into the job path to read one regex. `doctor.mjs` cannot statically
14
+ * import `env-allowlist.mjs` either -- it reaches it through a DYNAMIC import on purpose, so that pi is
15
+ * not loaded before doctor's own Node-floor check has run and printed. A third module with no
16
+ * dependencies is the only shape that lets both have this for free.
17
+ */
18
+
19
+ // The ONE credential fact this project holds itself, and it has to hold one: this is a statement ABOUT a
20
+ // credential that must NOT be used, so it cannot come from pi's table of credentials that DO work --
21
+ // that table says which variables pi reads, never which of them is a subscription login.
22
+ // Checked and rejected: pi's provider descriptors carry an `auth.oauth` block, but it is per-PROVIDER,
23
+ // not per-variable -- `github-copilot` has one and exactly ONE key variable, so "this provider supports
24
+ // oauth" cannot name WHICH variable is the token.
25
+ // A suffix rule rather than a one-name set, because the expensive direction is the false green: the day
26
+ // pi adds a second provider's OAuth variable a set would silently bless it. Pinned against pi in
27
+ // worker/test/provider-key.test.mjs -- never against a second copy of a table.
28
+ export const OAUTH_KEY_RE = /_OAUTH_TOKEN$/;
29
+
30
+ /**
31
+ * The api-key variable to write and to name, given pi's candidate list for a provider in pi's own
32
+ * precedence order. Never the OAuth token, whatever that precedence says: pi returns
33
+ * ANTHROPIC_OAUTH_TOKEN first, "set your subscription login" is wrong advice for an unattended service,
34
+ * and an api-key credential written under that name would be a value whose variable lies about what it
35
+ * is. Falls back to the first candidate only for a provider with no non-OAuth variable at all, which is
36
+ * no provider pi has today; `null` for a provider pi reads no key variable for, which is the caller's
37
+ * cue to refuse rather than to guess.
38
+ */
39
+ export function apiKeyVariable(candidates) {
40
+ return candidates.find((name) => !OAUTH_KEY_RE.test(name)) ?? candidates[0] ?? null;
41
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The environment variables pi and its provider SDKs read to CONFIGURE a provider: where the request goes,
3
+ * and which credentials it carries (issue #314).
4
+ *
5
+ * WHY THIS IS A REFUSAL. `run.secrets` lets a trigger name an environment variable, and the existing gates
6
+ * refuse the names the worker writes and the ones pi reads the resolved provider's KEY from. They refuse
7
+ * nothing that says where the request goes, and that is the worse of the two: substitution spends the
8
+ * trigger author's money, redirection sends the OPERATOR'S credential to a host the trigger chose.
9
+ *
10
+ * Measured against the 0.80.7 pin with a stubbed fetch and no real key, three providers, three routes:
11
+ * AZURE_OPENAI_BASE_URL -> the request goes to the named host carrying `api-key`
12
+ * GOOGLE_GEMINI_BASE_URL -> ... carrying `x-goog-api-key`
13
+ * AWS_ENDPOINT_URL -> ... carrying a SigV4 signature over the operator's Bedrock credential
14
+ *
15
+ * The azure case is not even a shadowed default: every azure model ships `baseUrl: ""`, so there the
16
+ * environment is the PRIMARY source, ahead of the resource name and ahead of the model.
17
+ *
18
+ * WHAT THIS SET DOES NOT CONTAIN, and the reason is the whole shape of the design: **a provider's KEY
19
+ * variables**. Those are `providerKeyCandidates`' business, refused PRE-SPEND against the job's own
20
+ * resolved provider, and the bound that gate keeps is deliberate and documented -- an `anthropic` job may
21
+ * bind `OPENAI_API_KEY` for a flow that talks to OpenAI itself, because refusing it would be this project
22
+ * claiming a namespace it does not own. Including them here would have broken that bound ARBITRARILY: only
23
+ * four of pi's thirty-one key variables happen to appear as literals in a scanned artifact, so
24
+ * `OPENAI_API_KEY` would refuse while `GROQ_API_KEY` and `HF_TOKEN` stayed bindable, and the documented
25
+ * rule would be false for reasons no operator could predict. They are subtracted, and the bolt subtracts
26
+ * them the same way rather than by hand.
27
+ * The AWS credential variables STAY, and they are not an exception: pi's key table has no entry for
28
+ * `amazon-bedrock` at all (`providerKeyCandidates("amazon-bedrock")` is empty), so nothing else reserves
29
+ * them and they are read by the SDK as provider configuration, which is exactly what this set is.
30
+ *
31
+ * DERIVED, AND THE DERIVATION IS BOLTED IN BOTH DIRECTIONS by `worker/test/provider-steering.test.mjs`,
32
+ * from the pinned artifacts rather than from a second copy of them: pi's own `dist`, plus every package pi
33
+ * imports a client from, discovered from pi's own import statements rather than from a list here -- so a
34
+ * pi bump that adds an SDK fails the bolt too. Four accessor spellings are matched, because the SDKs do
35
+ * not agree on one: `getProviderEnvValue`, `readEnv`, `getEnv` and `process.env["NAME"]`, the last of
36
+ * which is the only way `AZURE_OPENAI_ENDPOINT` is read.
37
+ *
38
+ * That derivation is why the set holds names nobody would have written down.
39
+ * `AWS_CONTAINER_CREDENTIALS_FULL_URI` makes the AWS SDK fetch credentials from a URL of the trigger's
40
+ * choosing, `AWS_WEB_IDENTITY_TOKEN_FILE` and `GOOGLE_APPLICATION_CREDENTIALS` are paths to credential
41
+ * files, `AWS_BEDROCK_SKIP_AUTH` is an auth bypass, and `GOOGLE_GENAI_USE_VERTEXAI` moves the request to a
42
+ * different Google product.
43
+ *
44
+ * THREE LIMITS, stated because a set like this is only worth what its boundary is honest about.
45
+ *
46
+ * The scan is ONE `node_modules` HOP DEEP: pi's own dist and the packages pi imports directly. It does not
47
+ * follow those packages' dependencies, and the AWS variables are read one level further in, inside
48
+ * `@smithy/core`. The names measured to matter from that layer are in the residual list below; the rest of
49
+ * that closure (`AWS_EC2_METADATA_SERVICE_ENDPOINT`, `AWS_ROLE_ARN`, `GCE_METADATA_HOST` and some forty
50
+ * more) is NOT covered. Recursing the whole closure would reserve most of the AWS and Google SDK surface
51
+ * and take a large bite out of what an operator may legitimately bind, so the boundary is deliberate.
52
+ *
53
+ * `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `NODE_TLS_REJECT_UNAUTHORIZED` are NOT here.
54
+ * They subvert any provider call, but they are properties of the RUNTIME rather than of a provider, they
55
+ * predate this gate, and `NODE_OPTIONS` additionally needs a file the attacker can place. They belong to
56
+ * whatever closes the runtime-hijack question, not to a set derived from what pi reads about providers.
57
+ *
58
+ * And a bound on the whole family, which no document here stated before: a trigger author picks the
59
+ * variable NAME and a vault REFERENCE, never a value. Exploiting any of these needs the operator's own
60
+ * vault to hold a useful string at a reference the author is allowed to name. That is equally true of
61
+ * `AZURE_OPENAI_BASE_URL`, the variable this issue was filed about, so it bounds the severity of the whole
62
+ * set rather than distinguishing parts of it.
63
+ *
64
+ * IMPORT-FREE, like `reserved-env.mjs` and `provider-key.mjs` beside it: `triggers.mjs` is the shared
65
+ * validator, the receiver loads it, and `admin/build.mjs` inlines it into the published console.
66
+ */
67
+
68
+
69
+ /**
70
+ * The steering variables a literal scan of the packages pi imports cannot reach.
71
+ *
72
+ * Named rather than quietly absent, because "derived, never curated" would otherwise be a claim the bolt
73
+ * cannot keep. Two reasons they are unreachable, and the test asserts BOTH still hold.
74
+ *
75
+ * The AWS four are read inside `@smithy/core`, one dependency hop past this scan's boundary, and two of
76
+ * them are additionally read through a key the resolver builds at runtime
77
+ * (`AWS_ENDPOINT_URL_<SERVICEID>`). They matter because pi stops pinning the Bedrock endpoint itself as
78
+ * soon as `AWS_REGION` or `AWS_PROFILE` is present, which is the ordinary way to configure Bedrock. Both
79
+ * measured: `AWS_ENDPOINT_URL` redirects the call, and `AWS_SHARED_CREDENTIALS_FILE` replaces the
80
+ * credential it is signed with. `AWS_CONFIG_FILE` does both, and can also name a `credential_process`
81
+ * shell command.
82
+ *
83
+ * The proxy spellings are read by pi's own `getProxyEnv`, which lowercases and uppercases the key it is
84
+ * given and asks for both. `EGRESS_ENV_VARS` owns `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` and keeps
85
+ * them; what is added here is the spellings it does not have. pi reads the LOWERCASE form FIRST, so
86
+ * `https_proxy` outranks the egress policy's own variable in pi's reader. Only the schemes a provider call
87
+ * can use are listed: `ws_proxy` and the rest are reachable in `getProxyEnv` but not from an HTTPS request.
88
+ */
89
+ const UNREACHABLE_BY_SCAN = [
90
+ "AWS_CONFIG_FILE",
91
+ "AWS_ENDPOINT_URL",
92
+ "AWS_ENDPOINT_URL_BEDROCK_RUNTIME",
93
+ "AWS_SHARED_CREDENTIALS_FILE",
94
+ "ALL_PROXY",
95
+ "all_proxy",
96
+ "http_proxy",
97
+ "https_proxy",
98
+ "no_proxy",
99
+ ];
100
+
101
+ export const PROVIDER_STEERING_VARS = new Set([
102
+ "ANTHROPIC_AUTH_TOKEN",
103
+ "ANTHROPIC_BASE_URL",
104
+ "ANTHROPIC_LOG",
105
+ "AWS_ACCESS_KEY_ID",
106
+ "AWS_BEARER_TOKEN_BEDROCK",
107
+ "AWS_BEDROCK_FORCE_CACHE",
108
+ "AWS_BEDROCK_FORCE_HTTP1",
109
+ "AWS_BEDROCK_SKIP_AUTH",
110
+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
111
+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
112
+ "AWS_DEFAULT_REGION",
113
+ "AWS_PROFILE",
114
+ "AWS_REGION",
115
+ "AWS_SECRET_ACCESS_KEY",
116
+ "AWS_SESSION_TOKEN",
117
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
118
+ "AZURE_OPENAI_API_VERSION",
119
+ "AZURE_OPENAI_BASE_URL",
120
+ "AZURE_OPENAI_DEPLOYMENT_NAME_MAP",
121
+ "AZURE_OPENAI_ENDPOINT",
122
+ "AZURE_OPENAI_RESOURCE_NAME",
123
+ "GCLOUD_PROJECT",
124
+ "GEMINI_NEXT_GEN_API_BASE_URL",
125
+ "GEMINI_NEXT_GEN_API_LOG",
126
+ "GOOGLE_API_KEY",
127
+ "GOOGLE_APPLICATION_CREDENTIALS",
128
+ "GOOGLE_CLOUD_LOCATION",
129
+ "GOOGLE_CLOUD_PROJECT",
130
+ "GOOGLE_GEMINI_BASE_URL",
131
+ "GOOGLE_GENAI_USE_ENTERPRISE",
132
+ "GOOGLE_GENAI_USE_VERTEXAI",
133
+ "GOOGLE_VERTEX_BASE_URL",
134
+ "OPENAI_API_VERSION",
135
+ "OPENAI_BASE_URL",
136
+ "OPENAI_LOG",
137
+ "OPENAI_ORG_ID",
138
+ "OPENAI_PROJECT_ID",
139
+ "OPENAI_WEBHOOK_SECRET",
140
+ "PI_CACHE_RETENTION",
141
+ "PI_GATEWAY",
142
+ "PI_OAUTH_CALLBACK_HOST",
143
+ ...UNREACHABLE_BY_SCAN,
144
+ ]);
package/src/queue.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Queue } from "bullmq";
2
+ import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
2
3
  import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId } from "./job-id.mjs";
3
4
  import { targetSeparator } from "./forges.mjs";
4
5
  import { PR_CLOSE_ACTIONS } from "./triggers.mjs";
@@ -68,7 +69,12 @@ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeli
68
69
  * single-host deployment never names anything else.
69
70
  */
70
71
  export function makeQueue(connection, { name = QUEUE } = {}) {
71
- return new Queue(name, { connection });
72
+ // Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials.
73
+ assertJudgedConnection(connection);
74
+ const queue = new Queue(name, { connection });
75
+ // Issue #468: an error of this queue is one line, its message, never BullMQ's console.error of the whole object.
76
+ onValkeyError(queue, `queue ${name}`);
77
+ return queue;
72
78
  }
73
79
 
74
80
  /**
@@ -78,7 +84,7 @@ export function makeQueue(connection, { name = QUEUE } = {}) {
78
84
  * removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
79
85
  * redelivered, so a modest window is enough.
80
86
  */
81
- export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = new Date() }) {
87
+ export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, excludeTools, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = new Date() }) {
82
88
  const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
83
89
  // A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
84
90
  // minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
@@ -110,6 +116,10 @@ export async function enqueueLocalJob(queue, { folder, flow, task, command, prov
110
116
  // there becomes agent-visible input, and where the box was built is the worker's business rather
111
117
  // than the agent's.
112
118
  ...(backend !== undefined && { backend }),
119
+ // #291. WHAT the session's agent may not do. Conditional like `backend`, and at JOB level for the
120
+ // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
121
+ // worker's business, never agent-visible input to reason about.
122
+ ...(excludeTools !== undefined && { excludeTools }),
113
123
  // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
114
124
  // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
115
125
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
@@ -138,9 +148,13 @@ export async function enqueueLocalJob(queue, { folder, flow, task, command, prov
138
148
  const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
139
149
 
140
150
  /**
141
- * Enqueue a GitHub-triggered job. Returns the jobId. The data shape is what prepare/runJob consumes
142
- * for the github kind. No `sha` field: the commit is resolved fresh in prepare (C1), so baking a
143
- * possibly-stale sha here would only race the branch head.
151
+ * Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
152
+ * per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
153
+ * layer's replays are deliberately invisible, see the comparison below). `enqueueLocalJob` keeps its
154
+ * plain string return: it carries no `deduplication` option, so there is nothing the comparison could
155
+ * see, and its return is consumed as a bare id by the CLI and the outbox. The data shape is what
156
+ * prepare/runJob consumes for the github kind. No `sha` field: the commit is resolved fresh in prepare
157
+ * (C1), so baking a possibly-stale sha here would only race the branch head.
144
158
  *
145
159
  * `target` is the discriminated subject of the job -- `{ type:"issue"|"pull_request", number, title,
146
160
  * body, ... }` -- built by the receiver's filter from the INT-WEBHOOK-PAYLOAD-SUBSET fields. Its `number`
@@ -197,7 +211,7 @@ export async function enqueueGitLabJob(queue, fields) {
197
211
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
198
212
  * has always been.
199
213
  */
200
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
214
+ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, backend, excludeTools, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
201
215
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
202
216
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
203
217
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -231,6 +245,10 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
231
245
  // there becomes agent-visible input, and where the box was built is the worker's business rather
232
246
  // than the agent's.
233
247
  ...(backend !== undefined && { backend }),
248
+ // #291. WHAT the session's agent may not do. Conditional like `backend`, and at JOB level for the
249
+ // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
250
+ // worker's business, never agent-visible input to reason about.
251
+ ...(excludeTools !== undefined && { excludeTools }),
234
252
  // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
235
253
  // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
236
254
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
@@ -279,7 +297,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
279
297
  // narrow to the matched action word, like the PR half already does.
280
298
  const isCloseJob = matched?.type === "issue" || (matched?.type === "pull_request" && PR_CLOSE_WORDS.has(matched?.action));
281
299
  const flowSlot = `${isCloseJob ? "closed:" : ""}${command !== undefined ? `cmd:${command}` : flow}`;
282
- await queue.add(kind, data, {
300
+ const added = await queue.add(kind, data, {
283
301
  jobId,
284
302
  // A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
285
303
  // trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
@@ -293,7 +311,16 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
293
311
  removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
294
312
  removeOnFail: { age: 31 * 24 * 3600 },
295
313
  });
296
- return jobId;
314
+ // Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
315
+ // of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
316
+ // REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
317
+ // because a forge retry of a delivery that IS queued deserves the answer "queued" -- while the
318
+ // `deduplication` option (the 10-minute semantic window) returns the EXISTING job's DIFFERENT id.
319
+ // So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
320
+ // the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
321
+ // predating the comparison) reads as not-deduplicated, today's behaviour exactly.
322
+ if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id };
323
+ return { jobId, deduplicated: false };
297
324
  }
298
325
 
299
326
  /**
package/src/redact.mjs ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Take credentials out of a subprocess's own words, so a fault line can carry them (issue #339).
3
+ *
4
+ * IMPORT-FREE, and it has to stay that way. `backend-registry.mjs` and `retention-sweep.mjs` import nothing
5
+ * at all today, and this is their first import; a heavy one would pull the docker adapter into every graph
6
+ * that reaches `reapAll`. Same shape as `transient.mjs`, which says the same thing about itself.
7
+ *
8
+ * WHY A SCRUBBER AND NOT A FIXED TOKEN, which is the other shape this repo already uses
9
+ * (`classifyEndpointFailure` classifies on values and never reads stderr). The twelve call sites are not one
10
+ * kind of error. Five carry filesystem faults where the message IS the diagnosis (`EACCES: permission
11
+ * denied, scandir '/var/lib/...'`), and two exist to catch a throwing FACTORY, where `makeSandboxReaperFn is
12
+ * not a function` is the entire value of the line. A token would delete the diagnosis at most sites to
13
+ * protect three. A byte COUNTER (`secrets.mjs`) is right where the text is always secret-adjacent; here it
14
+ * is usually not.
15
+ *
16
+ * THE RULE IS THE URL GRAMMAR, never any runtime's prose, so Podman's different wording and its own
17
+ * connection URIs (`ssh://user@host:22/run/user/1000/podman/podman.sock`) are covered by construction.
18
+ * Nothing here mentions docker, a daemon, a context or an exit code.
19
+ *
20
+ * WHAT IT DOES NOT CATCH, stated rather than implied. A credential holding whitespace or a bare quote that
21
+ * the runtime printed UNQUOTED: both docker and podman print an unparseable endpoint through Go's `%q`,
22
+ * which quotes and escapes it, and the quoted rule covers that exactly. Closing the unquoted case needs a
23
+ * rule that crosses whitespace, and such a rule eats the useful half of every line holding an unrelated `@`.
24
+ * It also does not catch a secret that is not in a userinfo position: a bearer token echoed as a bare word,
25
+ * a registry auth blob, the contents of a key. And it deliberately keeps the HOST, which is the fact an
26
+ * operator needs and is never credential material.
27
+ */
28
+
29
+ /**
30
+ * `scheme://userinfo@host` inside a Go `%q`-quoted region, where `\X` is an escape rather than a delimiter.
31
+ * Both runtimes print an endpoint they could not parse this way, and it is the only form that survives a
32
+ * credential containing whitespace or a quote.
33
+ */
34
+ const QUOTED_USERINFO = /"([a-z][a-z0-9+.-]*:\/\/)?(?:[^"\\\n]|\\.)*@/gi;
35
+ /** The same, unquoted: a run of non-delimiter characters up to and including the LAST `@` in it. */
36
+ const BARE_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)?[^\s"'`<>]*@/gi;
37
+ /** The account segment of a home path, which an identity-file error carries (no-pii-in-logs). */
38
+ const POSIX_HOME = /(\/(?:home|Users)\/)[^/\s"'`<>:,;]+/g;
39
+ const WINDOWS_HOME = /([A-Za-z]:\\Users\\)[^\\\s"'`<>,;]+/g;
40
+
41
+ /**
42
+ * The cap. Applied AFTER scrubbing, never before: a cut that lands inside a credential leaves its prefix,
43
+ * which is the whole failure mode this file exists to prevent.
44
+ */
45
+ export const SCRUBBED_MAX = 300;
46
+
47
+ /** Any `"..."` region, so a fragment repeated outside its URL can be checked against what was just taken. */
48
+ const QUOTED_REGION = /"((?:[^"\\\n]|\\.)*)"/g;
49
+ /** Below this, a fragment is too short to redact elsewhere without eating the diagnosis around it. */
50
+ const MIN_ECHOED = 2;
51
+
52
+ /**
53
+ * A subprocess's message with anything in a userinfo position, and any home-directory account, replaced.
54
+ *
55
+ * TWO PASSES, because one is not enough against a MEASURED error. Go's URL parser quotes the offending
56
+ * fragment a second time in its own words: `parse "ssh://bob:pa?ss@remote": invalid port ":pa" after host`.
57
+ * The first pass takes the userinfo inside the URL and leaves `":pa"` standing beside it. So whatever the
58
+ * first pass removed is treated as a known secret for the rest of the string, and any QUOTED region that is
59
+ * a substring of it goes too.
60
+ *
61
+ * QUOTED regions only, and a length floor, because the safe direction here has a cost: a one-character
62
+ * password would otherwise redact every occurrence of that character and destroy the diagnosis this file
63
+ * exists to preserve. A fragment shorter than two characters, or one echoed UNQUOTED, survives -- stated
64
+ * rather than claimed away, and neither runtime is known to echo one that way.
65
+ */
66
+ export function scrubCredentials(value) {
67
+ if (typeof value !== "string" || value === "") return "unknown";
68
+ const taken = [];
69
+ const remember = (m, scheme) => {
70
+ taken.push(m);
71
+ return `${scheme ?? ""}[redacted]@`;
72
+ };
73
+ let out = value
74
+ .replace(QUOTED_USERINFO, (m, scheme) => `"${remember(m.slice(1), scheme)}`)
75
+ .replace(BARE_USERINFO, remember)
76
+ .replace(POSIX_HOME, "$1[redacted]")
77
+ .replace(WINDOWS_HOME, "$1[redacted]");
78
+ if (taken.length > 0) {
79
+ out = out.replace(QUOTED_REGION, (m, inner) =>
80
+ inner.length >= MIN_ECHOED && taken.some((t) => t.includes(inner)) ? '"[redacted]"' : m,
81
+ );
82
+ }
83
+ return out.length > SCRUBBED_MAX ? `${out.slice(0, SCRUBBED_MAX)}\u2026` : out;
84
+ }
@@ -13,9 +13,9 @@
13
13
  * have it for free.
14
14
  *
15
15
  * Only the STATIC names live here. The rest of the closed map is deployment state and cannot be known
16
- * from a triggers file at all: the provider credential's variable names come from `findEnvKeys(provider,
17
- * hostEnv)`, and `PI_FORWARD_ENV` is an operator env list. Those two are refused PRE-SPEND, in the
18
- * processor, where the resolved provider and the host env are both in hand. `MINTED_TOKEN_VARS` and
16
+ * from a triggers file at all: the provider's credential variable names come from `providerKeyCandidates`
17
+ * once the job's provider is resolved, and `PI_FORWARD_ENV` is an operator env list. Those two are refused
18
+ * PRE-SPEND, in the processor, where the resolved provider and the operator's forward list are in hand. `MINTED_TOKEN_VARS` and
19
19
  * `FORGE_HOST_VARS` (forges.mjs) and `EGRESS_ENV_VARS`/`WORKER_ONLY_SECRET_VARS` (config.mjs) stay in
20
20
  * their own modules and are imported by the validator beside this one, never copied into it.
21
21
  *
@@ -33,8 +33,12 @@ export const CONTAINER_ENV_NAMES = new Set([
33
33
  "PI_SESSION_FILE",
34
34
  "PI_FLOW",
35
35
  "PI_COMMAND",
36
+ "PI_EXCLUDE_TOOLS",
36
37
  "PI_OFFLINE",
37
38
  "PLAYWRIGHT_BROWSERS_PATH",
38
39
  "PLAYWRIGHT_MCP_BROWSER",
39
40
  "PLAYWRIGHT_MCP_SANDBOX",
41
+ // Issue #341: set beside `--user` so a uid with no passwd entry has a writable home. A new reservation, so a
42
+ // triggers file binding a secret named HOME is now refused at parse (worker, receiver and admin alike).
43
+ "HOME",
40
44
  ]);
@@ -0,0 +1,178 @@
1
+ // The one import this module takes, and it is otherwise an import-free leaf (issue #339).
2
+ import { scrubCredentials } from "./redact.mjs";
3
+
4
+ /**
5
+ * The periodic retention sweep (issue #292, OQ-007).
6
+ *
7
+ * Every reaper in this project swept ONCE, at boot: the run history (`makeLogReaper`), the retained
8
+ * sandboxes (`makeSandboxReaper`) and the session store (`sessionStore.reapSessions`). The supported
9
+ * deployment shape is a service unit that restarts only on failure, so the healthy worker was exactly
10
+ * the one that never re-swept. Thirty days of uptime held thirty days of GROWTH, not thirty days of
11
+ * retention, and the three windows an operator configured described nothing.
12
+ *
13
+ * This is one `.unref()`'d interval that re-runs the three closures boot already built. It rebuilds
14
+ * nothing, so one configuration read serves boot and every tick after it and the two cannot drift.
15
+ *
16
+ * A MODULE rather than a few lines inline in `start.mjs`, for a reason that is about testing and not
17
+ * about tidiness: `worker/test/start-wiring.test.mjs` skips its entire file unless `VALKEY_TEST_URL` is
18
+ * set, so anything living in the boot function is exercised only in CI. The sharp edges of an interval
19
+ * -- re-entrancy, an idempotent close, a sweep still running when the next tick fires -- are precisely
20
+ * what wants pinning on a laptop, and here they get a plain test file with no queue in it.
21
+ *
22
+ * REJECTED: a `while (!stopped) { await sweep(); await sleep(ms); }` loop on `receiver/src/poller.mjs`'s
23
+ * pattern. It gets non-overlap for free and is already proven testable in this repo, but its `setTimeout`
24
+ * is not unref'd and would hold the worker's event loop open for up to a day, and `close()` becomes a
25
+ * promise handshake rather than a `clearInterval`. The poller IS its process's main loop and wants to
26
+ * hold the loop open; a background sweep must not.
27
+ *
28
+ * The interval idiom is `host-registry.mjs`'s, deliberately, down to the ordering inside `close()`. Two
29
+ * divergences from it are marked at their sites, because a reader who knows that file will otherwise
30
+ * "fix" them back.
31
+ *
32
+ * WHAT THIS COSTS THAT THE BOOT SWEEP DID NOT, and it is the one genuinely new failure mode here. All
33
+ * three reapers delete SYNCHRONOUSLY (`rmSync`, `unlinkSync`), which was free at boot because nothing was
34
+ * in flight, and is not free on a timer beside draining jobs: a long delete blocks the event loop, and
35
+ * `index.mjs` sets `maxStalledCount: 0` against BullMQ's 30s lock, so a loop blocked past the renewal
36
+ * window FAILS a paid job rather than silently re-running it. The direction is safe and the outcome is
37
+ * still new. Two things bound it: this loop yields between stores, and the sandbox reaper -- the only one
38
+ * that deletes whole trees, each a repository clone -- yields between entries. What remains is one
39
+ * directory's own `rmSync`, which is why that residual is stated here rather than implied.
40
+ */
41
+
42
+ /** The default cadence. A named constant on HOST_BEAT_MS's precedent, not a literal at the call site. */
43
+ export const SWEEP_INTERVAL_HOURS = 24;
44
+
45
+ /**
46
+ * How long `close()` will wait for an in-flight sweep before giving up on it.
47
+ *
48
+ * `index.mjs`'s shutdown says a closer that never settles blocks `process.exit(0)`, "which no closer
49
+ * here does" -- and an unbounded drain would have made that sentence false. The obvious defence, that
50
+ * the sweep's own docker call is capped at 5s, is NOT true: `listRunningSandboxes` uses
51
+ * `execFile`'s `timeout`, which only sends SIGTERM and then still waits for the child's `close`, so a
52
+ * `docker ps` wedged on a dead daemon socket never settles at all.
53
+ *
54
+ * NOT unref'd, on `host-registry`'s own reasoning: an unref'd timer does not fire when nothing else
55
+ * holds the loop, which is exactly the shutdown this bound exists for. The cost is that a hung sweep
56
+ * delays exit by at most this long, which is the point.
57
+ */
58
+ const SWEEP_DRAIN_TIMEOUT_MS = 10_000;
59
+
60
+ /**
61
+ * The ceiling, and it is not decoration. `setInterval` clamps a delay above 2^31-1 ms (about 24.85 days)
62
+ * to 1ms, so a fat-fingered `PI_SWEEP_INTERVAL_HOURS=1000` would silently become a hot loop sweeping the
63
+ * filesystem as fast as the event loop allows. One week is the refusal point rather than Node's own
64
+ * ceiling, because an interval longer than a week is not a sweep, it is `0` with extra steps, and `0`
65
+ * already says that clearly.
66
+ */
67
+ export const SWEEP_INTERVAL_MAX_HOURS = 168;
68
+
69
+ /**
70
+ * Build the sweep. `reapers` is an ORDERED array of `{ name, reap }` -- logs, sandboxes, sessions, the
71
+ * same order boot runs them in, so a tick's log lines read like a boot's. An object would read as
72
+ * unordered, and this one is not.
73
+ *
74
+ * `setIntervalFn`/`clearIntervalFn` are injected. No production caller passes them; they exist because
75
+ * this repo has no fake-timer infrastructure at all and its stated doctrine is to inject a seam rather
76
+ * than reach for a global. The alternative -- a 1ms interval and a real `sleep` in the test -- would be a
77
+ * wall-clock-dependent test, which is the exact class issue #293 exists to eliminate, landing in the same
78
+ * change that adds #293's guard.
79
+ */
80
+ export function makeRetentionSweep({ reapers, intervalMs, log = () => {}, setIntervalFn = setInterval, clearIntervalFn = clearInterval }) {
81
+ let timer = null;
82
+ let closed = false;
83
+ let inFlight = null;
84
+
85
+ async function sweepOnce() {
86
+ if (closed) return;
87
+ // DIVERGENCE from host-registry, which has no overlap guard: a beat is three bounded Redis writes
88
+ // against a 15s interval and two overlapping beats write the same values, while a sweep is
89
+ // unbounded filesystem I/O with `rmSync` in it. At the 24h default this is close to unreachable --
90
+ // `listRunningSandboxes` alone is capped at 5s by its own exec timeout -- but it is what keeps a
91
+ // short PI_SWEEP_INTERVAL_HOURS against a slow network filesystem from stacking sweeps that race
92
+ // each other's deletes.
93
+ if (inFlight) {
94
+ log("retention_sweep_overlapped", {});
95
+ return;
96
+ }
97
+ const startedAt = Date.now();
98
+ inFlight = (async () => {
99
+ for (const entry of reapers) {
100
+ try {
101
+ // Destructured INSIDE the try: a malformed entry must be one log line like any other
102
+ // reaper fault, not a rejection out of a function the interval calls with `void`.
103
+ const { name, reap } = entry;
104
+ await reap();
105
+ // Between stores, so one tick is never a single uninterruptible block. See the note on
106
+ // synchronous deletes in the module docblock.
107
+ await new Promise((resolve) => setImmediate(resolve));
108
+ } catch (err) {
109
+ // The existing per-store event names, reused rather than invented: an operator greps one
110
+ // name and gets both the boot sweep and every tick. Fault isolation per store, so one
111
+ // broken reaper cannot stop the other two.
112
+ // SCRUBBED here as well as at each store's own catch, and that is not belt-and-braces: this line
113
+ // re-emits every one of those families on the TIMER, so a fix confined to the boot paths would
114
+ // leave every tick uncovered (issue #339).
115
+ log(`${entry?.name}_reaper_skipped`, { reason: scrubCredentials(err?.message) });
116
+ }
117
+ }
118
+ })();
119
+ try {
120
+ await inFlight;
121
+ log("retention_sweep", { ms: Date.now() - startedAt });
122
+ } catch {
123
+ // Unreachable today: every reaper is wrapped above. Here because the interval calls this with
124
+ // `void`, so ANY rejection is an unhandled rejection, and Node kills the process for one by
125
+ // default. A sweep must never be able to take the worker down.
126
+ } finally {
127
+ inFlight = null;
128
+ }
129
+ }
130
+
131
+ return {
132
+ sweepOnce,
133
+
134
+ /**
135
+ * Arm the interval. DIVERGENCE from `host-registry.start`, which beats immediately: boot has just
136
+ * run all three reapers, so an immediate sweep would double the boot work and break the call-order
137
+ * pins in start-wiring. The first tick lands one full interval after boot.
138
+ */
139
+ start() {
140
+ if (closed || timer) return; // a second start would leak the first interval
141
+ // A non-positive interval must arm NOTHING. setInterval(fn, 0) is a hot loop, and the caller
142
+ // already gates on the knob being > 0; this is what turns a future mis-wire into a no-op rather
143
+ // than a disk-melting one.
144
+ if (!(intervalMs > 0)) return;
145
+ timer = setIntervalFn(() => void sweepOnce(), intervalMs);
146
+ timer?.unref?.();
147
+ },
148
+
149
+ /**
150
+ * `closed` is set FIRST, for host-registry's own reason: a sweep already past the top-of-function
151
+ * check must not `rmSync` after the closer has reported done. Then the in-flight sweep is drained,
152
+ * so a shutdown never leaves a half-deleted retained workspace behind a worker that has already
153
+ * said it stopped cleanly.
154
+ *
155
+ * IDEMPOTENCE COMES FROM NULLING THE TIMER, not from an `if (closed) return` at the top. There was
156
+ * one here, and a mutation pass proved it pinned nothing: with `timer` already null the second call
157
+ * clears nothing, awaits a settled promise and returns. Issue #295 reached the identical conclusion
158
+ * about the watcher closers and removed the guard rather than documenting it, and a guard with
159
+ * nothing behind it is worse than none, because it invites a reader to trust it for something.
160
+ */
161
+ async close() {
162
+ closed = true;
163
+ if (timer) clearIntervalFn(timer);
164
+ timer = null;
165
+ if (!inFlight) return;
166
+ await new Promise((resolve) => {
167
+ const t = setTimeout(() => {
168
+ log("retention_sweep_drain_timeout", { ms: SWEEP_DRAIN_TIMEOUT_MS });
169
+ resolve();
170
+ }, SWEEP_DRAIN_TIMEOUT_MS);
171
+ inFlight.catch(() => {}).then(() => {
172
+ clearTimeout(t);
173
+ resolve();
174
+ });
175
+ });
176
+ },
177
+ };
178
+ }