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.
- package/CHANGELOG.md +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/broker/crew-broker-child.ts +55 -17
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 || !
|
|
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,
|