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
package/package.json
CHANGED
|
@@ -202,6 +202,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
|
|
|
202
202
|
|
|
203
203
|
> **2026-10-28-b update (ROOT-CAUSE CLOSED + fd3 transport + review-wave fixes, commits `38d5ed58`..`5190e66b`, cold-verified review + 3-agent verification)**: (1) **ROOT-CAUSE incident 0.11.46/47 (đóng)** — pi's `readPipedStdin` (pi@1.0.4 `dist/main.js:52-65`) đọc TOÀN BỘ piped stdin làm PROMPT CONTENT trong mọi mode trừ `--mode=rpc` ⇒ handshake fd0 race 3 cách: token JSON thành literal prompt text (SECRET LEAK — chứng minh trên transcript thật), stream-ended creds-less câm lặng, reader `pause()` trước EOF drain → pi boot await deadlock → **TỰ exit 0** (KHÔNG phải parent SIGTERM — strace cũ misread) + takeOverStdout buffer ⇒ ~0 bytes. **Fix: handshake dời sang fd3** (stdio entry 4 `['ignore','pipe','pipe','pipe']`, `readFirstLineFromFd(BROKER_HANDSHAKE_FD=3)`; fd0 về `"ignore"` — shape pre-0.11.46); detached runner GIỮ fd0 (node process, không race). Gate `PI_CREW_BROKER_STDIN` vẫn OPT-IN. Evidence: pre-fix pi@1.0.4 gate-ON 5/5 chết (~1s, 0B) → post-fix 2/2 sống (106-110KB, ~3.2s, 0 leak); integration live-worker giờ assert CẢ gate-ON liveness (bỏ comment "DO NOT assert liveness") + leak-check `v":2` không xuất hiện trong output. **Contract nền: fd0 KHÔNG BAO GIỜ là kênh sideband của extension khi spawn pi binary — dùng stdio entry 4+ (fd3).** (2) **T7 judge mechanics ĐỔI** — dispatch goal giờ mang nonce `[t7-nonce:<uuid>]`, judge CHỈ dirs có nonce trong events.jsonl (hết race newest-mtime với run đồng thời); non-terminal run = FAIL (check TRƯỚC killed-branch); stdout/stderr của dispatch được capture (cap 64KB) vào failure reason + audit. (3) **T7 triggers mở rộng** — thêm `src/(state|utils|agents|teams|workflows|hooks|observability)/` + `scripts/` (gate changes live-test the gate). (4) diff-base tag filter `^v\d+\.\d+\.\d+$` + porcelain rename both-sides; `PI_CREW_BATTERY_SKIP` giờ sống qua prepublishOnly path. (5) maxTasksPerRun admission cap tại createRunManifest (pre-dispatch); seam fix gate đọc `options.env ?? process.env` (test inject được — hết mutate process.env); `buildBrokerStdinLine` warn >60KiB (reader abort ~131KiB thực tế Linux — pipe chunks). (6) **CI 'Checks' step có gates NGOÀI battery list** (`check:env-vars`, `check:decision-drift`) — thêm vào leader local gate list sau mọi src edit.
|
|
204
204
|
|
|
205
|
+
> **2026-10-28-c update (LOW-1 DEFAULT-ON flip — commit riêng ngay sau `deb09c5e`/v0.11.49)**: `PI_CREW_BROKER_STDIN` gate lật **DEFAULT-ON** — cả 2 gate (`resolveBrokerStdinHandshakeEnabled` child-pi-spawn.ts + `resolveBrokerStdinFallbackEnabled` crew-broker-child.ts) đổi `=== "1"` → `!== "0"` (chỉ đúng `0` opt-out; unset/`1`/bất kỳ giá trị khác = ON — kể cả garbage như `true`). Fix-1 spawner vẫn ghi `"1"` vào built.env khi gate ON (pin route + tương thích worker pre-flip); parent opt-out ⇒ token ở env child (worker reader tự disarm). T7 live-worker test có thêm pin DEFAULT (gate unset + fd3 payload → alive + no-leak). **Meta-rule của update 2026-10-28 (1) vẫn nguyên: flip này là ĐÚNG lớp incident 0.11.46/47 — battery T7 REAL-TEST BẮT BUỘC trước publish, không chỉ CI.** Skew case mới: gate unset + KHÔNG fd3 pipe (old-parent shape) → `readFirstLineFromFd(3)` missing-fd contract = undefined (bounded, không treo) — pin bởi test đầu của `broker-stdin-reader-liveworker.test.ts`.
|
|
206
|
+
|
|
205
207
|
## Core principle: disk ≠ live Pi
|
|
206
208
|
|
|
207
209
|
Two locations hold pi-crew state:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
2
2
|
import type { RoleToolConfig } from "../config/role-tools.ts";
|
|
3
3
|
import { getToolConfig, isScratchpadEnabledForRole } from "../config/role-tools.ts";
|
|
4
|
+
import { permissionForRole } from "../runtime/role-permission.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* F1 (v0.7.9): canonical built-in tool name list. Used by `parseToolsField`
|
|
@@ -146,6 +147,13 @@ export function getAgentSessionOptions(role: string): {
|
|
|
146
147
|
*
|
|
147
148
|
* Returns `{ tools, excludeTools }` where each is `undefined` when no
|
|
148
149
|
* restriction of that kind applies (so callers no-op cleanly).
|
|
150
|
+
*
|
|
151
|
+
* S-7 (w4b): INDEPENDENT of both rules above, a role classified read_only by
|
|
152
|
+
* `permissionForRole` (including the default-deny for unknown names) never
|
|
153
|
+
* resolves bash/edit/write — they are stripped from the allowlist and merged
|
|
154
|
+
* into the excludeTools denylist regardless of source or frontmatter. The
|
|
155
|
+
* resolved policy is therefore safe to emit verbatim on BOTH spawn paths
|
|
156
|
+
* (child-pi `pi-args.ts` and live-session `filterActiveTools`).
|
|
149
157
|
*/
|
|
150
158
|
export interface ResolvedToolPolicy {
|
|
151
159
|
/** Allowlist; undefined = no allowlist restriction (all built-ins allowed). */
|
|
@@ -160,9 +168,15 @@ function uniqueToolMerge(...lists: Array<string[] | undefined>): string[] | unde
|
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedToolPolicy {
|
|
171
|
+
// Normalize raw frontmatter shapes BEFORE any array operation: inline /
|
|
172
|
+
// runtime-constructed agents may carry the raw CSV string (the type says
|
|
173
|
+
// string[], but callers historically pass the raw form — pi-args.ts docs).
|
|
174
|
+
// Without this, the array filters below crash on a string, and consumers
|
|
175
|
+
// building a Set from the "list" would spread a string into single chars.
|
|
176
|
+
const declaredTools = normalizeToolList(agent.tools);
|
|
163
177
|
const roleConfig = role ? getToolConfig(role) : {};
|
|
164
178
|
// allowlist: source-aware precedence (see doc above).
|
|
165
|
-
const explicitTools = agent.source === "builtin" ? (roleConfig.tools ??
|
|
179
|
+
const explicitTools = agent.source === "builtin" ? (roleConfig.tools ?? declaredTools) : (declaredTools ?? roleConfig.tools);
|
|
166
180
|
let tools =
|
|
167
181
|
agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
|
|
168
182
|
// denylist: additive merge of role excludeTools + agent disallowedTools.
|
|
@@ -179,9 +193,52 @@ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedTo
|
|
|
179
193
|
tools = tools ? tools.filter((t) => t !== "bash") : tools;
|
|
180
194
|
excludeTools = uniqueToolMerge(excludeTools, ["bash"]);
|
|
181
195
|
}
|
|
196
|
+
// S-7 (w4b security fix — mirrors S-6 onto the tools path): a role
|
|
197
|
+
// classified read_only by permissionForRole must never receive
|
|
198
|
+
// execution-capable tools from ANY frontmatter declaration, wildcard
|
|
199
|
+
// expansion, or default fallback, regardless of agent.source — user intent
|
|
200
|
+
// does not outrank the tier. Project/project-pi frontmatter previously
|
|
201
|
+
// REPLACED the role allowlist wholesale (and custom role names had no
|
|
202
|
+
// role allowlist at all — getToolConfig {}), so a repo-authored
|
|
203
|
+
// `tools: bash` armed host-level command execution on a worker whose
|
|
204
|
+
// permission tier is read_only (default-deny). The denylist side is
|
|
205
|
+
// additive: it can never be weakened by frontmatter omission.
|
|
206
|
+
// F10: normalize underscore→hyphen before the tier lookup (same convention
|
|
207
|
+
// as isScratchpadEnabledForRole) — a raw `test_engineer` role must classify
|
|
208
|
+
// as its hyphenated WRITE counterpart, not default-deny to read_only.
|
|
209
|
+
if (role && permissionForRole(normalizeRoleKey(role)) === "read_only") {
|
|
210
|
+
tools = tools ? tools.filter((t) => !EXECUTION_TOOLS.has(t)) : tools;
|
|
211
|
+
excludeTools = uniqueToolMerge(excludeTools, [...EXECUTION_TOOLS]);
|
|
212
|
+
}
|
|
182
213
|
return { tools, excludeTools };
|
|
183
214
|
}
|
|
184
215
|
|
|
216
|
+
/**
|
|
217
|
+
* S-7: execution-capable builtin tools. A read_only-tier role never holds
|
|
218
|
+
* these, no matter what any frontmatter or role-config allowlist declares.
|
|
219
|
+
* (`scratchpad`/`sh` execution is already gated by S-6 in
|
|
220
|
+
* isScratchpadEnabledForRole — the two guards together close both execution
|
|
221
|
+
* paths for read-only roles.)
|
|
222
|
+
*/
|
|
223
|
+
const EXECUTION_TOOLS: ReadonlySet<string> = new Set(["bash", "edit", "write"]);
|
|
224
|
+
|
|
225
|
+
/** F10: hyphen/underscore role-key normalization (mirrors getToolConfig). */
|
|
226
|
+
function normalizeRoleKey(role: string): string {
|
|
227
|
+
return role.includes("_") ? role.replaceAll("_", "-") : role;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Normalize a declared tool list into a clean string[]. Accepts the raw CSV
|
|
232
|
+
* string form (parseToolsField semantics: `*`/`all` expand to the builtin
|
|
233
|
+
* list, `none` → []) and plain arrays (trim + drop empties, NO wildcard
|
|
234
|
+
* re-expansion — an already-parsed array passes through verbatim).
|
|
235
|
+
*/
|
|
236
|
+
function normalizeToolList(raw: unknown): string[] | undefined {
|
|
237
|
+
if (raw === undefined || raw === null) return undefined;
|
|
238
|
+
if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
|
|
239
|
+
return parseToolsField(raw);
|
|
240
|
+
}
|
|
241
|
+
|
|
185
242
|
/**
|
|
186
243
|
* P2: should `bash` be demoted (removed) for a scratchpad-armed role?
|
|
187
244
|
*
|
|
@@ -828,6 +828,24 @@ function parseOtlpConfig(value: unknown): CrewOtlpConfig | undefined {
|
|
|
828
828
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
829
829
|
intervalMs: parseWithSchema(Type.Integer({ minimum: 5000 }), obj.intervalMs),
|
|
830
830
|
};
|
|
831
|
+
// W9-R1 (w7b deferred): refuse credential-bearing endpoints at CONFIG
|
|
832
|
+
// VALIDATION time, mirroring the runtime refusal in otlp-exporter.ts
|
|
833
|
+
// validateEndpoint. The error text renders the ORIGIN only — the
|
|
834
|
+
// credential-bearing URL is never echoed (unparseable URLs refuse without
|
|
835
|
+
// echoing any part of the endpoint).
|
|
836
|
+
if (otlp.endpoint !== undefined && /^https?:\/\/[^/?#]*@/.test(otlp.endpoint)) {
|
|
837
|
+
let origin = "";
|
|
838
|
+
try {
|
|
839
|
+
origin = new URL(otlp.endpoint).origin;
|
|
840
|
+
} catch {
|
|
841
|
+
origin = "";
|
|
842
|
+
}
|
|
843
|
+
throw new Error(
|
|
844
|
+
origin
|
|
845
|
+
? `OTLP endpoint must not embed credentials (userinfo) — pass them via headers instead: ${origin}`
|
|
846
|
+
: "OTLP endpoint must not embed credentials (userinfo) — pass them via headers instead",
|
|
847
|
+
);
|
|
848
|
+
}
|
|
831
849
|
return Object.values(otlp).some((entry) => entry !== undefined) ? otlp : undefined;
|
|
832
850
|
}
|
|
833
851
|
|
|
@@ -191,6 +191,10 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
191
191
|
PI_CREW_DELEGATE_ENABLED: { kind: "child-write", reason: "child-pi-spawn dormant delegate gate" },
|
|
192
192
|
PI_CREW_STATE_ROOT: { kind: "child-write", reason: "child-pi-spawn mailbox root" },
|
|
193
193
|
PI_CREW_BROKER_RUN_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
|
|
194
|
+
PI_CREW_MAILBOX_VERIFY_KEY: {
|
|
195
|
+
kind: "child-write",
|
|
196
|
+
reason: "SEC-2026-11 w1a: PUBLIC base64 SPKI Ed25519 verify key handed to children via built.env (child-pi-spawn) for coordination-row verify-only; public material — possession cannot forge rows (private key is heap-only in the root session)",
|
|
197
|
+
},
|
|
194
198
|
PI_CREW_AGENT_EVENTS_PATH: { kind: "child-write", reason: "prepareSurfaceSpawn recorder log" },
|
|
195
199
|
PI_CREW_BROKER_SOCKET: { kind: "child-write", reason: "child-pi-spawn broker handshake (never ambient — per-run socket)" },
|
|
196
200
|
PI_CREW_BROKER_TOKEN: {
|
|
@@ -200,7 +204,7 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
200
204
|
PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
|
|
201
205
|
PI_CREW_BROKER_STDIN: {
|
|
202
206
|
kind: "child-write",
|
|
203
|
-
reason: "LOW-1 broker-credential transport gate — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env)
|
|
207
|
+
reason: "LOW-1 broker-credential transport gate (DEFAULT-ON since 2026-10-28 — only exact '0' opts out) — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every gate-ON brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) is armed deterministically and pre-flip workers (mirror === '1') stay compatible — ambient forwarding never reaches the child, so child-write is the only channel that wires the route through (FIX-1 2026-10-28). Never ambient-allowlisted: route selection is per-spawn",
|
|
204
208
|
},
|
|
205
209
|
PI_CREW_INFERENCE_BROKER: {
|
|
206
210
|
kind: "gap",
|
package/src/config/env-vars.ts
CHANGED
|
@@ -636,6 +636,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
636
636
|
parser: "number",
|
|
637
637
|
doc: "swap-detection mtime pin for restore snapshots (scratchpad-lifecycle.ts:302)",
|
|
638
638
|
},
|
|
639
|
+
PI_CREW_MAILBOX_VERIFY_KEY: {
|
|
640
|
+
name: "PI_CREW_MAILBOX_VERIFY_KEY",
|
|
641
|
+
doc: "SEC-2026-11 w1a coordination sender-binding: base64 SPKI Ed25519 public verify key handed to child workers at spawn (child-pi-spawn.ts) so they can VERIFY parent-signed mailbox/steering rows; the PRIVATE signing key never leaves the root session heap (mailbox.ts signingKeypair). Children verify-only — possession of the public key cannot forge rows",
|
|
642
|
+
},
|
|
639
643
|
PI_CREW_MAX_AUTO_FALLBACKS: {
|
|
640
644
|
name: "PI_CREW_MAX_AUTO_FALLBACKS",
|
|
641
645
|
parser: "int",
|
|
@@ -668,7 +672,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
668
672
|
},
|
|
669
673
|
PI_CREW_BROKER_STDIN: {
|
|
670
674
|
name: "PI_CREW_BROKER_STDIN",
|
|
671
|
-
doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, OPT-IN
|
|
675
|
+
doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, DEFAULT-ON since 2026-10-28 (was OPT-IN v0.11.48–0.11.49): unset (default)/'1'/any value other than exactly '0' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over a DEDICATED fd3 spawn pipe (one buildBrokerStdinLine v2 payload on stdio entry 4; heap → fd3 → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader; fd0 stays 'ignore') so the token never appears in the child env; exactly '0' opts OUT (rollback to the LEGACY ENV ROUTE — token in built.env — on BOTH sides) — the original fd0 variant was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 (root cause: pi@1.0.4 readPipedStdin consumes all piped fd0 as prompt content); the fd3 transport removes that race. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side fd3 read skips --mode rpc argv by design)",
|
|
672
676
|
},
|
|
673
677
|
PI_CREW_GUEST: {
|
|
674
678
|
name: "PI_CREW_GUEST",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { collectSensitiveConfigPaths } from "../schema/sensitive-config-paths.ts";
|
|
2
|
+
import { getCrewEnv } from "./env-vars.ts";
|
|
2
3
|
import type { ConfigValidationResult, PiTeamsConfig } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -43,6 +44,14 @@ export function projectOverrideWarning(projectPath: string, dottedPath: string):
|
|
|
43
44
|
* `limits.maxConcurrentWorkers` deliberately has NO entry: the schema already
|
|
44
45
|
* bounds it (Type.Integer minimum 1, reader-side sanity ceilings) and a project
|
|
45
46
|
* can only lower it — i.e. tighten — so no drop is needed (ADR Wave 1A).
|
|
47
|
+
*
|
|
48
|
+
* `runtime.embeddedWorkers` deliberately has NO entry either (w4b): the whole
|
|
49
|
+
* block is sensitive-marked in the schema (user-only), so the unconditional
|
|
50
|
+
* walk above already drops it from the project tier — there is no residual
|
|
51
|
+
* project-tier surface to guard-tier. Do not re-add conditional entries for
|
|
52
|
+
* it; if the block ever loses its sensitive mark, routing to the in-process
|
|
53
|
+
* embedded tier becomes project-settable again, which is the bug class this
|
|
54
|
+
* table exists to prevent.
|
|
46
55
|
*/
|
|
47
56
|
export const CONDITIONAL_PROJECT_DROPS: Record<string, (value: unknown) => boolean> = {
|
|
48
57
|
// Legacy exception (ADR Decision 3, folded in here): only `=== false`
|
|
@@ -170,3 +179,49 @@ export function sanitizeProjectConfig(projectPath: string, userConfig: PiTeamsCo
|
|
|
170
179
|
|
|
171
180
|
/** @internal — direct-test seam for Phase 2.2 extraction target (refactor-plan step 1.9c). */
|
|
172
181
|
export const __test__sanitizeProjectConfig = sanitizeProjectConfig;
|
|
182
|
+
|
|
183
|
+
// ─── w4b defense-in-depth: the embedded tier must not void the sandbox opt-in ───
|
|
184
|
+
// A project/user config can route a role onto the U11 EMBEDDED tier
|
|
185
|
+
// (runtime.embeddedWorkers), where the worker's Pi session runs IN-PROCESS in
|
|
186
|
+
// the host pi process. Both deterministic host protections live ONLY on the
|
|
187
|
+
// child spawn path: the PI_CREW_SANDBOX=bwrap OS boundary (child-pi.ts
|
|
188
|
+
// resolveSandboxConfig → sandbox.ts) and the env allowlist secret scrub
|
|
189
|
+
// (child-pi-spawn.ts sanitizeEnvSecrets). An embedded-routed task never
|
|
190
|
+
// reaches either — so while the sandbox posture is ON, routing to the
|
|
191
|
+
// in-process tier silently voids the user's explicit opt-in. The schema now
|
|
192
|
+
// removes the PROJECT tier's authority to enable embeddedWorkers; this gate is
|
|
193
|
+
// the second layer: even a USER-tier enable must refuse to route while the
|
|
194
|
+
// sandbox is on (fail closed, like resolveSandboxConfig itself).
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* FAIL-CLOSED: must the embedded tier refuse routing because a sandbox
|
|
198
|
+
* posture is configured?
|
|
199
|
+
*
|
|
200
|
+
* Mirrors resolveSandboxConfig's value handling (sandbox.ts): `""` and `"0"
|
|
201
|
+
* mean OFF; ANY other non-empty value means sandbox intent — `"bwrap"` is the
|
|
202
|
+
* active boundary, and unrecognized values throw on the child path entirely
|
|
203
|
+
* (refusing embedded routing for them too is the same fail-closed posture:
|
|
204
|
+
* the user asked for containment and must never get the unprotected in-process
|
|
205
|
+
* tier instead).
|
|
206
|
+
*
|
|
207
|
+
* CONSUMER (required follow-up wiring, outside this wave's file ownership):
|
|
208
|
+
* `planEmbeddedTier` (src/runtime/task-runner/hybrid-tier.ts) must call this
|
|
209
|
+
* FIRST and, when true, emit a visible `tier.decision` warning event (reason
|
|
210
|
+
* "sandbox-opt-in", route subprocess) and return undefined — the task stays
|
|
211
|
+
* on the subprocess tier, which resolveSandboxConfig actually wraps. Until
|
|
212
|
+
* wired, the gate is exported + unit-tested but not yet consulted by the
|
|
213
|
+
* routing seam.
|
|
214
|
+
*/
|
|
215
|
+
export function embeddedTierRefusedBySandbox(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
216
|
+
const raw = (getCrewEnv("PI_CREW_SANDBOX", env) ?? "").trim();
|
|
217
|
+
return raw !== "" && raw !== "0";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Human-visible refusal message for the gate above (single wording shared by
|
|
222
|
+
* the event emission and tests, so the warning cannot drift from the gate).
|
|
223
|
+
*/
|
|
224
|
+
export function embeddedTierSandboxRefusalReason(env: NodeJS.ProcessEnv = process.env): string {
|
|
225
|
+
const raw = (getCrewEnv("PI_CREW_SANDBOX", env) ?? "").trim();
|
|
226
|
+
return `Embedded tier refused: PI_CREW_SANDBOX='${raw}' is set — the in-process embedded tier has no bwrap OS boundary and no child-env secret scrub, so routing would void the sandbox opt-in; the task stays on the subprocess tier (which the sandbox wraps)`;
|
|
227
|
+
}
|
|
@@ -555,6 +555,15 @@ export function handleDelete(params: TeamToolParamsValue, ctx: ManagementContext
|
|
|
555
555
|
if (!params.confirm) return result("delete requires confirm: true.", "error", true);
|
|
556
556
|
const resolved = resolveMutable(ctx, params);
|
|
557
557
|
if (resolved.error) return resolved.error;
|
|
558
|
+
// Wave-3 root-gate parity with handleUpdate (w5a,
|
|
559
|
+
// rootGateActionMatrixOmitsDeleteCleanupInit): delete resolves its write
|
|
560
|
+
// tier from the MATCHED resource (params.scope may be undefined and match
|
|
561
|
+
// either tier via the user+project pool), so the facade gate cannot decide
|
|
562
|
+
// — gate HERE on the resolved source. Non-root sessions may only delete
|
|
563
|
+
// project-tier resources; dryRun previews stay behind this gate (fail-
|
|
564
|
+
// closed, matching handleUpdate's gate-before-dryRun order).
|
|
565
|
+
const deleteGate = assertRootOnlyAction("delete", undefined, resolved.resource!.source === "project" ? "project" : "user");
|
|
566
|
+
if (deleteGate) return result(deleteGate.message, "error", true);
|
|
558
567
|
const refs = findReferences(ctx, params.resource!, resolved.resource!.name);
|
|
559
568
|
if (refs.length > 0 && !params.force) {
|
|
560
569
|
return result(
|
|
@@ -43,6 +43,7 @@ import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
|
|
|
43
43
|
import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
|
|
44
44
|
import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
|
|
45
45
|
import { flushReapEscalationsSync } from "../../runtime/stale-reconciler.ts";
|
|
46
|
+
import { getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
|
|
46
47
|
import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
|
|
47
48
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
48
49
|
import { installInlinePanel } from "../../ui/inline-panel/index.ts";
|
|
@@ -758,6 +759,22 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
|
|
|
758
759
|
let starting: Promise<CrewBroker> | null = null;
|
|
759
760
|
let cachedSessionId: string | undefined;
|
|
760
761
|
|
|
762
|
+
// w1a (coordination-durable-files-no-sender-binding): eagerly create this
|
|
763
|
+
// process's coordination-row signing keypair (heap-only Ed25519) at install
|
|
764
|
+
// time in ROOT sessions, so the parent is a fail-closed verifier from the
|
|
765
|
+
// first mailbox/steering read — before any row it ever signed exists — and
|
|
766
|
+
// children can be handed the public verify key at spawn time
|
|
767
|
+
// (child-pi-spawn → PI_CREW_MAILBOX_VERIFY_KEY). Children install handlers
|
|
768
|
+
// too but never act as coordination writers; creating a keypair there is
|
|
769
|
+
// harmless heap state, but skip it to keep child heaps clean.
|
|
770
|
+
if (isRootSession(process.env)) {
|
|
771
|
+
try {
|
|
772
|
+
getMailboxVerifyKeyPublic();
|
|
773
|
+
} catch {
|
|
774
|
+
/* heap-only keygen must never break extension install */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
761
778
|
function effectiveEnabled(): boolean {
|
|
762
779
|
// Env wins over config. PI_CREW_BROKER=1 forces on, =0 forces off.
|
|
763
780
|
const envOverride = getCrewEnv("PI_CREW_BROKER");
|
|
@@ -860,6 +877,17 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
|
|
|
860
877
|
|
|
861
878
|
const issueForChild = async (runId: string, taskId?: string, childDepth?: number): Promise<BrokerSpawnCredentials | undefined> => {
|
|
862
879
|
if (!runId || typeof runId !== "string") return undefined;
|
|
880
|
+
// w1a (broker-bare-run-token-forged-task-identity): FAIL CLOSED on a missing
|
|
881
|
+
// task identity — a spawn without a taskId must NOT receive the legacy bare
|
|
882
|
+
// per-run token. The registry's runId fallback let an LLM worker holding
|
|
883
|
+
// that env-delivered bearer claim ANY queued sibling's identity
|
|
884
|
+
// (cross-task inbox theft + parked-ask hijack, observed end-to-end). This
|
|
885
|
+
// mirrors the detached runner's issuer (background-runner.ts:802 —
|
|
886
|
+
// `if (!taskId) return undefined`). Legit agentId-less scaffold/DWF flows
|
|
887
|
+
// keep working via the explicit credential-less route: the spawn simply
|
|
888
|
+
// carries no broker creds and the worker runs file-paths-only (the
|
|
889
|
+
// pre-broker behavior).
|
|
890
|
+
if (!taskId || typeof taskId !== "string") return undefined;
|
|
863
891
|
if (!isRootSession(process.env)) return undefined;
|
|
864
892
|
if (!effectiveEnabled()) return undefined;
|
|
865
893
|
// ADR-5 §4 (governed nesting): tokens are minted ONLY for children that
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
savePersistedSubagentRecord,
|
|
31
31
|
} from "../../runtime/subagent-manager.ts";
|
|
32
32
|
import { TEAM_TERMINAL_TASK_STATUSES } from "../../state/contracts.ts";
|
|
33
|
+
import { signSteeringEntry } from "../../state/coordination/mailbox.ts";
|
|
33
34
|
import { resolveEntryBySubagentId, upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
|
|
34
35
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
35
36
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
@@ -558,7 +559,10 @@ export function registerSubagentTools(
|
|
|
558
559
|
} catch (error) {
|
|
559
560
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
560
561
|
}
|
|
561
|
-
|
|
562
|
+
// w1a sender binding: the steering file is same-uid writable — the
|
|
563
|
+
// line carries this process's Ed25519 signature (worker pollSteering
|
|
564
|
+
// drops unauthenticated lines).
|
|
565
|
+
const line = JSON.stringify(signSteeringEntry({ type: "steer", message: p.message, ts: new Date().toISOString() })) + "\n";
|
|
562
566
|
if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
|
|
563
567
|
return subagentToolResult(
|
|
564
568
|
`Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
|
|
@@ -23,6 +23,33 @@ export interface ImportedRunBundleInfo {
|
|
|
23
23
|
// reading + JSON.parse). Stat BEFORE reading so we never buffer a huge file.
|
|
24
24
|
export const MAX_IMPORT_BUNDLE_BYTES = 50 * 1024 * 1024;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* W7B (imported-manifest-goal-raw-render): output-encoding seam for untrusted
|
|
28
|
+
* strings imported from run bundles (manifest.goal/team/workflow/status,
|
|
29
|
+
* exportedAt, task.error/id/role/agent) before they are rendered into the
|
|
30
|
+
* parent-LLM team-tool listing (lifecycle-actions.ts handleImports) or the
|
|
31
|
+
* durable import README. Bundle files are repo-authored / plant-able content —
|
|
32
|
+
* assertRunBundle validates types only, not content — so every rendered field
|
|
33
|
+
* is forced into inert single-line data:
|
|
34
|
+
* 1. strip well-formed ANSI CSI escape sequences (colors / cursor moves),
|
|
35
|
+
* 2. collapse remaining C0/C1 controls + DEL to spaces (kills forged
|
|
36
|
+
* multi-line listing entries and injected directive lines),
|
|
37
|
+
* 3. strip angle brackets (kills fake <dependency-context> fences and
|
|
38
|
+
* <script> tags),
|
|
39
|
+
* 4. collapse whitespace runs and hard-cap the length.
|
|
40
|
+
* Mirrors the sanitizeFencedBody seam applied to worker output entering parent
|
|
41
|
+
* context (task-output-context.ts).
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeImportedText(value: string | undefined, max = 200): string {
|
|
44
|
+
const stripped = String(value ?? "")
|
|
45
|
+
.replace(/\u001b\[[0-9;?]*[0-9A-Za-z]/g, "")
|
|
46
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
47
|
+
.replace(/[<>]/g, "")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
return stripped.length > max ? stripped.slice(0, max) : stripped;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
function importRoot(cwd: string, scope: "project" | "user"): string {
|
|
27
54
|
const base = scope === "project" ? projectCrewRoot(cwd) : userCrewRoot();
|
|
28
55
|
// SECURITY NOTE: `DEFAULT_PATHS.state.importsSubdir` is a constant (not user-controlled).
|
|
@@ -157,20 +184,27 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
|
|
|
157
184
|
atomicWriteFile(targetJson, `${JSON.stringify({ ...raw, importedAt, importedFrom: resolvedPath }, null, 2)}\n`);
|
|
158
185
|
atomicWriteFile(
|
|
159
186
|
targetSummary,
|
|
187
|
+
// W7B: every bundle-authored string below is untrusted (repo/plant
|
|
188
|
+
// content) — render through the sanitizeImportedText seam so control
|
|
189
|
+
// chars, ANSI escapes and angle-bracket fence tokens can never break out
|
|
190
|
+
// of this README into forged headings/directives.
|
|
160
191
|
[
|
|
161
192
|
`# Imported pi-crew run ${runId}`,
|
|
162
193
|
"",
|
|
163
194
|
`Imported: ${importedAt}`,
|
|
164
195
|
`Source: ${resolvedPath}`,
|
|
165
|
-
`Original export: ${raw.exportedAt}`,
|
|
166
|
-
`Status: ${raw.manifest.status}`,
|
|
167
|
-
`Team: ${raw.manifest.team}`,
|
|
168
|
-
`Workflow: ${raw.manifest.workflow
|
|
169
|
-
`Goal: ${raw.manifest.goal}`,
|
|
196
|
+
`Original export: ${sanitizeImportedText(raw.exportedAt, 64)}`,
|
|
197
|
+
`Status: ${sanitizeImportedText(raw.manifest.status, 64)}`,
|
|
198
|
+
`Team: ${sanitizeImportedText(raw.manifest.team, 64)}`,
|
|
199
|
+
`Workflow: ${raw.manifest.workflow === undefined ? "(none)" : sanitizeImportedText(raw.manifest.workflow, 64)}`,
|
|
200
|
+
`Goal: ${sanitizeImportedText(raw.manifest.goal)}`,
|
|
170
201
|
"",
|
|
171
202
|
"## Tasks",
|
|
172
203
|
...raw.tasks.map(
|
|
173
|
-
(task) =>
|
|
204
|
+
(task) =>
|
|
205
|
+
`- ${sanitizeImportedText(task.id, 120)}: ${task.status} (${sanitizeImportedText(task.role, 64)} -> ${sanitizeImportedText(task.agent, 64)})${
|
|
206
|
+
typeof task.error === "string" ? ` - ${sanitizeImportedText(task.error)}` : ""
|
|
207
|
+
}`,
|
|
174
208
|
),
|
|
175
209
|
"",
|
|
176
210
|
].join("\n"),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* byte-identical to the inline `if` blocks they replace.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { getCrewEnv } from "../../../config/env-vars.ts";
|
|
8
9
|
import { readCrewAgents } from "../../../runtime/crew-agent-records.ts";
|
|
9
10
|
import { appendLiveAgentControlRequest } from "../../../runtime/live-session/live-agent-control.ts";
|
|
10
11
|
import {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
stopLiveAgent,
|
|
17
18
|
} from "../../../runtime/live-session/live-agent-manager.ts";
|
|
18
19
|
import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../../../runtime/live-session/live-control-realtime.ts";
|
|
20
|
+
import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
|
|
19
21
|
import {
|
|
20
22
|
appendFollowUpMessage,
|
|
21
23
|
appendFollowUpMessageAsync,
|
|
@@ -26,6 +28,40 @@ import { appendEvent, appendEventBuffered } from "../../../state/event-log/event
|
|
|
26
28
|
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
27
29
|
import type { ApiOperationHandler } from "./handler-context.ts";
|
|
28
30
|
|
|
31
|
+
// ── W1b deferred residual (w9-r1): role gate on the live-agent control cluster ──
|
|
32
|
+
// Every operation dispatched to handleLiveAgentControl (steer-agent,
|
|
33
|
+
// follow-up-agent, stop-agent, resume-agent, interrupt-agent) mutates run
|
|
34
|
+
// state — mailbox writes, live-control request files, session stop/resume —
|
|
35
|
+
// so the whole shared handler is gated with the canMutateTaskClaims pattern
|
|
36
|
+
// (task-claims.ts): read_only-tier roles (explorer/analyst/reviewer/critic/
|
|
37
|
+
// planner/security-reviewer + default-deny unknowns) are denied; write-tier
|
|
38
|
+
// workers and role-less root-session callers stay allowed. handleListLiveAgents
|
|
39
|
+
// is a pure read and stays open. NOTE: the separately named `foreground-interrupt`
|
|
40
|
+
// operation lives in read.ts (outside this file's ownership) — see the w9-r1
|
|
41
|
+
// fix record for the ownership-handoff note.
|
|
42
|
+
|
|
43
|
+
/** Role-tier gate for live-agent control mutations (canApprovePlan pattern). */
|
|
44
|
+
function canControlLiveAgents(): { allowed: boolean; reason?: string } {
|
|
45
|
+
const role = currentCrewRole();
|
|
46
|
+
if (!role) return { allowed: true };
|
|
47
|
+
if (permissionForRole(role) === "read_only")
|
|
48
|
+
return {
|
|
49
|
+
allowed: false,
|
|
50
|
+
reason: `Role '${role}' is read-only and cannot steer, stop, resume, or interrupt agents.`,
|
|
51
|
+
};
|
|
52
|
+
return { allowed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Caller identity fields for the audit trail (w1b callerAuditFields pattern). */
|
|
56
|
+
function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
|
|
57
|
+
const callerRole = currentCrewRole();
|
|
58
|
+
const callerTaskId = getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
|
|
59
|
+
return {
|
|
60
|
+
...(callerRole ? { callerRole } : {}),
|
|
61
|
+
...(callerTaskId ? { callerTaskId } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export const handleNudgeAgent: ApiOperationHandler = (hctx) => {
|
|
30
66
|
const { cfg, loaded, result, paramRequired, ctx } = hctx;
|
|
31
67
|
const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
|
|
@@ -109,6 +145,17 @@ export const handleListLiveAgents: ApiOperationHandler = (hctx) => {
|
|
|
109
145
|
*/
|
|
110
146
|
export const handleLiveAgentControl: ApiOperationHandler = async (hctx) => {
|
|
111
147
|
const { cfg, loaded, result, paramRequired, ctx } = hctx;
|
|
148
|
+
const gate = canControlLiveAgents();
|
|
149
|
+
if (!gate.allowed)
|
|
150
|
+
return result(
|
|
151
|
+
gate.reason ?? "agent control is not allowed in this context.",
|
|
152
|
+
{
|
|
153
|
+
action: "api",
|
|
154
|
+
status: "error",
|
|
155
|
+
runId: loaded.manifest.runId,
|
|
156
|
+
},
|
|
157
|
+
true,
|
|
158
|
+
);
|
|
112
159
|
const operation = typeof cfg.operation === "string" ? cfg.operation : "";
|
|
113
160
|
const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
|
|
114
161
|
if (!agentId)
|
|
@@ -330,6 +377,8 @@ export const handleLiveAgentControl: ApiOperationHandler = async (hctx) => {
|
|
|
330
377
|
request,
|
|
331
378
|
mailboxMessageId: mailboxMessage?.id,
|
|
332
379
|
realtime: true,
|
|
380
|
+
// W1b residual: record the verified caller identity on the audit trail.
|
|
381
|
+
...callerAuditFields(),
|
|
333
382
|
},
|
|
334
383
|
}).catch((e) => logInternalError("api.agent-control.buffered", e, "type=agent.control.queued"));
|
|
335
384
|
return result(JSON.stringify({ queued: true, request, mailboxMessage }, null, 2), {
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* byte-identical to the inline `if` blocks they replace.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { getCrewEnv } from "../../../config/env-vars.ts";
|
|
8
9
|
import { touchWorkerHeartbeat } from "../../../runtime/heartbeat/worker-heartbeat.ts";
|
|
10
|
+
import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
|
|
9
11
|
import { isTerminalTaskStatus } from "../../../state/contracts.ts";
|
|
10
12
|
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
11
13
|
import { appendEventBuffered } from "../../../state/event-log/event-log.ts";
|
|
@@ -14,8 +16,49 @@ import { logInternalError } from "../../../utils/internal-error.ts";
|
|
|
14
16
|
import { RUN_NOT_FOUND_HINT } from "../run-not-found.ts";
|
|
15
17
|
import type { ApiOperationHandler } from "./handler-context.ts";
|
|
16
18
|
|
|
19
|
+
// ── W1b deferred residual (w9-r1): role gate on write-heartbeat ──
|
|
20
|
+
// write-heartbeat mutates run state (task.heartbeat on disk + worker.heartbeat
|
|
21
|
+
// event) and drives liveness/watchdog decisions, so it follows the
|
|
22
|
+
// canMutateTaskClaims pattern (task-claims.ts): read_only-tier roles
|
|
23
|
+
// (explorer/analyst/reviewer/critic/planner/security-reviewer + default-deny
|
|
24
|
+
// unknowns) are denied; write-tier workers and role-less root-session callers
|
|
25
|
+
// stay allowed.
|
|
26
|
+
|
|
27
|
+
/** Role-tier gate for heartbeat-mutating operations (canApprovePlan pattern). */
|
|
28
|
+
function canWriteHeartbeat(): { allowed: boolean; reason?: string } {
|
|
29
|
+
const role = currentCrewRole();
|
|
30
|
+
if (!role) return { allowed: true };
|
|
31
|
+
if (permissionForRole(role) === "read_only")
|
|
32
|
+
return {
|
|
33
|
+
allowed: false,
|
|
34
|
+
reason: `Role '${role}' is read-only and cannot write worker heartbeats.`,
|
|
35
|
+
};
|
|
36
|
+
return { allowed: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Caller identity fields for the audit trail (w1b callerAuditFields pattern). */
|
|
40
|
+
function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
|
|
41
|
+
const callerRole = currentCrewRole();
|
|
42
|
+
const callerTaskId = getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
|
|
43
|
+
return {
|
|
44
|
+
...(callerRole ? { callerRole } : {}),
|
|
45
|
+
...(callerTaskId ? { callerTaskId } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
17
49
|
export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
|
|
18
50
|
const { cfg, loaded, result, paramRequired } = hctx;
|
|
51
|
+
const gate = canWriteHeartbeat();
|
|
52
|
+
if (!gate.allowed)
|
|
53
|
+
return result(
|
|
54
|
+
gate.reason ?? "write-heartbeat is not allowed in this context.",
|
|
55
|
+
{
|
|
56
|
+
action: "api",
|
|
57
|
+
status: "error",
|
|
58
|
+
runId: loaded.manifest.runId,
|
|
59
|
+
},
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
19
62
|
const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
|
|
20
63
|
const task = loaded.tasks.find((item) => item.id === taskId || item.stepId === taskId);
|
|
21
64
|
if (!task)
|
|
@@ -84,7 +127,11 @@ export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
|
|
|
84
127
|
type: "worker.heartbeat",
|
|
85
128
|
runId: fresh.manifest.runId,
|
|
86
129
|
taskId: freshTask.id,
|
|
87
|
-
data: {
|
|
130
|
+
data: {
|
|
131
|
+
...heartbeat,
|
|
132
|
+
// W1b residual: record the verified caller identity on the audit trail.
|
|
133
|
+
...callerAuditFields(),
|
|
134
|
+
},
|
|
88
135
|
}).catch((e) => logInternalError("api.heartbeat.buffered", e, "type=worker.heartbeat"));
|
|
89
136
|
return result(JSON.stringify(heartbeat, null, 2), {
|
|
90
137
|
action: "api",
|