pi-crew 0.11.5 → 0.11.7
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 +165 -0
- package/README.md +1 -1
- package/dist/index.mjs +503 -493
- package/docs/dynamic-workflows.md +37 -0
- package/package.json +13 -9
- package/scripts/analyze-run.mjs +95 -11
- package/skills/model-routing-context/SKILL.md +4 -3
- package/skills/real-test-pi-crew/SKILL.md +9 -6
- package/src/config/config-validation.ts +28 -0
- package/src/config/env-vars.ts +29 -0
- package/src/config/types.ts +47 -0
- package/src/extension/crew-vibes/index.ts +29 -10
- package/src/extension/pi-api.ts +1 -0
- package/src/extension/registration/lifecycle-handlers.ts +65 -3
- package/src/extension/team-tool/respond.ts +54 -0
- package/src/extension/team-tool/status.ts +10 -0
- package/src/extension/team-tool.ts +393 -3
- package/src/extension/webhook-notify.ts +23 -1
- package/src/observability/exporters/otlp-exporter.ts +93 -11
- package/src/prompt/prompt-runtime.ts +46 -8
- package/src/runtime/agent-control.ts +1 -15
- package/src/runtime/background-runner.ts +3 -0
- package/src/runtime/broker/crew-broker.ts +12 -103
- package/src/runtime/broker/protocol/request-parsers.ts +9 -1
- package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
- package/src/runtime/child-pi/child-pi.ts +93 -4
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/dispatch-batch.ts +3 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
- package/src/runtime/live-session/live-agent-manager.ts +35 -0
- package/src/runtime/live-session/live-session-runtime.ts +34 -5
- package/src/runtime/mcp-proxy.ts +110 -7
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +115 -11
- package/src/runtime/model/provider-extensions.ts +67 -1
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/rpc/README.md +100 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +151 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +14 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
- package/src/runtime/stale-reconciler.ts +47 -0
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +170 -6
- package/src/runtime/task-runner/output-splitter.ts +48 -5
- package/src/runtime/task-runner/post-execution.ts +5 -5
- package/src/runtime/task-runner/pre-execution.ts +32 -4
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
- package/src/schema/config-schema.ts +25 -0
- package/src/schema/team-tool-schema.ts +35 -0
- package/src/state/contracts.ts +16 -0
- package/src/state/crew-init.ts +114 -0
- package/src/state/stores/active-run-registry.ts +13 -6
- package/src/state/types.ts +0 -8
- package/src/ui/goal-flag.ts +44 -0
- package/src/ui/live-run-sidebar.ts +5 -1
- package/src/ui/pi-ui-compat.ts +37 -11
- package/src/ui/run-dashboard.ts +2 -1
- package/src/ui/widget/index.ts +0 -1
- package/src/ui/widget/widget-model.ts +6 -2
- package/src/utils/ip-literal.ts +145 -0
- package/src/workflows/workflow-config.ts +6 -0
|
@@ -12,11 +12,14 @@ 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
14
|
import { replayPendingMailboxMessages } from "../state/coordination/mailbox.ts";
|
|
15
|
+
import { isTaskClaimExpired } from "../state/coordination/task-claims.ts";
|
|
15
16
|
import { appendEventAsync, appendEventFireAndForget } from "../state/event-log/event-log.ts";
|
|
17
|
+
import { ACTIVE_RUN_STALE_MS, activeRunEntries, registerActiveRun, unregisterActiveRun } from "../state/stores/active-run-registry.ts";
|
|
16
18
|
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
17
19
|
import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
|
|
18
20
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
19
21
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
22
|
+
import type { TeamConfig } from "../teams/team-config.ts";
|
|
20
23
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
24
|
import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
22
25
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
@@ -40,6 +43,7 @@ import { buildParentContext, formatScoped, result, type TeamContext } from "./te
|
|
|
40
43
|
// Lazy-loaded: run.ts pulls in spawnBackgroundTeamRun, resolveCrewRuntime, etc.
|
|
41
44
|
// Static import fails silently in some jiti contexts (child-process), leaving handleRun undefined.
|
|
42
45
|
import type { handleRun as _handleRunFn } from "./team-tool/run.ts";
|
|
46
|
+
import { resolveRunDeadline } from "./team-tool/run-deadline.ts";
|
|
43
47
|
|
|
44
48
|
type HandleRunFn = typeof _handleRunFn;
|
|
45
49
|
async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<ReturnType<HandleRunFn>>> {
|
|
@@ -315,12 +319,318 @@ async function recoverCheckpointedTasks(
|
|
|
315
319
|
return { manifest: nextManifest, tasks: nextTasks, recovered };
|
|
316
320
|
}
|
|
317
321
|
|
|
322
|
+
/** PID liveness probe — same semantics as filterAliveEntries: only ESRCH/ENOENT
|
|
323
|
+
* mean "process does not exist"; EPERM means alive in another security context. */
|
|
324
|
+
function isPidAliveForResume(pid: number): boolean {
|
|
325
|
+
try {
|
|
326
|
+
process.kill(pid, 0);
|
|
327
|
+
return true;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
330
|
+
return code !== "ESRCH" && code !== "ENOENT";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** G12 (SDD-3 W-C WI-1): liveness evidence for a run being resumed. Returns a
|
|
335
|
+
* human-readable refusal message when the run is LIVE, undefined when it is
|
|
336
|
+
* safe to resume. force:true bypasses OWNERSHIP, never LIVENESS — callers must
|
|
337
|
+
* refuse regardless of force when this returns a message.
|
|
338
|
+
*
|
|
339
|
+
* Three independent signals (any one ⇒ live):
|
|
340
|
+
* 1. active-run-registry entry (activeRunEntries already applies the full
|
|
341
|
+
* filter: terminal status, dead async PID, >30-min staleness);
|
|
342
|
+
* 2. a running task holding a PRESENT, UNEXPIRED worker claim (task-claims
|
|
343
|
+
* lease). Review fix 2026-10-01 (MAJOR): the claim must EXIST — coalesced
|
|
344
|
+
* dispatch (run-coalesced-task-group.ts) and the task-graph scheduler mark
|
|
345
|
+
* tasks running WITHOUT claims, so counting claim-less running tasks as
|
|
346
|
+
* live permanently refused resume of crashed coalesced runs (with a
|
|
347
|
+
* misleading "until undefined" message). Runs dispatched by real engines
|
|
348
|
+
* carry the registry (signal 1) / async-PID (signal 3) liveness instead;
|
|
349
|
+
* 3. the manifest's detached async PID is alive and the manifest is fresh
|
|
350
|
+
* (covers goal-loop/DWF background runs whose registry entry was pruned).
|
|
351
|
+
* Terminal runs (completed/failed/cancelled) never count as live via signal 3
|
|
352
|
+
* — a lingering post-finalize PID must not block legitimate re-runs. */
|
|
353
|
+
function resumeLivenessRefusal(manifest: TeamRunManifest, tasks: TeamTaskState[]): string | undefined {
|
|
354
|
+
const runId = manifest.runId;
|
|
355
|
+
const registryEntry = activeRunEntries().find((entry) => entry.runId === runId);
|
|
356
|
+
if (registryEntry) {
|
|
357
|
+
return [
|
|
358
|
+
`Run ${runId} is still live (active-run registry, last heartbeat ${registryEntry.updatedAt}) — resume refused to prevent double-dispatch.`,
|
|
359
|
+
`Current status: ${manifest.status}. Wait for it to finish, or cancel it first (team action=cancel runId=${runId}).`,
|
|
360
|
+
"force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
|
|
361
|
+
].join("\n");
|
|
362
|
+
}
|
|
363
|
+
const claimed = tasks.find(
|
|
364
|
+
// Review fix 2026-10-01 (MAJOR): require the claim to EXIST — see the
|
|
365
|
+
// signal-2 note above (claim-less running tasks come from coalesced
|
|
366
|
+
// dispatch / scheduler markings and must not read as permanently live).
|
|
367
|
+
(task) => task.status === "running" && task.claim !== undefined && !isTaskClaimExpired(task.claim),
|
|
368
|
+
);
|
|
369
|
+
if (claimed) {
|
|
370
|
+
return [
|
|
371
|
+
`Run ${runId} is still live (task '${claimed.id}' holds an unexpired worker claim until ${claimed.claim?.leasedUntil}) — resume refused to prevent double-dispatch.`,
|
|
372
|
+
`Current status: ${manifest.status}. Wait for the worker to finish, or cancel the run first (team action=cancel runId=${runId}).`,
|
|
373
|
+
"force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
|
|
374
|
+
].join("\n");
|
|
375
|
+
}
|
|
376
|
+
const pid = manifest.async?.pid;
|
|
377
|
+
if (
|
|
378
|
+
typeof pid === "number" &&
|
|
379
|
+
Number.isInteger(pid) &&
|
|
380
|
+
pid > 0 &&
|
|
381
|
+
manifest.status !== "completed" &&
|
|
382
|
+
manifest.status !== "failed" &&
|
|
383
|
+
manifest.status !== "cancelled"
|
|
384
|
+
) {
|
|
385
|
+
const updatedAt = Date.parse(manifest.updatedAt);
|
|
386
|
+
// NIT 1 fix: shared registry constant — the resume horizon can never drift
|
|
387
|
+
// from filterAliveEntries' staleness horizon.
|
|
388
|
+
const fresh = Number.isFinite(updatedAt) && Date.now() - updatedAt <= ACTIVE_RUN_STALE_MS;
|
|
389
|
+
if (fresh && isPidAliveForResume(pid)) {
|
|
390
|
+
return [
|
|
391
|
+
`Run ${runId} is still live (background process PID ${pid} is alive) — resume refused to prevent double-dispatch.`,
|
|
392
|
+
`Current status: ${manifest.status}. Wait for the background run to finish, or cancel it first (team action=cancel runId=${runId}).`,
|
|
393
|
+
"force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
|
|
394
|
+
].join("\n");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** G11 (SDD-3 W-C WI-2): adopt ownership + refresh heartbeat for a special-kind
|
|
401
|
+
* (goal-loop / dynamic-workflow) resume, under the same per-run lock semantics
|
|
402
|
+
* as the static path. Mirrors the static path's B1 adoption (battery 2026-08-18
|
|
403
|
+
* case b): a force-resumed run must not keep its dead original ownerSessionId,
|
|
404
|
+
* or a third session's orphan-scan cancels the live resumed run. The adopted
|
|
405
|
+
* manifest PRESERVES the original runKind — the whole point of the branch. */
|
|
406
|
+
async function adoptSpecialKindRunForResume(
|
|
407
|
+
manifest: TeamRunManifest,
|
|
408
|
+
runKind: "goal-loop" | "dynamic-workflow",
|
|
409
|
+
ctx: TeamContext,
|
|
410
|
+
): Promise<TeamRunManifest> {
|
|
411
|
+
return await withRunLock(manifest, async () => {
|
|
412
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
413
|
+
const base = fresh?.manifest ?? manifest;
|
|
414
|
+
// W-C2 (SDD-4 WI-6): drop the dispatch-time detached-runner pointer. Resume
|
|
415
|
+
// arms re-execute IN THIS session's process (runGoalLoop /
|
|
416
|
+
// runDynamicWorkflow) — no runner is re-spawned — yet goal-wrapped runs carry
|
|
417
|
+
// `async: { pid }` from their original background spawn. A dead pid on an
|
|
418
|
+
// adopted "running" manifest is exactly the state that
|
|
419
|
+
// transitionStaleAsyncUnderLock (status.ts) flips to failed on the next
|
|
420
|
+
// status poll ("Async process stale: process does not exist"). The drop is
|
|
421
|
+
// audited on the run.resume_requested event (clearedAsyncPid).
|
|
422
|
+
const adoptedBase: TeamRunManifest = { ...base };
|
|
423
|
+
const staleAsyncPid = adoptedBase.async?.pid;
|
|
424
|
+
delete adoptedBase.async;
|
|
425
|
+
const adopted: TeamRunManifest = {
|
|
426
|
+
...adoptedBase,
|
|
427
|
+
// WI-2 (G11): keep the ORIGINAL runKind — an accidental fallback to
|
|
428
|
+
// "team-run" would route the NEXT resume through the static path.
|
|
429
|
+
runKind,
|
|
430
|
+
// Mark running at adoption: registerActiveRun refuses terminal
|
|
431
|
+
// entries, and the run IS executing from this dispatch until terminal.
|
|
432
|
+
// allowTerminalExit mirrors the static resume path (finding 8 write
|
|
433
|
+
// guard — resume is the one legitimate terminal-exit flow).
|
|
434
|
+
status: "running",
|
|
435
|
+
summary: `Resuming ${runKind} run.`,
|
|
436
|
+
updatedAt: new Date().toISOString(),
|
|
437
|
+
...(ctx.sessionId ? { ownerSessionId: ctx.sessionId } : {}),
|
|
438
|
+
};
|
|
439
|
+
await saveRunManifestAsync(adopted, { allowTerminalExit: true });
|
|
440
|
+
await appendEventAsync(adopted.eventsPath, {
|
|
441
|
+
type: "run.resume_requested",
|
|
442
|
+
runId: adopted.runId,
|
|
443
|
+
data: { runKind, action: "resume", ...(staleAsyncPid !== undefined ? { clearedAsyncPid: staleAsyncPid } : {}) },
|
|
444
|
+
});
|
|
445
|
+
return adopted;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** G11 (SDD-3 W-C WI-2): resume arm for runKind="dynamic-workflow" — mirrors
|
|
450
|
+
* run.ts's DWF dispatch (:390-435): re-synthesize the dynamic team, adopt the
|
|
451
|
+
* run, then hand off to runDynamicWorkflow (which hydrates the dwf-checkpoint
|
|
452
|
+
* state, dwf-runner.ts round-18 P2-3). The static team lookup CANNOT resolve
|
|
453
|
+
* the synthetic `dwf-*` team, which is why this branch exists. */
|
|
454
|
+
async function resumeDynamicWorkflowRun(
|
|
455
|
+
params: TeamToolParamsValue,
|
|
456
|
+
ctx: TeamContext,
|
|
457
|
+
manifest: TeamRunManifest,
|
|
458
|
+
): Promise<PiTeamsToolResult> {
|
|
459
|
+
const dwfWorkflow = allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === manifest.workflow);
|
|
460
|
+
if (dwfWorkflow?.runtime !== "dynamic" || !dwfWorkflow?.dynamicScript) {
|
|
461
|
+
return result(
|
|
462
|
+
`Workflow '${manifest.workflow ?? ""}' is not a dynamic workflow (runKind=dynamic-workflow); cannot resume run ${manifest.runId}. Fix or restore the workflow file, or re-dispatch with action=run.`,
|
|
463
|
+
{ action: "resume", status: "error", runId: manifest.runId },
|
|
464
|
+
true,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
// Re-synthesize the dynamic team (§0c C9 mirror of run.ts) — role resolution only.
|
|
468
|
+
const dwfTeam: TeamConfig = {
|
|
469
|
+
name: manifest.team,
|
|
470
|
+
description: `Dynamic workflow run for ${dwfWorkflow.name}`,
|
|
471
|
+
source: "dynamic",
|
|
472
|
+
filePath: "<dynamic-workflow>",
|
|
473
|
+
roles: [{ name: "worker", agent: params.agent ?? "executor" }],
|
|
474
|
+
workspaceMode: "single",
|
|
475
|
+
};
|
|
476
|
+
const adopted = await adoptSpecialKindRunForResume(manifest, "dynamic-workflow", ctx);
|
|
477
|
+
registerActiveRun(adopted);
|
|
478
|
+
// CORE-8 mirror: unified deadline (params.timeoutMs > config.maxRunMinutes > 1h default).
|
|
479
|
+
const dwfDeadline = resolveRunDeadline(ctx, params);
|
|
480
|
+
try {
|
|
481
|
+
// LAZY: defer dynamic import of the DWF runner to its call site (mirrors run.ts:399).
|
|
482
|
+
const { runDynamicWorkflow } = await import("../runtime/goal-workflow/dynamic-workflow-runner.ts");
|
|
483
|
+
const dwfResult = await runDynamicWorkflow({
|
|
484
|
+
manifest: adopted,
|
|
485
|
+
workflow: dwfWorkflow as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
|
|
486
|
+
team: dwfTeam,
|
|
487
|
+
signal: dwfDeadline.signal,
|
|
488
|
+
modelOverride: params.model,
|
|
489
|
+
tokenBudget:
|
|
490
|
+
params.tokenBudget ?? (dwfWorkflow as import("../workflows/workflow-config.ts").DynamicWorkflowConfig).maxTokenBudget,
|
|
491
|
+
});
|
|
492
|
+
await saveRunManifestAsync(dwfResult.manifest);
|
|
493
|
+
return result(
|
|
494
|
+
[
|
|
495
|
+
`Resumed dynamic-workflow run ${dwfResult.manifest.runId}.`,
|
|
496
|
+
`Status: ${dwfResult.manifest.status}`,
|
|
497
|
+
dwfResult.manifest.summary ? `Result: ${dwfResult.manifest.summary}` : undefined,
|
|
498
|
+
]
|
|
499
|
+
.filter((line): line is string => line !== undefined)
|
|
500
|
+
.join("\n"),
|
|
501
|
+
{
|
|
502
|
+
action: "resume",
|
|
503
|
+
status: dwfResult.manifest.status === "failed" ? "error" : "ok",
|
|
504
|
+
runId: dwfResult.manifest.runId,
|
|
505
|
+
artifactsRoot: dwfResult.manifest.artifactsRoot,
|
|
506
|
+
},
|
|
507
|
+
dwfResult.manifest.status === "failed",
|
|
508
|
+
);
|
|
509
|
+
} catch (runnerError) {
|
|
510
|
+
// Round-11 runtime-fix mirror (run.ts): persist the failure instead of
|
|
511
|
+
// leaving the manifest at its pre-resume status forever.
|
|
512
|
+
const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
|
|
513
|
+
const failedManifest = {
|
|
514
|
+
...adopted,
|
|
515
|
+
status: "failed" as const,
|
|
516
|
+
summary: `Dynamic workflow '${dwfWorkflow.name}' resume failed: ${failureReason}`.slice(0, 2000),
|
|
517
|
+
updatedAt: new Date().toISOString(),
|
|
518
|
+
};
|
|
519
|
+
await saveRunManifestAsync(failedManifest);
|
|
520
|
+
return result(
|
|
521
|
+
`Dynamic workflow '${dwfWorkflow.name}' resume failed: ${failureReason}`,
|
|
522
|
+
{ action: "resume", status: "error", runId: failedManifest.runId, artifactsRoot: failedManifest.artifactsRoot },
|
|
523
|
+
true,
|
|
524
|
+
);
|
|
525
|
+
} finally {
|
|
526
|
+
unregisterActiveRun(adopted.runId);
|
|
527
|
+
clearTimeout(dwfDeadline.timer); // RC-02
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** G11 (SDD-3 W-C WI-2): resume arm for runKind="goal-loop" — mirrors the
|
|
532
|
+
* background-runner.ts goal-loop short-circuit (:492): hydrate GoalLoopState,
|
|
533
|
+
* continue the loop via runGoalLoop, map the goal outcome to a run status. */
|
|
534
|
+
async function resumeGoalLoopRun(params: TeamToolParamsValue, ctx: TeamContext, manifest: TeamRunManifest): Promise<PiTeamsToolResult> {
|
|
535
|
+
// Check the goal state BEFORE adoption: a missing GoalLoopState is not
|
|
536
|
+
// resumable at all, so the run must be left untouched (no ownership flip,
|
|
537
|
+
// no status change) for a clearer retry story.
|
|
538
|
+
// LAZY: defer heavy goal-loop imports to the call site (mirrors background-runner.ts:498-506).
|
|
539
|
+
const { GoalStore } = await import("../runtime/goal-workflow/goal-state-store.ts");
|
|
540
|
+
const store = new GoalStore(manifest.cwd);
|
|
541
|
+
const goalState = store.load(manifest.runId);
|
|
542
|
+
if (!goalState) {
|
|
543
|
+
return result(
|
|
544
|
+
`runKind="goal-loop" but GoalLoopState '${manifest.runId}' not found (cwd=${manifest.cwd}); cannot resume. The goal state file may have been pruned — start a new goal run instead.`,
|
|
545
|
+
{ action: "resume", status: "error", runId: manifest.runId },
|
|
546
|
+
true,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const adopted = await adoptSpecialKindRunForResume(manifest, "goal-loop", ctx);
|
|
550
|
+
// LAZY: the runner import stays after the state check for fast failure.
|
|
551
|
+
const { runGoalLoop } = await import("../runtime/goal-workflow/goal-loop-runner.ts");
|
|
552
|
+
registerActiveRun(adopted);
|
|
553
|
+
// CORE-8 mirror (review MINOR 4): honor params.timeoutMs like the DWF arm —
|
|
554
|
+
// previously this arm resolved the deadline with {} and silently ignored the
|
|
555
|
+
// caller's timeout override.
|
|
556
|
+
const goalDeadline = resolveRunDeadline(ctx, params);
|
|
557
|
+
try {
|
|
558
|
+
const goalResult = await runGoalLoop({
|
|
559
|
+
goalState,
|
|
560
|
+
manifest: adopted,
|
|
561
|
+
signal: goalDeadline.signal,
|
|
562
|
+
deps: {
|
|
563
|
+
discoverAgents: (cwd: string) => allAgents(discoverAgents(cwd)),
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
// Fix P1-1 + round-6 #5 mirror (background-runner.ts:525-536): persist the
|
|
567
|
+
// terminal status reflecting the goal's actual outcome, not a blanket
|
|
568
|
+
// 'completed'.
|
|
569
|
+
const goalStatusToRunStatus: Record<string, TeamRunManifest["status"]> = {
|
|
570
|
+
achieved: "completed",
|
|
571
|
+
max_turns: "completed",
|
|
572
|
+
budget_exceeded: "completed",
|
|
573
|
+
blocked: "blocked",
|
|
574
|
+
cancelled: "cancelled",
|
|
575
|
+
paused: "blocked",
|
|
576
|
+
running: "running",
|
|
577
|
+
};
|
|
578
|
+
const runStatus = goalStatusToRunStatus[goalResult.goalState.state] ?? "completed";
|
|
579
|
+
const finalManifest: TeamRunManifest = {
|
|
580
|
+
...goalResult.manifest,
|
|
581
|
+
status: runStatus,
|
|
582
|
+
updatedAt: new Date().toISOString(),
|
|
583
|
+
};
|
|
584
|
+
await saveRunManifestAsync(finalManifest);
|
|
585
|
+
return result(
|
|
586
|
+
[
|
|
587
|
+
`Resumed goal-loop run ${finalManifest.runId}.`,
|
|
588
|
+
`Status: ${finalManifest.status} (goal state: ${goalResult.goalState.state})`,
|
|
589
|
+
].join("\n"),
|
|
590
|
+
{
|
|
591
|
+
action: "resume",
|
|
592
|
+
status: runStatus === "failed" ? "error" : "ok",
|
|
593
|
+
runId: finalManifest.runId,
|
|
594
|
+
artifactsRoot: finalManifest.artifactsRoot,
|
|
595
|
+
},
|
|
596
|
+
runStatus === "failed",
|
|
597
|
+
);
|
|
598
|
+
} catch (runnerError) {
|
|
599
|
+
// Round-11 runtime-fix mirror (DWF arm + review MINOR 1): persist the
|
|
600
|
+
// failure instead of leaving the adopted manifest stuck at
|
|
601
|
+
// status:"running" with a live registry entry until the stale horizon.
|
|
602
|
+
const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
|
|
603
|
+
const failedManifest = {
|
|
604
|
+
...adopted,
|
|
605
|
+
status: "failed" as const,
|
|
606
|
+
summary: `Goal-loop resume failed: ${failureReason}`.slice(0, 2000),
|
|
607
|
+
updatedAt: new Date().toISOString(),
|
|
608
|
+
};
|
|
609
|
+
await saveRunManifestAsync(failedManifest);
|
|
610
|
+
return result(
|
|
611
|
+
`Goal-loop run ${failedManifest.runId} resume failed: ${failureReason}`,
|
|
612
|
+
{ action: "resume", status: "error", runId: failedManifest.runId, artifactsRoot: failedManifest.artifactsRoot },
|
|
613
|
+
true,
|
|
614
|
+
);
|
|
615
|
+
} finally {
|
|
616
|
+
unregisterActiveRun(adopted.runId);
|
|
617
|
+
clearTimeout(goalDeadline.timer); // RC-02
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
318
621
|
export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
319
622
|
if (!params.runId) return result("Resume requires runId.", { action: "resume", status: "error" }, true);
|
|
320
623
|
const runCwd = locateRunCwd(params.runId, ctx.cwd);
|
|
321
624
|
if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
|
|
322
625
|
const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
323
626
|
if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
|
|
627
|
+
// G12 (SDD-3 W-C WI-1): liveness-first gate — BEFORE the ownership check and
|
|
628
|
+
// BEFORE any reset/dispatch. executeTeamRun runs OUTSIDE the resume lock
|
|
629
|
+
// (LOCK-2 below), so a resume accepted against a live run double-dispatches
|
|
630
|
+
// workers (duplicate tokens + duplicate side effects). force:true bypasses
|
|
631
|
+
// OWNERSHIP only — never LIVENESS — so the refusal applies even when forced.
|
|
632
|
+
const liveness = resumeLivenessRefusal(loaded.manifest, loaded.tasks);
|
|
633
|
+
if (liveness) return result(liveness, { action: "resume", status: "error", runId: loaded.manifest.runId }, true);
|
|
324
634
|
// R1: foreign-ownership check — mirrors handleRetry/handleCancel. Without it,
|
|
325
635
|
// another session can resume (and re-execute) a run it doesn't own, racing
|
|
326
636
|
// the owning session.
|
|
@@ -332,8 +642,40 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
332
642
|
true,
|
|
333
643
|
);
|
|
334
644
|
}
|
|
645
|
+
// G4 (SDD-3 W-C WI-1): force-on-foreign is an authorization override — liveness
|
|
646
|
+
// was already REFUSED above, so force only bypassed OWNERSHIP here. Record it
|
|
647
|
+
// as a security event (registered in TEAM_EVENT_TYPES) so cross-session forced
|
|
648
|
+
// resumes are auditable; never silent.
|
|
649
|
+
if (foreignRun && params.force) {
|
|
650
|
+
await appendEventAsync(loaded.manifest.eventsPath, {
|
|
651
|
+
type: "run.resume_forced_foreign",
|
|
652
|
+
runId: loaded.manifest.runId,
|
|
653
|
+
message: `Foreign run ${loaded.manifest.runId} force-resumed by session ${ctx.sessionId} (owner ${loaded.manifest.ownerSessionId}).`,
|
|
654
|
+
data: {
|
|
655
|
+
action: "resume",
|
|
656
|
+
forced: true,
|
|
657
|
+
ownerSessionId: loaded.manifest.ownerSessionId,
|
|
658
|
+
forcingSessionId: ctx.sessionId,
|
|
659
|
+
runStatus: loaded.manifest.status,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
335
663
|
if (!loaded.manifest.workflow)
|
|
336
664
|
return result(`Run '${params.runId}' has no workflow to resume.`, { action: "resume", status: "error" }, true);
|
|
665
|
+
// G11 (SDD-3 W-C WI-2): goal-loop and dynamic-workflow runs use SYNTHETIC
|
|
666
|
+
// team names (goal-*/dwf-*) that never appear in discoverTeams — the static
|
|
667
|
+
// team lookup below fails with "Team not found" and the run is unresumable
|
|
668
|
+
// even though both engines have resume support (DWF checkpoint hydration,
|
|
669
|
+
// goal-loop state). Branch on the ORIGINAL runKind (mirrors run.ts:390-435
|
|
670
|
+
// dispatch and background-runner.ts:492 short-circuit); the resumed run
|
|
671
|
+
// PRESERVES its runKind (see adoptSpecialKindRunForResume).
|
|
672
|
+
const resumeRunKind = loaded.manifest.runKind ?? "team-run";
|
|
673
|
+
if (resumeRunKind === "dynamic-workflow") {
|
|
674
|
+
return await resumeDynamicWorkflowRun(params, ctx, loaded.manifest);
|
|
675
|
+
}
|
|
676
|
+
if (resumeRunKind === "goal-loop") {
|
|
677
|
+
return await resumeGoalLoopRun(params, ctx, loaded.manifest);
|
|
678
|
+
}
|
|
337
679
|
const agents = allAgents(discoverAgents(ctx.cwd));
|
|
338
680
|
const direct = directTeamAndWorkflowFromRun(loaded.manifest, loaded.tasks, agents);
|
|
339
681
|
const team = direct?.team ?? allTeams(discoverTeams(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.team);
|
|
@@ -359,9 +701,36 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
359
701
|
const fresh = loadRunManifestById(runCwd, loaded.manifest.runId);
|
|
360
702
|
const lockedManifest = fresh?.manifest ?? loaded.manifest;
|
|
361
703
|
const lockedTasks = fresh?.tasks ?? loaded.tasks;
|
|
704
|
+
// F1 (2026-10-01 security review): the PRE-lock liveness gate raced
|
|
705
|
+
// concurrent dispatch — between the pre-lock load and this lock acquisition
|
|
706
|
+
// another session may have adopted and REGISTERED the run. Re-check
|
|
707
|
+
// liveness against the LOCKED state before any recovery/reset mutation;
|
|
708
|
+
// force:true still never bypasses liveness (G12 principle).
|
|
709
|
+
const lockedLiveness = resumeLivenessRefusal(lockedManifest, lockedTasks);
|
|
710
|
+
if (lockedLiveness) {
|
|
711
|
+
return {
|
|
712
|
+
kind: "blocked" as const,
|
|
713
|
+
payload: result(lockedLiveness, { action: "resume", status: "error", runId: lockedManifest.runId }, true),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
362
716
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
363
717
|
const recovered = await recoverCheckpointedTasks(lockedManifest, lockedTasks);
|
|
364
718
|
const resumeManifest = recovered.manifest;
|
|
719
|
+
// W-C2 (SDD-4 WI-6): drop the stale detached-runner pointer — resume never
|
|
720
|
+
// re-spawns a background runner (executeTeamRun below runs in THIS
|
|
721
|
+
// session's process), yet the adopted manifest kept the dispatch-time
|
|
722
|
+
// `async: { pid }` block pointing at the (now dead) original runner. During
|
|
723
|
+
// the re-execution window (status=running) any status poll read that dead
|
|
724
|
+
// pid and transitionStaleAsyncUnderLock flipped the LIVE resumed run to
|
|
725
|
+
// failed ("Async process stale: process does not exist", cancelling running
|
|
726
|
+
// tasks) — real-test battery 2026-10-02 finding 1: resume of a completed
|
|
727
|
+
// async run died 14s in while the sync contrast passed. Invariant restored:
|
|
728
|
+
// manifest.async is present ONLY while a detached runner owns execution; a
|
|
729
|
+
// resumed run's liveness is carried by registerActiveRun + ownerSessionId.
|
|
730
|
+
// The drop is audited on the run.resume_requested event (clearedAsyncPid).
|
|
731
|
+
const resumeBase: TeamRunManifest = { ...resumeManifest };
|
|
732
|
+
const staleAsyncPid = resumeBase.async?.pid;
|
|
733
|
+
delete resumeBase.async;
|
|
365
734
|
const executedConfig = {
|
|
366
735
|
...effectiveRunConfig(loadedConfig.config, params.config),
|
|
367
736
|
};
|
|
@@ -382,7 +751,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
382
751
|
const runtime = await resolveCrewRuntime(executedConfig);
|
|
383
752
|
const runtimeResolution = runtimeResolutionState(runtime);
|
|
384
753
|
const runtimeManifest = {
|
|
385
|
-
...
|
|
754
|
+
...resumeBase,
|
|
386
755
|
runtimeResolution,
|
|
387
756
|
updatedAt: new Date().toISOString(),
|
|
388
757
|
// B1 battery 2026-08-18 (case b root-cause fix): resume ADOPTS the run —
|
|
@@ -458,6 +827,9 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
458
827
|
data: {
|
|
459
828
|
replayedMailboxMessages: replay.messages.length,
|
|
460
829
|
recoveredCheckpointTasks: recovered.recovered,
|
|
830
|
+
// W-C2 (SDD-4 WI-6): audit trail for the stale detached-runner pointer
|
|
831
|
+
// dropped at adoption (see resumeBase above).
|
|
832
|
+
...(staleAsyncPid !== undefined ? { clearedAsyncPid: staleAsyncPid } : {}),
|
|
461
833
|
},
|
|
462
834
|
});
|
|
463
835
|
if (recovered.recovered.length)
|
|
@@ -479,10 +851,28 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
479
851
|
});
|
|
480
852
|
const executeWorkers = runtime.kind !== "scaffold";
|
|
481
853
|
const resumeSkillOverride = normalizeSkillOverride(params.skill) ?? runtimeManifest.skillOverride;
|
|
482
|
-
|
|
854
|
+
// F1 (2026-10-01 security review): adopt running + REGISTER in the
|
|
855
|
+
// cross-process active-run registry INSIDE the lock, mirroring run.ts
|
|
856
|
+
// dispatch (:388) and adoptSpecialKindRunForResume. The static path
|
|
857
|
+
// previously never registered, so during foreground executeTeamRun (which
|
|
858
|
+
// runs OUTSIDE the lock) a concurrent resume saw NO cross-process liveness
|
|
859
|
+
// signal until the first task claim appeared — the double-dispatch window
|
|
860
|
+
// (even with force:true). Terminal finalize unregisters (updateRunStatus →
|
|
861
|
+
// unregisterActiveRun); a resume that crashes mid-execution ages out via
|
|
862
|
+
// the registry's staleness horizon. registerActiveRun throwing here (a
|
|
863
|
+
// raced external cancel flipped the disk manifest terminal) is intentional
|
|
864
|
+
// fail-safe: the other writer won the race, so we must not dispatch.
|
|
865
|
+
const executingManifest = updateRunStatus(
|
|
866
|
+
runtimeManifest,
|
|
867
|
+
"running",
|
|
868
|
+
`Resuming run (adopted by session ${ctx.sessionId ?? "unknown"}).`,
|
|
869
|
+
// Finding 8 write guard: resume is the legitimate terminal-exit flow.
|
|
870
|
+
{ allowTerminalExit: true },
|
|
871
|
+
);
|
|
872
|
+
registerActiveRun(executingManifest);
|
|
483
873
|
return {
|
|
484
874
|
kind: "execute" as const,
|
|
485
|
-
runtimeManifest,
|
|
875
|
+
runtimeManifest: executingManifest,
|
|
486
876
|
resetTasks,
|
|
487
877
|
executeWorkers,
|
|
488
878
|
resumeSkillOverride,
|
|
@@ -36,6 +36,7 @@ import type { CrewWebhookConfig } from "../config/types.ts";
|
|
|
36
36
|
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
37
37
|
import type { TeamTaskState } from "../state/types.ts";
|
|
38
38
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
39
|
+
import { embeddedIpv4FromGroups, parseIpv6Literal } from "../utils/ip-literal.ts";
|
|
39
40
|
import { isInQuietHours } from "./notification-router.ts";
|
|
40
41
|
|
|
41
42
|
/** Documented payload (spec §Design). Field order matches the spec example. */
|
|
@@ -139,7 +140,28 @@ function isLocalAddress(hostname: string): boolean {
|
|
|
139
140
|
const firstHextet = bare.split(":").find((segment) => segment.length > 0);
|
|
140
141
|
if (firstHextet && /^[0-9a-f]{1,4}$/.test(firstHextet)) {
|
|
141
142
|
const value = Number.parseInt(firstHextet, 16);
|
|
142
|
-
|
|
143
|
+
if (value >= 0xfe80 && value <= 0xfebf) return true;
|
|
144
|
+
}
|
|
145
|
+
// G6: classify any remaining IPv6 literal through the shared parser —
|
|
146
|
+
// the textual checks above match only a subset of spellings, so
|
|
147
|
+
// `::127.0.0.1` (IPv4-compatible loopback), `0:0:0:0:0:0:0:1` (expanded
|
|
148
|
+
// ::1) and full-form mapped addresses slipped through. Same predicate
|
|
149
|
+
// scope as above: loopback/unspecified/link-local + the local part of an
|
|
150
|
+
// embedded IPv4 (RFC1918 deliberately stays allowed — internal
|
|
151
|
+
// webhooks are a use case).
|
|
152
|
+
const groups = parseIpv6Literal(bare);
|
|
153
|
+
if (groups) {
|
|
154
|
+
if (groups.every((group) => group === 0)) return true;
|
|
155
|
+
if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true;
|
|
156
|
+
if ((groups[0] & 0xffc0) === 0xfe80) return true;
|
|
157
|
+
const embedded = embeddedIpv4FromGroups(groups);
|
|
158
|
+
if (embedded) {
|
|
159
|
+
const v4 = ipv4Octets(embedded);
|
|
160
|
+
if (v4) {
|
|
161
|
+
const [a, b] = v4;
|
|
162
|
+
return a === 127 || a === 0 || (a === 169 && b === 254);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
143
165
|
}
|
|
144
166
|
return false;
|
|
145
167
|
}
|
|
@@ -2,6 +2,7 @@ import { lookup } from "node:dns/promises";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { gzip } from "node:zlib";
|
|
4
4
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
5
|
+
import { bracketIpv6InUrl, embeddedIpv4FromGroups, parseIpv6Literal, unbracketHost } from "../../utils/ip-literal.ts";
|
|
5
6
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
6
7
|
import type { MetricRegistry } from "../metric-registry.ts";
|
|
7
8
|
import type { MetricSnapshot } from "../metrics-primitives.ts";
|
|
@@ -17,6 +18,10 @@ const gzipAsync = promisify(gzip);
|
|
|
17
18
|
* Only http:// and https:// to public hostnames are allowed.
|
|
18
19
|
*/
|
|
19
20
|
export function validateEndpoint(endpoint: string): void {
|
|
21
|
+
// G6: a bare IPv6 literal in the authority (`http://::1:4318`) makes
|
|
22
|
+
// `new URL` throw — bracket it first so the classifier sees the literal
|
|
23
|
+
// instead of a generic invalid-URL error (RFC 3986 §3.2.2 host ABNF).
|
|
24
|
+
endpoint = bracketIpv6InUrl(endpoint);
|
|
20
25
|
let url: URL;
|
|
21
26
|
try {
|
|
22
27
|
url = new URL(endpoint);
|
|
@@ -67,6 +72,13 @@ export function validateEndpoint(endpoint: string): void {
|
|
|
67
72
|
if (lower.startsWith("::ffff:")) {
|
|
68
73
|
throw new Error(`OTLP endpoint must not target IPv4-mapped IPv6 address: ${endpoint}`);
|
|
69
74
|
}
|
|
75
|
+
// G6: catch every literal form the prefix checks above miss —
|
|
76
|
+
// IPv4-compatible `::a.b.c.d` (e.g. ::127.0.0.1, ::169.254.169.254),
|
|
77
|
+
// expanded spellings (`0:0:0:0:0:0:0:1`), zone-ids — through the
|
|
78
|
+
// shared parser, applying the same predicates as the IPv4 branch.
|
|
79
|
+
if (isPrivateIpv6Literal(bare)) {
|
|
80
|
+
throw new Error(`OTLP endpoint must not target private/reserved IPv6 address: ${endpoint}`);
|
|
81
|
+
}
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// Reject IPv4 private/reserved ranges
|
|
@@ -101,6 +113,63 @@ export function validateEndpoint(endpoint: string): void {
|
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
/**
|
|
117
|
+
* IPv4 predicate shared by the IPv6 literal classifier below: loopback,
|
|
118
|
+
* private (RFC1918), link-local/metadata, this-network. Mirrors the ranges
|
|
119
|
+
* checked textually in `validateEndpoint`.
|
|
120
|
+
*/
|
|
121
|
+
function isPrivateIpv4(address: string): boolean {
|
|
122
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
|
|
123
|
+
if (!match) return false;
|
|
124
|
+
const o0 = Number(match[1]);
|
|
125
|
+
const o1 = Number(match[2]);
|
|
126
|
+
if (o0 === 127 || o0 === 0) return true;
|
|
127
|
+
if (o0 === 10) return true;
|
|
128
|
+
if (o0 === 172 && o1 >= 16 && o1 <= 31) return true;
|
|
129
|
+
if (o0 === 192 && o1 === 168) return true;
|
|
130
|
+
if (o0 === 169 && o1 === 254) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* G6: classify a parsed IPv6 literal (8 groups) as private/reserved using
|
|
136
|
+
* the same predicates as the IPv4 branch. Catches the forms the textual
|
|
137
|
+
* prefix checks miss: IPv4-compatible `::a.b.c.d`, IPv4-mapped
|
|
138
|
+
* `::ffff:a.b.c.d` (refused wholesale, as before), expanded spellings of
|
|
139
|
+
* `::1` / ULA / link-local / site-local / multicast, and zone-ids.
|
|
140
|
+
*/
|
|
141
|
+
function isPrivateIpv6Groups(groups: number[]): boolean {
|
|
142
|
+
// Unspecified `::`.
|
|
143
|
+
if (groups.every((group) => group === 0)) return true;
|
|
144
|
+
// Loopback `::1`.
|
|
145
|
+
if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true;
|
|
146
|
+
// IPv4-mapped `::ffff:a.b.c.d` — refused wholesale (existing rule).
|
|
147
|
+
if (groups[5] === 0xffff && groups.slice(0, 5).every((group) => group === 0)) return true;
|
|
148
|
+
// IPv4-compatible `::a.b.c.d` — map to IPv4, apply the IPv4 predicates.
|
|
149
|
+
const embedded = embeddedIpv4FromGroups(groups);
|
|
150
|
+
if (embedded && isPrivateIpv4(embedded)) return true;
|
|
151
|
+
// Unique local fc00::/7.
|
|
152
|
+
if ((groups[0] & 0xfe00) === 0xfc00) return true;
|
|
153
|
+
// Link-local fe80::/10.
|
|
154
|
+
if ((groups[0] & 0xffc0) === 0xfe80) return true;
|
|
155
|
+
// Site-local fec0::/10 (deprecated).
|
|
156
|
+
if ((groups[0] & 0xffc0) === 0xfec0) return true;
|
|
157
|
+
// Multicast ff00::/8.
|
|
158
|
+
if ((groups[0] & 0xff00) === 0xff00) return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* G6: classify ANY IPv6 literal spelling (bracketed or bare) as
|
|
164
|
+
* private/reserved. Non-literals (hostnames, plain IPv4) return false —
|
|
165
|
+
* those are handled by `validateEndpoint`'s IPv4 branch and the DNS
|
|
166
|
+
* rebinding guard.
|
|
167
|
+
*/
|
|
168
|
+
function isPrivateIpv6Literal(host: string): boolean {
|
|
169
|
+
const groups = parseIpv6Literal(unbracketHost(host));
|
|
170
|
+
return groups !== undefined && isPrivateIpv6Groups(groups);
|
|
171
|
+
}
|
|
172
|
+
|
|
104
173
|
/**
|
|
105
174
|
* CFG-6: classify a single IP address (IPv4 or IPv6) as private / reserved /
|
|
106
175
|
* loopback / link-local. Used by the runtime DNS-rebinding guard below to
|
|
@@ -122,14 +191,11 @@ export function isPrivateIpAddress(address: string): boolean {
|
|
|
122
191
|
if (o0 === 169 && o1 === 254) return true;
|
|
123
192
|
return false;
|
|
124
193
|
}
|
|
125
|
-
// IPv6 (without surrounding brackets, as `dns.lookup` returns them)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (lower.startsWith("ff")) return true;
|
|
131
|
-
if (lower.startsWith("::ffff:")) return true;
|
|
132
|
-
return false;
|
|
194
|
+
// IPv6 (without surrounding brackets, as `dns.lookup` returns them):
|
|
195
|
+
// classify through the shared literal parser (G6) so compressed,
|
|
196
|
+
// expanded, embedded-IPv4 and zone-id forms all reduce to the same
|
|
197
|
+
// predicates — the old prefix chain missed `::127.0.0.1`.
|
|
198
|
+
return isPrivateIpv6Literal(lower);
|
|
133
199
|
}
|
|
134
200
|
|
|
135
201
|
/**
|
|
@@ -142,6 +208,9 @@ export function isPrivateIpAddress(address: string): boolean {
|
|
|
142
208
|
* allowed to fall through so the underlying `fetch` surfaces the error.
|
|
143
209
|
*/
|
|
144
210
|
export async function assertResolvedAddressSafe(endpoint: string): Promise<void> {
|
|
211
|
+
// G6: bare IPv6 literal authorities are bracketed before parsing so the
|
|
212
|
+
// literal classifier below sees them (idempotent for normal URLs).
|
|
213
|
+
endpoint = bracketIpv6InUrl(endpoint);
|
|
145
214
|
let url: URL;
|
|
146
215
|
try {
|
|
147
216
|
url = new URL(endpoint);
|
|
@@ -155,7 +224,16 @@ export async function assertResolvedAddressSafe(endpoint: string): Promise<void>
|
|
|
155
224
|
// round-trip so we don't double-report errors the sync check already
|
|
156
225
|
// produces.
|
|
157
226
|
if (hostname === "localhost" || hostname.endsWith(".localhost")) return;
|
|
158
|
-
|
|
227
|
+
// G6: bracketed IPv6 literals used to bypass this layer entirely; now
|
|
228
|
+
// they are classified with the same predicates as `validateEndpoint`
|
|
229
|
+
// (defense in depth — layer 2 must not trust layer 1 having run). A
|
|
230
|
+
// literal needs no DNS round-trip.
|
|
231
|
+
if (hostname.startsWith("[")) {
|
|
232
|
+
if (isPrivateIpv6Literal(hostname)) {
|
|
233
|
+
throw new Error(`OTLP endpoint targets private/reserved IPv6 literal: ${endpoint}`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
159
237
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return;
|
|
160
238
|
let addresses: { address: string }[];
|
|
161
239
|
try {
|
|
@@ -261,8 +339,12 @@ export class OTLPExporter implements MetricExporter {
|
|
|
261
339
|
private readonly registry: MetricRegistry;
|
|
262
340
|
|
|
263
341
|
constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
|
|
264
|
-
|
|
265
|
-
|
|
342
|
+
// G6: normalize a bare IPv6 literal authority (`http://::1:4318`) to
|
|
343
|
+
// bracketed form ONCE — `new URL` throws on the raw form, and both
|
|
344
|
+
// SSRF layers plus the actual `fetch` must see the same parseable URL.
|
|
345
|
+
const endpoint = bracketIpv6InUrl(opts.endpoint);
|
|
346
|
+
validateEndpoint(endpoint);
|
|
347
|
+
this.opts = { ...opts, endpoint };
|
|
266
348
|
this.registry = registry;
|
|
267
349
|
}
|
|
268
350
|
|