pi-crew 0.11.49 → 0.11.51

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 (64) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/index.mjs +551 -544
  3. package/package.json +1 -1
  4. package/skills/real-test-pi-crew/SKILL.md +2 -0
  5. package/src/agents/agent-config.ts +58 -1
  6. package/src/config/config-validation.ts +18 -0
  7. package/src/config/env-allowlist-registry.ts +5 -1
  8. package/src/config/env-vars.ts +5 -1
  9. package/src/config/sanitize-project-config.ts +55 -0
  10. package/src/extension/management.ts +9 -0
  11. package/src/extension/registration/lifecycle-handlers.ts +28 -0
  12. package/src/extension/registration/subagent-tools.ts +5 -1
  13. package/src/extension/run-import.ts +40 -6
  14. package/src/extension/team-tool/api/agent-control.ts +49 -0
  15. package/src/extension/team-tool/api/heartbeat.ts +48 -1
  16. package/src/extension/team-tool/api/mailbox.ts +81 -2
  17. package/src/extension/team-tool/api/read.ts +22 -0
  18. package/src/extension/team-tool/api/task-claims.ts +134 -3
  19. package/src/extension/team-tool/destructive-gate.ts +92 -8
  20. package/src/extension/team-tool/inspect.ts +8 -2
  21. package/src/extension/team-tool/lifecycle-actions.ts +9 -2
  22. package/src/extension/team-tool.ts +128 -27
  23. package/src/observability/exporters/otlp-exporter.ts +69 -19
  24. package/src/prompt/message-tool.ts +19 -8
  25. package/src/prompt/prompt-runtime.ts +160 -51
  26. package/src/runtime/broker/crew-broker-child.ts +55 -17
  27. package/src/runtime/broker/crew-broker-tokens.ts +24 -13
  28. package/src/runtime/broker/crew-broker.ts +12 -6
  29. package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
  30. package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
  31. package/src/runtime/child-pi/child-pi-steering.ts +8 -1
  32. package/src/runtime/child-pi/child-pi.ts +2 -1
  33. package/src/runtime/child-pi/inference-broker.ts +41 -7
  34. package/src/runtime/child-pi/pi-version.ts +31 -3
  35. package/src/runtime/child-pi/sandbox.ts +38 -1
  36. package/src/runtime/dwf-state-store.ts +193 -7
  37. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
  38. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
  39. package/src/runtime/live-session/live-session-runtime.ts +78 -3
  40. package/src/runtime/mcp-proxy.ts +65 -6
  41. package/src/runtime/model/pi-args.ts +16 -15
  42. package/src/runtime/plan-replan.ts +4 -1
  43. package/src/runtime/task-runner/child-executor.ts +11 -5
  44. package/src/runtime/task-runner/hybrid-tier.ts +17 -0
  45. package/src/runtime/task-runner/pre-execution.ts +13 -6
  46. package/src/runtime/task-runner.ts +14 -0
  47. package/src/runtime/team-runner.ts +26 -0
  48. package/src/runtime/verification/verification-worktree.ts +46 -29
  49. package/src/runtime/verification/verify-gate-memo.ts +211 -32
  50. package/src/runtime/verification/verify-gate.ts +266 -14
  51. package/src/runtime/workspace-lock.ts +95 -21
  52. package/src/schema/config-schema.ts +12 -2
  53. package/src/state/coordination/locks.ts +86 -19
  54. package/src/state/coordination/mailbox.ts +263 -22
  55. package/src/state/stores/manifest-io.ts +206 -7
  56. package/src/state/stores/state-store.ts +44 -15
  57. package/src/state/types.ts +9 -0
  58. package/src/ui/inline-panel/agent-transcript.ts +151 -11
  59. package/src/ui/settings-overlay.ts +8 -3
  60. package/src/ui/theme-discovery.ts +14 -2
  61. package/src/ui/tool-renderers/index.ts +26 -6
  62. package/src/utils/safe-terminal-text.ts +60 -0
  63. package/src/worktree/git-hardening.ts +78 -1
  64. package/src/worktree/worktree-manager.ts +34 -4
@@ -11,12 +11,18 @@ import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner
11
11
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
12
12
  import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
13
13
  import { withRunLock } from "../state/coordination/locks.ts";
14
- import { replayPendingMailboxMessages } from "../state/coordination/mailbox.ts";
14
+ import { replayPendingMailboxMessages, signSteeringEntry } from "../state/coordination/mailbox.ts";
15
15
  import { isTaskClaimExpired } from "../state/coordination/task-claims.ts";
16
16
  import { appendEventAsync, appendEventFireAndForget } from "../state/event-log/event-log.ts";
17
17
  import { ACTIVE_RUN_STALE_MS, activeRunEntries, registerActiveRun, unregisterActiveRun } from "../state/stores/active-run-registry.ts";
18
18
  import { writeArtifact } from "../state/stores/artifact-store.ts";
19
- import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
19
+ import {
20
+ loadRunManifestById,
21
+ resolveMaxTasksPerRunAdmissionCap,
22
+ saveRunManifestAsync,
23
+ saveRunTasks,
24
+ updateRunStatus,
25
+ } from "../state/stores/state-store.ts";
20
26
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
21
27
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
22
28
  import type { TeamConfig } from "../teams/team-config.ts";
@@ -398,6 +404,16 @@ function resumeLivenessRefusal(manifest: TeamRunManifest, tasks: TeamTaskState[]
398
404
  return undefined;
399
405
  }
400
406
 
407
+ /** F1-mirror (adoptSpecialKindRunForResume in-lock liveness re-check): thrown
408
+ * by the special-kind (goal-loop / dynamic-workflow) resume adopt path when the
409
+ * freshly re-read LOCKED state shows the run LIVE — a concurrent session
410
+ * adopted + registered it between this session's pre-lock gate (handleResume)
411
+ * and this lock acquisition. Mirrors the static LOCK-2 refusal: force:true
412
+ * bypasses OWNERSHIP only, never LIVENESS (G12). The resume arms catch this
413
+ * BEFORE any adoption side effect and surface it as a typed error result — no
414
+ * manifest write, no ownership flip, no registry write, no engine re-dispatch. */
415
+ class ResumeLivenessRefusedError extends Error {}
416
+
401
417
  /** G11 (SDD-3 W-C WI-2): adopt ownership + refresh heartbeat for a special-kind
402
418
  * (goal-loop / dynamic-workflow) resume, under the same per-run lock semantics
403
419
  * as the static path. Mirrors the static path's B1 adoption (battery 2026-08-18
@@ -412,6 +428,17 @@ async function adoptSpecialKindRunForResume(
412
428
  return await withRunLock(manifest, async () => {
413
429
  const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
414
430
  const base = fresh?.manifest ?? manifest;
431
+ // F1 mirror (LOCK-2 in-lock liveness re-check, static path): the pre-lock
432
+ // gate in handleResume raced concurrent adoption — between that load and
433
+ // this lock acquisition another session may have adopted and REGISTERED
434
+ // the run (registerActiveRun + ownerSessionId). Re-check liveness against
435
+ // the LOCKED state BEFORE any adoption mutation (async drop, status flip,
436
+ // owner adopt, registry write, engine re-dispatch); force:true bypasses
437
+ // OWNERSHIP only, never LIVENESS (G12).
438
+ const lockedLiveness = resumeLivenessRefusal(base, fresh?.tasks ?? []);
439
+ if (lockedLiveness) {
440
+ throw new ResumeLivenessRefusedError(lockedLiveness);
441
+ }
415
442
  // W-C2 (SDD-4 WI-6): drop the dispatch-time detached-runner pointer. Resume
416
443
  // arms re-execute IN THIS session's process (runGoalLoop /
417
444
  // runDynamicWorkflow) — no runner is re-spawned — yet goal-wrapped runs carry
@@ -474,7 +501,20 @@ async function resumeDynamicWorkflowRun(
474
501
  roles: [{ name: "worker", agent: params.agent ?? "executor" }],
475
502
  workspaceMode: "single",
476
503
  };
477
- const adopted = await adoptSpecialKindRunForResume(manifest, "dynamic-workflow", ctx);
504
+ // In-lock liveness refusal surface (F1 mirror): adoptSpecialKindRunForResume
505
+ // re-checks liveness under the run lock and throws ResumeLivenessRefusedError
506
+ // BEFORE any adoption mutation — no manifest write, no ownership flip, no
507
+ // registry entry, no engine dispatch. Surface the exact refusal message the
508
+ // static LOCK-2 path returns.
509
+ let adopted: TeamRunManifest;
510
+ try {
511
+ adopted = await adoptSpecialKindRunForResume(manifest, "dynamic-workflow", ctx);
512
+ } catch (error) {
513
+ if (error instanceof ResumeLivenessRefusedError) {
514
+ return result(error.message, { action: "resume", status: "error", runId: manifest.runId }, true);
515
+ }
516
+ throw error;
517
+ }
478
518
  registerActiveRun(adopted);
479
519
  // CORE-8 mirror: unified deadline (params.timeoutMs > config.maxRunMinutes > 1h default).
480
520
  const dwfDeadline = resolveRunDeadline(ctx, params);
@@ -547,7 +587,16 @@ async function resumeGoalLoopRun(params: TeamToolParamsValue, ctx: TeamContext,
547
587
  true,
548
588
  );
549
589
  }
550
- const adopted = await adoptSpecialKindRunForResume(manifest, "goal-loop", ctx);
590
+ // In-lock liveness refusal surface (F1 mirror) — same contract as the DWF arm.
591
+ let adopted: TeamRunManifest;
592
+ try {
593
+ adopted = await adoptSpecialKindRunForResume(manifest, "goal-loop", ctx);
594
+ } catch (error) {
595
+ if (error instanceof ResumeLivenessRefusedError) {
596
+ return result(error.message, { action: "resume", status: "error", runId: manifest.runId }, true);
597
+ }
598
+ throw error;
599
+ }
551
600
  // LAZY: the runner import stays after the state check for fast failure.
552
601
  const { runGoalLoop } = await import("../runtime/goal-workflow/goal-loop-runner.ts");
553
602
  registerActiveRun(adopted);
@@ -714,6 +763,25 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
714
763
  payload: result(lockedLiveness, { action: "resume", status: "error", runId: lockedManifest.runId }, true),
715
764
  };
716
765
  }
766
+ // resume-skips-maxtasks-admission fix: limits.maxTasksPerRun is an ADMISSION
767
+ // invariant, not a creation-only one. The disk-loaded tasks.json is untrusted
768
+ // persisted state (worker-writable in single-workspace project state —
769
+ // loadTasksWithRecovery returns it verbatim with no count bound), so a
770
+ // poisoned file inflated past the ceiling must be refused here exactly as
771
+ // createRunManifest refuses the same cardinality at creation — BEFORE any
772
+ // recovery/reset mutation, status flip, or dispatch. Same contract as the
773
+ // creation gate: name the config key, the cap, and the actual count.
774
+ const admissionCap = resolveMaxTasksPerRunAdmissionCap(ctx.cwd);
775
+ if (lockedTasks.length > admissionCap) {
776
+ return {
777
+ kind: "blocked" as const,
778
+ payload: result(
779
+ `Refusing to resume run ${lockedManifest.runId}: tasks.json holds ${lockedTasks.length} tasks, exceeding limits.maxTasksPerRun=${admissionCap}. Persisted task state is untrusted (worker-writable in single-workspace project state); reduce the task count or raise limits.maxTasksPerRun.`,
780
+ { action: "resume", status: "error", runId: lockedManifest.runId },
781
+ true,
782
+ ),
783
+ };
784
+ }
717
785
  const loadedConfig = loadConfig(ctx.cwd);
718
786
  const recovered = await recoverCheckpointedTasks(lockedManifest, lockedTasks);
719
787
  const resumeManifest = recovered.manifest;
@@ -896,28 +964,55 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
896
964
  // handleRun (which never wraps executeTeamRun in an outer lock).
897
965
  if (decision.kind === "blocked") return decision.payload;
898
966
 
899
- const executed = await executeTeamRun({
900
- manifest: decision.runtimeManifest,
901
- tasks: decision.resetTasks,
902
- team,
903
- workflow,
904
- agents,
905
- executeWorkers: decision.executeWorkers,
906
- limits: decision.executedConfig.limits,
907
- runtime: decision.runtime,
908
- runtimeConfig: decision.executedConfig.runtime,
909
- parentContext: buildParentContext(ctx),
910
- parentModel: resolveParentModel(ctx.model),
911
- modelRegistry: ctx.modelRegistry,
912
- modelOverride: params.model,
913
- skillOverride: decision.resumeSkillOverride,
914
- signal: ctx.signal,
915
- reliability: decision.executedConfig.reliability,
916
- metricRegistry: ctx.metricRegistry,
917
- workspaceId: ctx.sessionId ?? ctx.cwd,
918
- // Finding 8: resume is the legitimate terminal-exit flow.
919
- isResume: true,
920
- });
967
+ // resume-disarms-run-budget-enforcement fix (handleResume re-arm): the static
968
+ // resume re-entry previously forwarded NEITHER the persisted budget fields
969
+ // NOR a unified deadline — enforceRunBudget gates solely on input.budgetTotal
970
+ // and resolveRunDeadline was armed only at the dispatch/DWF/goal-resume
971
+ // sites, so a paused-and-resumed run escaped both operator bounds entirely
972
+ // (disarmed). Mirror the inline dispatch path (run.ts CORE-8 deadline) and
973
+ // the detached re-entry (background-runner.ts manifest.budget* forwarding):
974
+ // arm the same unified deadline a fresh run gets (params.timeoutMs >
975
+ // config.limits.maxRunMinutes > 1h default — elapsed wall-clock counts
976
+ // against it) and re-forward the manifest budget persisted at the original
977
+ // dispatch. team-runner additionally re-arms missing budget fields from the
978
+ // manifest as defense-in-depth, so the two layers cannot cancel out.
979
+ const resumeDeadline = resolveRunDeadline(ctx, params, decision.executedConfig);
980
+ let executed: Awaited<ReturnType<typeof executeTeamRun>>;
981
+ try {
982
+ executed = await executeTeamRun({
983
+ manifest: decision.runtimeManifest,
984
+ tasks: decision.resetTasks,
985
+ team,
986
+ workflow,
987
+ agents,
988
+ executeWorkers: decision.executeWorkers,
989
+ limits: decision.executedConfig.limits,
990
+ runtime: decision.runtime,
991
+ runtimeConfig: decision.executedConfig.runtime,
992
+ parentContext: buildParentContext(ctx),
993
+ parentModel: resolveParentModel(ctx.model),
994
+ modelRegistry: ctx.modelRegistry,
995
+ modelOverride: params.model,
996
+ skillOverride: decision.resumeSkillOverride,
997
+ signal: resumeDeadline.signal,
998
+ reliability: decision.executedConfig.reliability,
999
+ metricRegistry: ctx.metricRegistry,
1000
+ workspaceId: ctx.sessionId ?? ctx.cwd,
1001
+ // Finding 8: resume is the legitimate terminal-exit flow.
1002
+ isResume: true,
1003
+ // resume-disarms fix: re-forward the operator's persisted spend cap —
1004
+ // this call site previously dropped the budget fields on the floor.
1005
+ ...(decision.runtimeManifest.budgetTotal !== undefined ? { budgetTotal: decision.runtimeManifest.budgetTotal } : {}),
1006
+ ...(decision.runtimeManifest.budgetWarning !== undefined ? { budgetWarning: decision.runtimeManifest.budgetWarning } : {}),
1007
+ ...(decision.runtimeManifest.budgetAbort !== undefined ? { budgetAbort: decision.runtimeManifest.budgetAbort } : {}),
1008
+ ...(decision.runtimeManifest.budgetUnlimited !== undefined
1009
+ ? { budgetUnlimited: decision.runtimeManifest.budgetUnlimited }
1010
+ : {}),
1011
+ });
1012
+ } finally {
1013
+ // RC-02: clear the resumed-execution deadline timer (idempotent).
1014
+ clearTimeout(resumeDeadline.timer);
1015
+ }
921
1016
  // RELIABILITY FIX 2026-10-10 (bug #3, wave-2/wave-3 observations): the old
922
1017
  // message printed a bare "Status: completed" which read as "the whole run
923
1018
  // just finished" even when the resume had re-queued real work that kept
@@ -1020,7 +1115,13 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
1020
1115
  // guards against future changes to task-id generation (e.g. if it ever
1021
1116
  // accepted user input).
1022
1117
  const safeSteeringPath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
1023
- fs.appendFileSync(safeSteeringPath, JSON.stringify({ type: "steer", message, ts: new Date().toISOString() }) + "\n");
1118
+ // w1a sender binding: the steering file is same-uid writable — the line
1119
+ // carries this process's Ed25519 signature (worker pollSteering drops
1120
+ // unauthenticated lines).
1121
+ fs.appendFileSync(
1122
+ safeSteeringPath,
1123
+ JSON.stringify(signSteeringEntry({ type: "steer", message, ts: new Date().toISOString() })) + "\n",
1124
+ );
1024
1125
  } catch {
1025
1126
  // Best-effort: file write failure doesn't block the steer from pending array
1026
1127
  }
@@ -10,6 +10,23 @@ import type { MetricExporter } from "./adapter.ts";
10
10
 
11
11
  const gzipAsync = promisify(gzip);
12
12
 
13
+ /**
14
+ * Log-safe endpoint form: origin only — never path, query, or userinfo.
15
+ * Mirrors the webhook-notify.ts `safeUrlForLog` control: the OTLP endpoint may
16
+ * carry embedded credentials (`http://user:pass@host/...` is accepted by the
17
+ * config schema pattern), and raw interpolation into log/throw text would leak
18
+ * those credentials to host stderr/session logs.
19
+ */
20
+ function safeEndpointForLog(endpoint: string): string {
21
+ try {
22
+ return new URL(bracketIpv6InUrl(endpoint)).origin;
23
+ } catch {
24
+ // Unparseable URL: still strip any embedded userinfo before echoing the
25
+ // raw text into an error message.
26
+ return endpoint.replace(/^([a-zA-Z][a-zA-Z\d+\-.]*:)\/\/[^/?#]*@/, "$1//[redacted]@");
27
+ }
28
+ }
29
+
13
30
  /**
14
31
  * SSRF protection: validate that an OTLP endpoint URL does not target
15
32
  * private/reserved networks or dangerous protocols.
@@ -26,7 +43,7 @@ export function validateEndpoint(endpoint: string): void {
26
43
  try {
27
44
  url = new URL(endpoint);
28
45
  } catch {
29
- throw new Error(`Invalid OTLP endpoint URL: ${endpoint}`);
46
+ throw new Error(`Invalid OTLP endpoint URL: ${safeEndpointForLog(endpoint)}`);
30
47
  }
31
48
 
32
49
  // Only allow http and https protocols
@@ -34,11 +51,20 @@ export function validateEndpoint(endpoint: string): void {
34
51
  throw new Error(`OTLP endpoint must use http:// or https:// protocol, got: ${url.protocol}`);
35
52
  }
36
53
 
54
+ // Credentials embedded in the URL would leak into logs — refuse them
55
+ // (pass them via the `headers` option instead), mirroring the webhook
56
+ // sink (webhook-notify.ts). Checked BEFORE every classification error so
57
+ // no later throw message can ever embed a credential-bearing endpoint:
58
+ // all messages below render the origin-only `safeEndpointForLog` form.
59
+ if (url.username || url.password) {
60
+ throw new Error(`OTLP endpoint must not embed credentials (userinfo) — pass them via headers instead: ${url.origin}`);
61
+ }
62
+
37
63
  const hostname = url.hostname.toLowerCase();
38
64
 
39
65
  // Reject known localhost names
40
66
  if (hostname === "localhost" || hostname.endsWith(".localhost")) {
41
- throw new Error(`OTLP endpoint must not target localhost: ${endpoint}`);
67
+ throw new Error(`OTLP endpoint must not target localhost: ${safeEndpointForLog(endpoint)}`);
42
68
  }
43
69
 
44
70
  // Reject IPv6 loopback and private
@@ -46,38 +72,38 @@ export function validateEndpoint(endpoint: string): void {
46
72
  const bare = hostname.replace(/^\[|\]$/g, "");
47
73
  const lower = bare.toLowerCase();
48
74
  if (lower === "::1") {
49
- throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
75
+ throw new Error(`OTLP endpoint must not target loopback address: ${safeEndpointForLog(endpoint)}`);
50
76
  }
51
77
  // Unique Local Addresses (fc00::/7) — fd and fc prefixes
52
78
  if (lower.startsWith("fd") || lower.startsWith("fc")) {
53
- throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
79
+ throw new Error(`OTLP endpoint must not target private IPv6 address: ${safeEndpointForLog(endpoint)}`);
54
80
  }
55
81
  // Link-Local (fe80::/10) — fe8x, fe9x, feax, febx prefixes
56
82
  if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) {
57
- throw new Error(`OTLP endpoint must not target IPv6 link-local address: ${endpoint}`);
83
+ throw new Error(`OTLP endpoint must not target IPv6 link-local address: ${safeEndpointForLog(endpoint)}`);
58
84
  }
59
85
  // Site-Local (fec0::/10) — deprecated but still routable on misconfigured networks
60
86
  if (lower.startsWith("fec") || lower.startsWith("fed") || lower.startsWith("fee") || lower.startsWith("fef")) {
61
- throw new Error(`OTLP endpoint must not target IPv6 site-local address: ${endpoint}`);
87
+ throw new Error(`OTLP endpoint must not target IPv6 site-local address: ${safeEndpointForLog(endpoint)}`);
62
88
  }
63
89
  // Multicast (ff00::/8) — prefix ff
64
90
  if (lower.startsWith("ff")) {
65
- throw new Error(`OTLP endpoint must not target IPv6 multicast address: ${endpoint}`);
91
+ throw new Error(`OTLP endpoint must not target IPv6 multicast address: ${safeEndpointForLog(endpoint)}`);
66
92
  }
67
93
  // Unspecified address ::
68
94
  if (lower === "::") {
69
- throw new Error(`OTLP endpoint must not target IPv6 unspecified address: ${endpoint}`);
95
+ throw new Error(`OTLP endpoint must not target IPv6 unspecified address: ${safeEndpointForLog(endpoint)}`);
70
96
  }
71
97
  // IPv4-mapped IPv6 (::ffff:x.x.x.x)
72
98
  if (lower.startsWith("::ffff:")) {
73
- throw new Error(`OTLP endpoint must not target IPv4-mapped IPv6 address: ${endpoint}`);
99
+ throw new Error(`OTLP endpoint must not target IPv4-mapped IPv6 address: ${safeEndpointForLog(endpoint)}`);
74
100
  }
75
101
  // G6: catch every literal form the prefix checks above miss —
76
102
  // IPv4-compatible `::a.b.c.d` (e.g. ::127.0.0.1, ::169.254.169.254),
77
103
  // expanded spellings (`0:0:0:0:0:0:0:1`), zone-ids — through the
78
104
  // shared parser, applying the same predicates as the IPv4 branch.
79
105
  if (isPrivateIpv6Literal(bare)) {
80
- throw new Error(`OTLP endpoint must not target private/reserved IPv6 address: ${endpoint}`);
106
+ throw new Error(`OTLP endpoint must not target private/reserved IPv6 address: ${safeEndpointForLog(endpoint)}`);
81
107
  }
82
108
  }
83
109
 
@@ -88,27 +114,27 @@ export function validateEndpoint(endpoint: string): void {
88
114
  const octets = [Number(ipv4Match[1]), Number(ipv4Match[2]), Number(ipv4Match[3]), Number(ipv4Match[4])];
89
115
  // 127.x.x.x - loopback
90
116
  if (octets[0] === 127) {
91
- throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
117
+ throw new Error(`OTLP endpoint must not target loopback address: ${safeEndpointForLog(endpoint)}`);
92
118
  }
93
119
  // 10.x.x.x - private class A
94
120
  if (octets[0] === 10) {
95
- throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${endpoint}`);
121
+ throw new Error(`OTLP endpoint must not target private network (10.0.0.0/8): ${safeEndpointForLog(endpoint)}`);
96
122
  }
97
123
  // 172.16.x.x - 172.31.x.x - private class B
98
124
  if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
99
- throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${endpoint}`);
125
+ throw new Error(`OTLP endpoint must not target private network (172.16.0.0/12): ${safeEndpointForLog(endpoint)}`);
100
126
  }
101
127
  // 192.168.x.x - private class C
102
128
  if (octets[0] === 192 && octets[1] === 168) {
103
- throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${endpoint}`);
129
+ throw new Error(`OTLP endpoint must not target private network (192.168.0.0/16): ${safeEndpointForLog(endpoint)}`);
104
130
  }
105
131
  // 169.254.x.x - link-local / cloud metadata
106
132
  if (octets[0] === 169 && octets[1] === 254) {
107
- throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${endpoint}`);
133
+ throw new Error(`OTLP endpoint must not target link-local/metadata endpoint (169.254.0.0/16): ${safeEndpointForLog(endpoint)}`);
108
134
  }
109
135
  // 0.x.x.x - this network
110
136
  if (octets[0] === 0) {
111
- throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${endpoint}`);
137
+ throw new Error(`OTLP endpoint must not target this-network address (0.0.0.0/8): ${safeEndpointForLog(endpoint)}`);
112
138
  }
113
139
  }
114
140
  }
@@ -230,7 +256,7 @@ export async function assertResolvedAddressSafe(endpoint: string): Promise<void>
230
256
  // literal needs no DNS round-trip.
231
257
  if (hostname.startsWith("[")) {
232
258
  if (isPrivateIpv6Literal(hostname)) {
233
- throw new Error(`OTLP endpoint targets private/reserved IPv6 literal: ${endpoint}`);
259
+ throw new Error(`OTLP endpoint targets private/reserved IPv6 literal: ${safeEndpointForLog(endpoint)}`);
234
260
  }
235
261
  return;
236
262
  }
@@ -244,7 +270,9 @@ export async function assertResolvedAddressSafe(endpoint: string): Promise<void>
244
270
  }
245
271
  for (const { address } of addresses) {
246
272
  if (isPrivateIpAddress(address)) {
247
- throw new Error(`OTLP endpoint resolves to private/reserved address ${address} (DNS rebinding blocked): ${endpoint}`);
273
+ throw new Error(
274
+ `OTLP endpoint resolves to private/reserved address ${address} (DNS rebinding blocked): ${safeEndpointForLog(endpoint)}`,
275
+ );
248
276
  }
249
277
  }
250
278
  }
@@ -411,12 +439,34 @@ export class OTLPExporter implements MetricExporter {
411
439
  },
412
440
  body,
413
441
  signal: controller.signal,
442
+ // SSRF (security review MEDIUM): redirect:"follow" (the undici
443
+ // default) would chase up to 20 hops WITHOUT re-running either
444
+ // guard — validateEndpoint and assertResolvedAddressSafe bind ONLY
445
+ // to the configured endpoint, so a 302 from the collector reaches
446
+ // loopback / RFC1918 / 169.254.169.254 cloud metadata, and a 307 on
447
+ // Node >=24 REPLAYS the gzipped body + custom headers there.
448
+ // Manual mode surfaces any 3xx as a failed push below, exactly like
449
+ // the sibling webhook sink (webhook-notify.ts redirect handling).
450
+ redirect: "manual",
414
451
  });
452
+ // 3xx under redirect:"manual" = a redirect we REFUSE to follow (the
453
+ // guards validated the configured URL, never the hop target). The
454
+ // Location target is never contacted — permanent failure for this push.
455
+ if (response.status >= 300 && response.status < 400) {
456
+ logInternalError(
457
+ "otlp-export-http",
458
+ new Error(`refused redirect HTTP ${response.status} (SSRF guard — redirects are not followed)`),
459
+ `endpoint=${safeEndpointForLog(this.opts.endpoint)}`,
460
+ );
461
+ return;
462
+ }
415
463
  if (!response.ok) {
416
464
  logInternalError(
417
465
  "otlp-export-http",
418
466
  new Error(`HTTP ${response.status}: ${response.statusText}`),
419
- `endpoint=${this.opts.endpoint}`,
467
+ // Origin only: never render path/query/userinfo into logs
468
+ // (credentials embedded in the endpoint must not reach stderr).
469
+ `endpoint=${safeEndpointForLog(this.opts.endpoint)}`,
420
470
  );
421
471
  }
422
472
  } finally {
@@ -27,6 +27,7 @@
27
27
  import { createHash } from "node:crypto";
28
28
  import { type Static, Type } from "@sinclair/typebox";
29
29
  import { getCrewEnv } from "../config/env-vars.ts";
30
+ import { resolvedChildBrokerCreds } from "../runtime/broker/crew-broker-child.ts";
30
31
  import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
31
32
 
32
33
  export const PI_CREW_MSG_ENABLED_ENV = "PI_CREW_MSG_ENABLED";
@@ -166,18 +167,28 @@ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
166
167
  text: "[message] rate-limited (10 messages/minute) — this message was NOT sent; include the note in your final result instead.",
167
168
  };
168
169
  }
169
- const runId = get("PI_CREW_BROKER_RUN_ID") ?? "";
170
- const taskId = get("PI_CREW_TASK_ID") ?? get("PI_CREW_BROKER_TASK_ID") ?? "";
171
- const socketPath = get("PI_CREW_BROKER_SOCKET") ?? "";
172
- const token = get("PI_CREW_BROKER_TOKEN") ?? "";
170
+ const taskId0 = get("PI_CREW_TASK_ID") ?? get("PI_CREW_BROKER_TASK_ID") ?? "";
171
+ let runId = get("PI_CREW_BROKER_RUN_ID") ?? "";
172
+ let socketPath = get("PI_CREW_BROKER_SOCKET") ?? "";
173
+ let token = get("PI_CREW_BROKER_TOKEN") ?? "";
174
+ // LOW-1 guard closure (2026-10-30): bridge to the fd3-resolved creds when
175
+ // the env token is deliberately absent (default-on fd3 route).
176
+ if (!token) {
177
+ const creds = await resolvedChildBrokerCreds();
178
+ if (creds && creds.taskId === taskId0) {
179
+ token = creds.token;
180
+ runId = runId || creds.runId;
181
+ socketPath = socketPath || creds.socketPath;
182
+ }
183
+ }
173
184
  const client = deps.makeBrokerClient
174
- ? deps.makeBrokerClient({ runId, taskId, socketPath, token })
185
+ ? deps.makeBrokerClient({ runId, taskId: taskId0, socketPath, token })
175
186
  : (() => {
176
187
  // Production: the broker credentials are only present for
177
188
  // broker-eligible workers (child-pi-spawn). Scaffold/mock
178
189
  // workers fast-fail with a structured notice, never a hang.
179
- if (!runId || !taskId || !socketPath || !token) return null;
180
- return new CrewBrokerClient({ runId, taskId, socketPath, token });
190
+ if (!runId || !taskId0 || !socketPath || !token) return null;
191
+ return new CrewBrokerClient({ runId, taskId: taskId0, socketPath, token });
181
192
  })();
182
193
  if (!client) return brokerUnavailable();
183
194
  // R6-6: stable requestId (idempotency across retries) + explicit
@@ -188,7 +199,7 @@ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
188
199
  body: params.body,
189
200
  requestId: computeMessageRequestId({
190
201
  runId,
191
- taskId,
202
+ taskId: taskId0,
192
203
  to: params.to,
193
204
  kind: params.kind,
194
205
  body: params.body,