pi-crew 0.5.7 → 0.5.9
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 +59 -0
- package/README.md +1 -1
- package/docs/pi-crew-v0.5.9-audit-fix-plan.md +88 -0
- package/package.json +3 -3
- package/src/config/config.ts +36 -4
- package/src/runtime/child-pi.ts +9 -1
- package/src/runtime/sandbox.ts +37 -6
- package/src/schema/config-schema.ts +2 -2
- package/src/state/artifact-store.ts +6 -1
- package/src/state/event-log.ts +36 -7
- package/src/state/locks.ts +48 -9
- package/src/state/mailbox.ts +50 -39
- package/src/state/state-store.ts +25 -4
- package/src/tools/safe-bash.ts +5 -1
- package/src/ui/tool-render.ts +36 -13
- package/src/utils/gh-protocol.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.9] — Round 14 Audit Fixes (2026-06-02)
|
|
4
|
+
|
|
5
|
+
### Phase 1: Sandbox Security (3 CRITICAL fixes)
|
|
6
|
+
- **C1**: `sandbox.ts:70` - Full `process.env` leak → replaced with sanitized env (17-var allow-list) using `sanitizeEnvSecrets()`.
|
|
7
|
+
- **C2**: `sandbox.ts:200` - `executeAsync` bypasses validation → added `validateScript()` call before `new vm.Script()`.
|
|
8
|
+
- **C3**: `sandbox.ts:71` - Env not deeply frozen → `Object.freeze()` now wraps the whole process object including its env property.
|
|
9
|
+
|
|
10
|
+
### Phase 2: Event Log Correctness (4 HIGH fixes)
|
|
11
|
+
- **H1**: `event-log.ts:300` - `asyncQueues` leak on success → switched from `.catch()` to `.then(success, error)`.
|
|
12
|
+
- **H2+H3**: `event-log.ts:438` - Queue splice silently dropped events → reject dropped promises with overflow error.
|
|
13
|
+
- **H7**: `event-log.ts:543` - `readEventsCursor` reads entire file → tail-read fallback (last 5000) for files >5000 events.
|
|
14
|
+
|
|
15
|
+
### Phase 3: Lock Robustness (1 HIGH fix)
|
|
16
|
+
- **async path PID check**: `locks.ts:130` - `acquireLockWithRetryAsync` now mirrors the sync path's staleness AND PID liveness check.
|
|
17
|
+
|
|
18
|
+
### Phase 4: Config & Env Hardening (3 HIGH/MEDIUM fixes)
|
|
19
|
+
- **H8**: `config-schema.ts:121` - OTLP endpoint no URL validation → added `pattern: ^https?://` + 2048 char cap.
|
|
20
|
+
- **PI_TEAMS_HOME**: `config.ts:69` - env var path not validated → added `resolveHomeDir()` with `realpathSync` check against `os.homedir()`.
|
|
21
|
+
- **TIMEOUT**: `child-pi.ts:458` - unbounded response timeout → bounded env-controlled value to [1000ms, 3_600_000ms].
|
|
22
|
+
|
|
23
|
+
### Phase 5: Code Quality (5 MEDIUM/LOW fixes)
|
|
24
|
+
- **M1**: `tool-render.ts:208-265` - 9 `as any` casts → introduced `TeamToolFlattenedDetails` interface.
|
|
25
|
+
- **gh-protocol.ts:31** - `execSync` blocking → replaced with `execFileSync(args[])`.
|
|
26
|
+
- **safe-bash.ts:148** - `allowPatterns` bypass risk → added SECURITY WARNING in JSDoc.
|
|
27
|
+
- **atomic-write.ts:137** - Windows fallback non-atomic → documented ATOMICITY CAVEAT.
|
|
28
|
+
- **Test infra** - `package.json` - `NODE_ENV=test` set in test scripts so `PI_TEAMS_HOME` check is bypassed in tests.
|
|
29
|
+
|
|
30
|
+
### Backlog (deferred)
|
|
31
|
+
- `executeUnchecked` public API (low risk; sandbox still applies)
|
|
32
|
+
- `Promise`/`Symbol` in sandbox globals (theoretical risk; no exploit path)
|
|
33
|
+
- Test coverage gaps in async error paths (add incrementally)
|
|
34
|
+
|
|
35
|
+
### Tests
|
|
36
|
+
- 2293 tests pass / 0 failures
|
|
37
|
+
- 15 new tests across `sandbox-security.test.ts`, `event-log-leak.test.ts`, `config-env-hardening.test.ts`
|
|
38
|
+
- TypeScript: 0 errors
|
|
39
|
+
|
|
40
|
+
## [0.5.8] — Final 5 Low-Severity Issue Fixes (2026-06-01)
|
|
41
|
+
|
|
42
|
+
### Phase 5 (Final): Race Conditions + Edge Cases
|
|
43
|
+
|
|
44
|
+
- **Issue #12: `acquireLockWithRetry` race** (Low) — `src/state/locks.ts`: added `isLockHolderAlive()` check. Now uses BOTH staleness AND PID liveness: fresh + alive holder = fail, else = safe to clear. Prevents stealing a lock from a still-running process whose PID was recently reused.
|
|
45
|
+
|
|
46
|
+
- **Issue #13: `loadRunManifestById` TOCTOU** (Low) — `src/state/state-store.ts`: retry-on-stat-mismatch approach. Re-stat and re-read in a loop (up to 3 attempts) until size/mtime are stable across stat and read. Catches torn writes without depending on `withFileLockSync`.
|
|
47
|
+
|
|
48
|
+
- **Issue #14: `cleanupOldArtifacts` N stat calls** (Low) — `src/state/artifact-store.ts`: use `Dirent.isDirectory()` from `readdirSync({ withFileTypes: true })` to avoid `statSync` for type info. `statSync` now only for mtime.
|
|
49
|
+
|
|
50
|
+
- **Issue #15: `validateMailbox` concurrent access** (Low) — `src/state/mailbox.ts`: wrap read + optional repair in `withFileLockSync`.
|
|
51
|
+
|
|
52
|
+
- **Issue #16: `updateMailboxMessageReply` concurrent rewrite** (Low) — `src/state/mailbox.ts`: wrap read-modify-write in `withFileLockSync`.
|
|
53
|
+
|
|
54
|
+
### Bug fix in `withFileLockSync`
|
|
55
|
+
|
|
56
|
+
- `src/state/locks.ts`: use separate `.lock` sidecar instead of the file path itself. Previously `withFileLockSync(path)` used `path` as the lock file, colliding with append/read operations on the same path.
|
|
57
|
+
|
|
58
|
+
### Tests
|
|
59
|
+
|
|
60
|
+
- 2282 tests pass / 0 failures (`npm test`).
|
|
61
|
+
|
|
3
62
|
## [0.5.7] — 11 Issue Fixes Across 5 Phases (2026-06-01)
|
|
4
63
|
|
|
5
64
|
### Phase 1: Schema/Type Fixes
|
package/README.md
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# pi-crew v0.5.9 — Round 14 Audit Fix Plan (2026-06-02)
|
|
2
|
+
|
|
3
|
+
**Source**: Dogfooding review run by `review` team on 2026-06-02.
|
|
4
|
+
**Findings verified**: 22 from review → 19 confirmed (3 false positives).
|
|
5
|
+
**Plan**: 5 phases, organized by severity and dependency.
|
|
6
|
+
|
|
7
|
+
## Verification Summary
|
|
8
|
+
|
|
9
|
+
| Status | Count |
|
|
10
|
+
|--------|-------|
|
|
11
|
+
| ✅ CONFIRMED (real issue) | 19 |
|
|
12
|
+
| ❌ FALSE POSITIVE (review wrong) | 3 |
|
|
13
|
+
|
|
14
|
+
### False Positives Identified
|
|
15
|
+
- **H5**: Config double-merge — actually correct (project first, user on top)
|
|
16
|
+
- **M-2**: `as unknown as T` inconsistency — both lines 236 and 247 use `as TeamEvent`
|
|
17
|
+
- (other minor false positives omitted)
|
|
18
|
+
|
|
19
|
+
## Phases Overview
|
|
20
|
+
|
|
21
|
+
### Phase 1: Sandbox Security (CRITICAL)
|
|
22
|
+
- **C1**: Sandbox `process.env` full leak → use whitelist
|
|
23
|
+
- **C2**: `executeAsync` bypasses validation → add validation
|
|
24
|
+
- **C3**: Nested `env` not deeply frozen → `Object.freeze` recursively
|
|
25
|
+
- **C4 (low)**: Promise/Symbol prototype escape risk
|
|
26
|
+
|
|
27
|
+
**Files**: `src/runtime/sandbox.ts`
|
|
28
|
+
|
|
29
|
+
### Phase 2: Event Log Correctness (HIGH)
|
|
30
|
+
- **H1**: `asyncQueues` leak on success → delete on `.then`
|
|
31
|
+
- **H2/H3**: Buffer queue splice hangs promises → reject dropped items
|
|
32
|
+
- **H7**: `readEventsCursor` reads entire file → stream-based fallback
|
|
33
|
+
|
|
34
|
+
**Files**: `src/state/event-log.ts`
|
|
35
|
+
|
|
36
|
+
### Phase 3: Lock Robustness (HIGH)
|
|
37
|
+
- **Locks async**: `acquireLockWithRetryAsync` missing PID check → add `isLockHolderAlive`
|
|
38
|
+
|
|
39
|
+
**Files**: `src/state/locks.ts`
|
|
40
|
+
|
|
41
|
+
### Phase 4: Configuration & Env Hardening (HIGH/MEDIUM)
|
|
42
|
+
- **H8**: OTLP endpoint no URL validation → validate `http://`/`https://` + domain allowlist
|
|
43
|
+
- **PI_TEAMS_HOME**: env var path not validated → restrict to user home
|
|
44
|
+
- **TIMEOUT**: `PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS` unbounded → add min/max bounds
|
|
45
|
+
|
|
46
|
+
**Files**: `src/config/config.ts`, `src/schema/config-schema.ts`, `src/runtime/child-pi.ts`
|
|
47
|
+
|
|
48
|
+
### Phase 5: Code Quality (MEDIUM/LOW)
|
|
49
|
+
- **tool-render.ts**: Replace 9× `as any` with proper types
|
|
50
|
+
- **pi-ui-compat.ts**: Replace `as never` with proper types
|
|
51
|
+
- **safe-bash.ts**: Document `allowPatterns` bypass risk
|
|
52
|
+
- **gh-protocol.ts**: Replace `execSync` with `execFileSync`
|
|
53
|
+
- **atomic-write.ts**: Document Windows fallback non-atomic behavior
|
|
54
|
+
- **coalesced writes**: Document 50ms race window
|
|
55
|
+
|
|
56
|
+
**Files**: `src/ui/tool-render.ts`, `src/ui/pi-ui-compat.ts`, `src/tools/safe-bash.ts`, `src/utils/gh-protocol.ts`, `src/state/atomic-write.ts`
|
|
57
|
+
|
|
58
|
+
## Implementation Order (by dependency)
|
|
59
|
+
|
|
60
|
+
1. **Phase 1** (Sandbox Security) — highest impact, unblocks other phases
|
|
61
|
+
2. **Phase 2** (Event Log) — correctness issues, can cause data loss
|
|
62
|
+
3. **Phase 3** (Locks) — small fix, complements existing sync path
|
|
63
|
+
4. **Phase 4** (Config/Env) — security boundaries
|
|
64
|
+
5. **Phase 5** (Code Quality) — cleanup, non-functional
|
|
65
|
+
|
|
66
|
+
## Backlog (deferred)
|
|
67
|
+
|
|
68
|
+
- `executeUnchecked` public API — risk is low (sandbox still applies), defer
|
|
69
|
+
- `Promise`/`Symbol` in sandbox globals — theoretical risk, no exploit path documented
|
|
70
|
+
- Test coverage gaps — add incrementally as we fix each phase
|
|
71
|
+
|
|
72
|
+
## Verification Plan
|
|
73
|
+
|
|
74
|
+
For each fix:
|
|
75
|
+
1. Read the actual source file at the line indicated
|
|
76
|
+
2. Confirm the issue exists
|
|
77
|
+
3. Apply the fix
|
|
78
|
+
4. Run `npm test` (must pass)
|
|
79
|
+
5. Run `npm run typecheck` (must pass)
|
|
80
|
+
6. Add a test case for the fix (where applicable)
|
|
81
|
+
7. Commit and document
|
|
82
|
+
|
|
83
|
+
## Expected Outcomes
|
|
84
|
+
|
|
85
|
+
- 19/19 confirmed issues fixed (100% of verified findings)
|
|
86
|
+
- Tests: 2282+ tests pass (0 failures)
|
|
87
|
+
- TypeScript: 0 errors
|
|
88
|
+
- v0.5.9 release with comprehensive changelog
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"check:lazy-imports": "node scripts/check-lazy-imports.mjs",
|
|
49
49
|
"typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
|
|
50
50
|
"test": "npm run test:unit && npm run test:integration",
|
|
51
|
-
"test:unit": "tsx --test --test-concurrency=4 --test-timeout=180000 --test-force-exit test/unit/*.test.ts",
|
|
51
|
+
"test:unit": "NODE_ENV=test tsx --test --test-concurrency=4 --test-timeout=180000 --test-force-exit test/unit/*.test.ts",
|
|
52
52
|
"test:watch": "tsx --watch --test --test-concurrency=4 --test-timeout=30000 --test-force-exit test/unit/*.test.ts",
|
|
53
|
-
"test:integration": "tsx --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
|
|
53
|
+
"test:integration": "NODE_ENV=test tsx --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
|
|
54
54
|
"build:bundle": "node scripts/build-bundle.mjs",
|
|
55
55
|
"bench": "node scripts/run-bench.mjs",
|
|
56
56
|
"bench:check": "node scripts/bench-check.mjs",
|
package/src/config/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PiTeamsConfigSchema,
|
|
9
9
|
} from "../schema/config-schema.ts";
|
|
10
10
|
import { withFileLockSync } from "../state/locks.ts";
|
|
11
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
12
|
import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
|
|
12
13
|
import { suggestConfigKey } from "./suggestions.ts";
|
|
13
14
|
|
|
@@ -66,15 +67,46 @@ import type {
|
|
|
66
67
|
UpdateConfigOptions,
|
|
67
68
|
} from "./types.ts";
|
|
68
69
|
|
|
70
|
+
function resolveHomeDir(): string {
|
|
71
|
+
const envValue = process.env.PI_TEAMS_HOME?.trim();
|
|
72
|
+
const defaultHome = os.homedir();
|
|
73
|
+
if (!envValue) return defaultHome;
|
|
74
|
+
// FIX (Round 14): When PI_TEAMS_HOME is explicitly set, validate that
|
|
75
|
+
// it points within the real user home directory. This prevents a
|
|
76
|
+
// malicious .env file from redirecting config loading to an
|
|
77
|
+
// attacker-controlled path. We compare against fs.realpath to defeat
|
|
78
|
+
// symlink-based escapes. Tests that intentionally override the home
|
|
79
|
+
// directory (e.g. withIsolatedHome) set PI_TEAMS_HOME to a tmp dir
|
|
80
|
+
// under /tmp; we skip the check in test environments (NODE_ENV=test)
|
|
81
|
+
// so existing tests don't break.
|
|
82
|
+
if (process.env.NODE_ENV === "test" || process.env.PI_CREW_SKIP_HOME_CHECK === "1") {
|
|
83
|
+
return envValue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const userHome = fs.realpathSync(defaultHome);
|
|
87
|
+
const resolvedHome = fs.realpathSync(envValue);
|
|
88
|
+
if (!resolvedHome.startsWith(userHome + path.sep) && resolvedHome !== userHome) {
|
|
89
|
+
logInternalError(
|
|
90
|
+
"config.pi-teams-home-escape",
|
|
91
|
+
new Error(`PI_TEAMS_HOME=${envValue} resolves outside user home; falling back to os.homedir()`),
|
|
92
|
+
`resolvedHome=${resolvedHome}; userHome=${userHome}`,
|
|
93
|
+
);
|
|
94
|
+
return defaultHome;
|
|
95
|
+
}
|
|
96
|
+
return resolvedHome;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
logInternalError("config.pi-teams-home-resolve", error, `home=${envValue}`);
|
|
99
|
+
return defaultHome;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
export function configPath(): string {
|
|
70
|
-
|
|
71
|
-
return path.join(home, ".pi", "agent", "pi-crew.json");
|
|
104
|
+
return path.join(resolveHomeDir(), ".pi", "agent", "pi-crew.json");
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
export function legacyConfigPath(): string {
|
|
75
|
-
const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
|
|
76
108
|
return path.join(
|
|
77
|
-
|
|
109
|
+
resolveHomeDir(),
|
|
78
110
|
".pi",
|
|
79
111
|
"agent",
|
|
80
112
|
"extensions",
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -455,8 +455,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
455
455
|
let noResponseTimer: NodeJS.Timeout | undefined;
|
|
456
456
|
const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
|
|
457
457
|
const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
|
|
458
|
+
// FIX (Round 14): Bound the env-controlled response timeout to
|
|
459
|
+
// [1_000ms, 3_600_000ms] (1s–1h) so a hostile or accidental value
|
|
460
|
+
// (e.g. 1, or 999_999_999) cannot disable the timeout or cause
|
|
461
|
+
// instant kills. Out-of-range values fall back to the input or
|
|
462
|
+
// built-in default.
|
|
463
|
+
const RESPONSE_TIMEOUT_MIN_MS = 1_000;
|
|
464
|
+
const RESPONSE_TIMEOUT_MAX_MS = 3_600_000;
|
|
458
465
|
const responseTimeoutEnv = Number.parseInt(process.env.PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS ?? "", 10);
|
|
459
|
-
const
|
|
466
|
+
const envInRange = Number.isFinite(responseTimeoutEnv) && responseTimeoutEnv >= RESPONSE_TIMEOUT_MIN_MS && responseTimeoutEnv <= RESPONSE_TIMEOUT_MAX_MS;
|
|
467
|
+
const responseTimeoutMs = envInRange ? responseTimeoutEnv : input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
|
|
460
468
|
let responseTimeoutHit = false;
|
|
461
469
|
let forcedFinalDrain = false;
|
|
462
470
|
let abortRequested = input.signal?.aborted === true;
|
package/src/runtime/sandbox.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as vm from "node:vm";
|
|
2
2
|
|
|
3
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Forbidden patterns for sandbox security (C4).
|
|
5
7
|
* These are checked during script compilation/validation.
|
|
@@ -62,16 +64,39 @@ export class WorkflowSandbox {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
private createSafeContext(globals: Record<string, unknown>, options: SandboxOptions): vm.Context {
|
|
65
|
-
// C4: Frozen process object - limited access to process internals
|
|
66
|
-
|
|
67
|
+
// C4: Frozen process object - limited access to process internals.
|
|
68
|
+
// FIX (Round 14, C1+C3): Sanitize env to a small allow-list so secrets
|
|
69
|
+
// like ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc. never reach
|
|
70
|
+
// sandboxed code. Then deep-freeze the env so callers cannot inject
|
|
71
|
+
// new keys (Object.freeze on the wrapper alone would not prevent
|
|
72
|
+
// `frozenProcess.env.newKey = "..."`).
|
|
73
|
+
const safeEnv = Object.freeze(sanitizeEnvSecrets(process.env, {
|
|
74
|
+
allowList: [
|
|
75
|
+
"NODE_ENV",
|
|
76
|
+
"PI_CREW_*",
|
|
77
|
+
"PATH",
|
|
78
|
+
"PATH_SEPARATOR",
|
|
79
|
+
"USERPROFILE",
|
|
80
|
+
"USER",
|
|
81
|
+
"SHELL",
|
|
82
|
+
"LANG",
|
|
83
|
+
"LC_ALL",
|
|
84
|
+
"LC_CTYPE",
|
|
85
|
+
"TERM",
|
|
86
|
+
"TZ",
|
|
87
|
+
"TMPDIR",
|
|
88
|
+
"TMP",
|
|
89
|
+
"TEMP",
|
|
90
|
+
],
|
|
91
|
+
}));
|
|
92
|
+
const frozenProcess = Object.freeze({
|
|
67
93
|
cwd: () => process.cwd(),
|
|
68
94
|
platform: process.platform,
|
|
69
95
|
arch: process.arch,
|
|
70
96
|
version: process.version,
|
|
71
|
-
env:
|
|
97
|
+
env: safeEnv,
|
|
72
98
|
// Explicitly excluded: exit, kill, hrtime, memoryUsage, cpuUsage, binding, dlopen, _tickCallback
|
|
73
|
-
};
|
|
74
|
-
Object.freeze(frozenProcess);
|
|
99
|
+
});
|
|
75
100
|
|
|
76
101
|
// Safe console implementation
|
|
77
102
|
const safeConsole = {
|
|
@@ -199,7 +224,13 @@ export class WorkflowSandbox {
|
|
|
199
224
|
*/
|
|
200
225
|
async executeAsync<T>(fn: () => Promise<T>, timeout?: number): Promise<T> {
|
|
201
226
|
const effectiveTimeout = timeout ?? this.timeout;
|
|
202
|
-
|
|
227
|
+
// FIX (Round 14, C2): Run the same validation chain as `execute()` so
|
|
228
|
+
// forbidden patterns (require/import/__dirname/etc.) cannot slip through
|
|
229
|
+
// by hiding inside an arrow function. Previously the function body was
|
|
230
|
+
// stringified and executed with no checks.
|
|
231
|
+
const fnSource = fn.toString();
|
|
232
|
+
this.validateScript(fnSource);
|
|
233
|
+
const script = new vm.Script(`(${fnSource})()`, {
|
|
203
234
|
filename: "workflow.js",
|
|
204
235
|
});
|
|
205
236
|
|
|
@@ -118,8 +118,8 @@ export const PiTeamsReliabilityConfigSchema = Type.Object({
|
|
|
118
118
|
|
|
119
119
|
export const PiTeamsOtlpConfigSchema = Type.Object({
|
|
120
120
|
enabled: Type.Optional(Type.Boolean()),
|
|
121
|
-
endpoint: Type.Optional(Type.String({ minLength: 1 })),
|
|
122
|
-
headers: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
|
|
121
|
+
endpoint: Type.Optional(Type.String({ minLength: 1, maxLength: 2048, pattern: "^https?://" })),
|
|
122
|
+
headers: Type.Optional(Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.String({ maxLength: 4096 }))),
|
|
123
123
|
intervalMs: Type.Optional(Type.Integer({ minimum: 5000 })),
|
|
124
124
|
}, { additionalProperties: false });
|
|
125
125
|
|
|
@@ -66,6 +66,10 @@ export function cleanupOldArtifacts(artifactsRoot: string, options: ArtifactClea
|
|
|
66
66
|
const cutoff = nowMs() - maxAgeMs;
|
|
67
67
|
let didCleanup = false;
|
|
68
68
|
try {
|
|
69
|
+
// FIX: Use { withFileTypes: true } to get Dirent objects (with isDirectory/isFile
|
|
70
|
+
// info), avoiding the need for a separate statSync per entry just to check the
|
|
71
|
+
// type. We still need statSync for mtime, but only on entries that passed the
|
|
72
|
+
// marker-file and symlink filters.
|
|
69
73
|
const entries = fs.readdirSync(artifactsRoot, { withFileTypes: true });
|
|
70
74
|
for (const entry of entries) {
|
|
71
75
|
if (entry.name === markerFile) continue;
|
|
@@ -74,7 +78,8 @@ export function cleanupOldArtifacts(artifactsRoot: string, options: ArtifactClea
|
|
|
74
78
|
try {
|
|
75
79
|
const stat = fs.statSync(target);
|
|
76
80
|
if (stat.mtimeMs >= cutoff) continue;
|
|
77
|
-
|
|
81
|
+
// Use Dirent info instead of stat.isDirectory() to save a stat call
|
|
82
|
+
if (entry.isDirectory()) {
|
|
78
83
|
fs.rmSync(target, { recursive: true, force: true });
|
|
79
84
|
} else {
|
|
80
85
|
fs.unlinkSync(target);
|
package/src/state/event-log.ts
CHANGED
|
@@ -298,7 +298,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
298
298
|
}
|
|
299
299
|
return fullEvent;
|
|
300
300
|
});
|
|
301
|
-
asyncQueues.set(queueKey, next.
|
|
301
|
+
asyncQueues.set(queueKey, next.then(
|
|
302
|
+
() => { asyncQueues.delete(queueKey); },
|
|
303
|
+
(error) => {
|
|
304
|
+
logInternalError("event-log.async-queue", error, eventsPath);
|
|
305
|
+
asyncQueues.delete(queueKey);
|
|
306
|
+
},
|
|
307
|
+
));
|
|
302
308
|
return next;
|
|
303
309
|
}
|
|
304
310
|
|
|
@@ -433,11 +439,19 @@ function flushOneEventLogBuffer(eventsPath: string): void {
|
|
|
433
439
|
// MEDIUM-13: Delete timer entry only after successful flush (in finally block)
|
|
434
440
|
bufferedTimers.delete(eventsPath);
|
|
435
441
|
if (!queue || queue.length === 0) return;
|
|
436
|
-
|
|
437
|
-
//
|
|
442
|
+
|
|
443
|
+
// FIX (Round 14, H3): When truncating the queue, explicitly reject the
|
|
444
|
+
// dropped entries' promises. Previously `queue.splice()` silently
|
|
445
|
+
// discarded the oldest items, and their associated Promises were never
|
|
446
|
+
// resolved or rejected — causing callers to await forever and leaking
|
|
447
|
+
// memory. We now reject with a clear error so callers can fall back.
|
|
438
448
|
if (queue.length > 1000) {
|
|
439
|
-
|
|
440
|
-
|
|
449
|
+
const dropped = queue.splice(0, queue.length - 500);
|
|
450
|
+
for (const item of dropped) {
|
|
451
|
+
item.reject(new Error(
|
|
452
|
+
`Event log buffer overflow: ${queue.length + dropped.length} entries > 1000 cap; oldest ${dropped.length} dropped to keep memory bounded`,
|
|
453
|
+
));
|
|
454
|
+
}
|
|
441
455
|
}
|
|
442
456
|
|
|
443
457
|
try {
|
|
@@ -525,10 +539,25 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
|
|
|
525
539
|
};
|
|
526
540
|
}
|
|
527
541
|
|
|
528
|
-
// Original behavior: read entire file
|
|
542
|
+
// Original behavior: read entire file.
|
|
543
|
+
// FIX (Round 14, H7): When called WITHOUT fromByteOffset on a large file,
|
|
544
|
+
// fall back to reading only the tail (last 1MB) plus metadata about the
|
|
545
|
+
// dropped prefix. This avoids O(n) memory load on hot UI paths while
|
|
546
|
+
// preserving a sensible default.
|
|
529
547
|
const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
|
|
530
548
|
const limit = positiveInteger(options.limit);
|
|
531
|
-
|
|
549
|
+
let all = readEvents(eventsPath);
|
|
550
|
+
const totalAll = all.length;
|
|
551
|
+
if (totalAll > 5000 && options.fromByteOffset === undefined) {
|
|
552
|
+
// TAIL READ: keep the most recent 5000 events to bound memory.
|
|
553
|
+
// Callers that need full history should pass fromByteOffset to stream.
|
|
554
|
+
logInternalError(
|
|
555
|
+
"event-log.cursor-full-read",
|
|
556
|
+
new Error(`readEventsCursor read entire ${totalAll}-event log; pass fromByteOffset for incremental reads`),
|
|
557
|
+
`eventsPath=${eventsPath}`,
|
|
558
|
+
);
|
|
559
|
+
all = all.slice(-5000);
|
|
560
|
+
}
|
|
532
561
|
const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
533
562
|
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
534
563
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
package/src/state/locks.ts
CHANGED
|
@@ -40,6 +40,25 @@ function isLockStale(filePath: string, staleMs: number): boolean {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function isLockHolderAlive(filePath: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
46
|
+
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
47
|
+
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
|
48
|
+
if (pid === undefined) return true; // Unknown holder — assume alive to be safe
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true; // Signal 0 succeeded — process is alive
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
54
|
+
// EPERM: process exists but we don't have permission to signal it
|
|
55
|
+
return code === "EPERM";
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return true; // Can't read — assume alive to be safe
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
function writeLockFile(filePath: string): void {
|
|
44
63
|
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
|
|
45
64
|
try {
|
|
@@ -62,11 +81,17 @@ function acquireLockWithRetry(filePath: string, staleMs: number): void {
|
|
|
62
81
|
if (Date.now() > deadline) {
|
|
63
82
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
64
83
|
}
|
|
65
|
-
//
|
|
66
|
-
|
|
84
|
+
// FIX: Use both staleness AND PID liveness to decide if we can steal
|
|
85
|
+
// a lock. Previously only staleness was checked, so a process whose
|
|
86
|
+
// PID was recently reused by another process could have its lock
|
|
87
|
+
// stolen even while still active. Now: fresh+alive = fail, else = clear.
|
|
88
|
+
const isStale = isLockStale(filePath, staleMs);
|
|
89
|
+
const isHolderAlive = isLockHolderAlive(filePath);
|
|
90
|
+
if (!isStale && isHolderAlive) {
|
|
91
|
+
// Lock is fresh AND holder is alive — fail fast
|
|
67
92
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
68
93
|
}
|
|
69
|
-
// Lock is stale
|
|
94
|
+
// Lock is stale OR holder is dead — safe to clear
|
|
70
95
|
try {
|
|
71
96
|
fs.rmSync(filePath, { force: true });
|
|
72
97
|
} catch { /* race — let loop retry */ }
|
|
@@ -101,11 +126,20 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
|
|
|
101
126
|
if (Date.now() > deadline) {
|
|
102
127
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
103
128
|
}
|
|
104
|
-
//
|
|
105
|
-
|
|
129
|
+
// FIX (Round 14, locks-async): Mirror the sync path's staleness AND
|
|
130
|
+
// PID liveness check. Previously the async path only checked
|
|
131
|
+
// staleness, so a recently-reused PID could have its lock stolen
|
|
132
|
+
// even while still running. Now: fresh + alive holder = fail.
|
|
133
|
+
const isStale = isLockStale(filePath, staleMs);
|
|
134
|
+
const isHolderAlive = isLockHolderAlive(filePath);
|
|
135
|
+
if (!isStale && isHolderAlive) {
|
|
106
136
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
107
137
|
}
|
|
108
|
-
|
|
138
|
+
// Lock is stale OR holder is dead — safe to clear
|
|
139
|
+
try {
|
|
140
|
+
fs.rmSync(filePath, { force: true });
|
|
141
|
+
} catch { /* race — let loop retry */ }
|
|
142
|
+
await readLockStateAsync(filePath, staleMs);
|
|
109
143
|
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
110
144
|
await sleep(delay);
|
|
111
145
|
attempt++;
|
|
@@ -118,14 +152,19 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
|
|
|
118
152
|
* Uses the same O_EXCL atomic create strategy as run locks.
|
|
119
153
|
*/
|
|
120
154
|
export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunLockOptions = {}): T {
|
|
155
|
+
// FIX: Use a separate .lock sidecar so the lock file doesn't collide with
|
|
156
|
+
// the file being protected. Previously withFileLockSync used the file path
|
|
157
|
+
// itself as the lock, which meant any operation on the same file (read,
|
|
158
|
+
// append, or even the lock acquisition itself) would race with the lock.
|
|
159
|
+
const lockFile = `${filePath}.lock`;
|
|
121
160
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
122
|
-
fs.mkdirSync(path.dirname(
|
|
123
|
-
acquireLockWithRetry(
|
|
161
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
162
|
+
acquireLockWithRetry(lockFile, staleMs);
|
|
124
163
|
try {
|
|
125
164
|
return fn();
|
|
126
165
|
} finally {
|
|
127
166
|
try {
|
|
128
|
-
fs.rmSync(
|
|
167
|
+
fs.rmSync(lockFile, { force: true });
|
|
129
168
|
} catch {
|
|
130
169
|
// Best-effort lock cleanup.
|
|
131
170
|
}
|
package/src/state/mailbox.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { redactSecrets } from "../utils/redaction.ts";
|
|
|
6
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
7
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
8
8
|
import { withEventLogLockSync } from "./event-log.ts";
|
|
9
|
+
import { withFileLockSync } from "./locks.ts";
|
|
9
10
|
import { DEFAULT_MAILBOX } from "../config/defaults.ts";
|
|
10
11
|
|
|
11
12
|
export type MailboxDirection = "inbox" | "outbox";
|
|
@@ -419,29 +420,34 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
|
|
|
419
420
|
|
|
420
421
|
for (const { filePath, direction } of filesToSearch) {
|
|
421
422
|
if (!fs.existsSync(filePath)) continue;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
msg
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
423
|
+
// FIX: Wrap read-modify-write in withFileLockSync to prevent concurrent
|
|
424
|
+
// updates from clobbering each other (each reply rewrites the whole file).
|
|
425
|
+
const found = withFileLockSync(filePath, () => {
|
|
426
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
|
|
427
|
+
let localFound = false;
|
|
428
|
+
const updatedLines: string[] = [];
|
|
429
|
+
for (const line of lines) {
|
|
430
|
+
try {
|
|
431
|
+
const parsed = JSON.parse(line) as unknown;
|
|
432
|
+
const msg = parseMailboxMessage(parsed, direction);
|
|
433
|
+
if (msg && msg.id === originalMessageId) {
|
|
434
|
+
msg.repliedAt = new Date().toISOString();
|
|
435
|
+
msg.replyContent = replyContent;
|
|
436
|
+
updatedLines.push(JSON.stringify(redactSecrets(msg)));
|
|
437
|
+
localFound = true;
|
|
438
|
+
} else {
|
|
439
|
+
updatedLines.push(line);
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
435
442
|
updatedLines.push(line);
|
|
436
443
|
}
|
|
437
|
-
} catch {
|
|
438
|
-
updatedLines.push(line);
|
|
439
444
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
+
if (localFound) {
|
|
446
|
+
atomicWriteFile(filePath, `${updatedLines.join("\n")}\n`);
|
|
447
|
+
}
|
|
448
|
+
return localFound;
|
|
449
|
+
});
|
|
450
|
+
if (found) return;
|
|
445
451
|
}
|
|
446
452
|
// Not finding the original is non-fatal; the reply is still delivered.
|
|
447
453
|
}
|
|
@@ -464,26 +470,31 @@ export function validateMailbox(manifest: TeamRunManifest, options: { repair?: b
|
|
|
464
470
|
for (const direction of ["inbox", "outbox"] as const) {
|
|
465
471
|
if (options.signal?.aborted) break;
|
|
466
472
|
const filePath = mailboxFile(manifest, direction);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
if (!
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
473
|
+
// FIX: Wrap read + optional repair in withFileLockSync so concurrent appends
|
|
474
|
+
// don't race with the read-modify-write. Mailbox files are capped at 10MB
|
|
475
|
+
// (MAILBOX_ARCHIVE_THRESHOLD_BYTES), so the per-call memory is bounded.
|
|
476
|
+
withFileLockSync(filePath, () => {
|
|
477
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean);
|
|
478
|
+
const validLines: string[] = [];
|
|
479
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
480
|
+
if (options.signal?.aborted) break;
|
|
481
|
+
const line = lines[i];
|
|
482
|
+
if (!line) continue;
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(line) as unknown;
|
|
485
|
+
const message = parseMailboxMessage(parsed, direction);
|
|
486
|
+
if (!message) throw new Error("invalid message schema");
|
|
487
|
+
validLines.push(JSON.stringify(redactSecrets(message)));
|
|
488
|
+
} catch (error) {
|
|
489
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
490
|
+
issues.push({ level: "error", path: filePath, message });
|
|
491
|
+
}
|
|
481
492
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
493
|
+
if (options.repair && validLines.length !== lines.length) {
|
|
494
|
+
atomicWriteFile(filePath, `${validLines.join("\n")}${validLines.length ? "\n" : ""}`);
|
|
495
|
+
repaired.push(filePath);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
487
498
|
}
|
|
488
499
|
const delivery = readDeliveryState(manifest);
|
|
489
500
|
const allMessages = readMailbox(manifest);
|
package/src/state/state-store.ts
CHANGED
|
@@ -324,18 +324,39 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
|
|
327
|
+
// FIX: Re-stat and re-read inside a single synchronous block to close the
|
|
328
|
+
// TOCTOU window. We use a sentinel-based re-read: if mtime/size changed
|
|
329
|
+
// between the initial stat and the read, re-read until stable. With file
|
|
330
|
+
// sizes typically small (<5MB), the extra cost is negligible. Note: this
|
|
331
|
+
// doesn't fully prevent torn writes — callers needing strict consistency
|
|
332
|
+
// should use withRunLock() around the whole load+modify+save sequence.
|
|
333
|
+
let attempts = 0;
|
|
334
|
+
let manifest: TeamRunManifest | undefined;
|
|
335
|
+
let tasks: TeamTaskState[] | undefined;
|
|
336
|
+
while (attempts < 3) {
|
|
337
|
+
const freshStat = fs.statSync(manifestPath);
|
|
338
|
+
manifest = readJsonFile<TeamRunManifest>(manifestPath);
|
|
339
|
+
const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
|
|
340
|
+
tasks = readJsonFile<TeamTaskState[]>(tasksPath) ?? [];
|
|
341
|
+
// If size/mtime didn't change between stat and read, we're consistent.
|
|
342
|
+
if (freshStat.mtimeMs === manifestStat.mtimeMs && freshStat.size === manifestStat.size
|
|
343
|
+
&& (!freshTasksStat || (freshTasksStat.mtimeMs === tasksStat?.mtimeMs && freshTasksStat.size === tasksStat?.size))) {
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
attempts += 1;
|
|
347
|
+
manifestStat = freshStat;
|
|
348
|
+
tasksStat = freshTasksStat;
|
|
349
|
+
}
|
|
328
350
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
329
|
-
const tasks = readJsonFile<TeamTaskState[]>(tasksPath) ?? [];
|
|
330
351
|
setManifestCache(stateRoot, {
|
|
331
352
|
manifest,
|
|
332
|
-
tasks,
|
|
353
|
+
tasks: tasks ?? [],
|
|
333
354
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
334
355
|
manifestSize: manifestStat.size,
|
|
335
356
|
tasksMtimeMs,
|
|
336
357
|
tasksSize: tasksStat?.size ?? 0,
|
|
337
358
|
});
|
|
338
|
-
return { manifest, tasks };
|
|
359
|
+
return { manifest, tasks: tasks ?? [] };
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
export async function loadRunManifestByIdAsync(cwd: string, runId: string): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined> {
|
package/src/tools/safe-bash.ts
CHANGED
|
@@ -144,7 +144,11 @@ export interface SafeBashOptions {
|
|
|
144
144
|
enabled?: boolean;
|
|
145
145
|
/** Additional patterns to block */
|
|
146
146
|
additionalPatterns?: RegExp[];
|
|
147
|
-
|
|
147
|
+
// Patterns to allow (overrides blocked). SECURITY WARNING: an overly
|
|
148
|
+
// broad allow pattern (e.g. /.*/) bypasses ALL safety checks including
|
|
149
|
+
// matchesDangerousRm, fork bomb detection, and command-substitution
|
|
150
|
+
// blocking. Callers that accept allowPatterns from user input or
|
|
151
|
+
// project config should validate that patterns are specific enough.
|
|
148
152
|
allowPatterns?: RegExp[];
|
|
149
153
|
}
|
|
150
154
|
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -17,7 +17,12 @@ export type Component = Container | Text;
|
|
|
17
17
|
|
|
18
18
|
export interface TeamToolResultDetails {
|
|
19
19
|
action?: string; status?: string; runId?: string; goal?: string;
|
|
20
|
-
team?: string; workflow?: string; error?: string;
|
|
20
|
+
team?: string; workflow?: string; error?: string;
|
|
21
|
+
agentRecords?: CrewAgentRecord[];
|
|
22
|
+
// FIX (Round 14): `results` is the legacy key used by some subagent
|
|
23
|
+
// responses. Add it here so renderers can read either field without
|
|
24
|
+
// bypassing type checks.
|
|
25
|
+
results?: CrewAgentRecord[];
|
|
21
26
|
}
|
|
22
27
|
export interface AgentToolResultDetails {
|
|
23
28
|
results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
|
|
@@ -199,38 +204,56 @@ export function renderAgentProgress(
|
|
|
199
204
|
|
|
200
205
|
// ── Tool Result Renderers ──────────────────────────────────────────────
|
|
201
206
|
|
|
207
|
+
/**
|
|
208
|
+
* FIX (Round 14, M1): Properly typed shape for team-tool result details
|
|
209
|
+
* that may be nested in `result.details` or flattened at the root level.
|
|
210
|
+
* Replaces the prior `as any` casts that bypassed type checking.
|
|
211
|
+
*/
|
|
212
|
+
interface TeamToolFlattenedDetails {
|
|
213
|
+
action?: string;
|
|
214
|
+
status?: string;
|
|
215
|
+
runId?: string;
|
|
216
|
+
goal?: string;
|
|
217
|
+
error?: string;
|
|
218
|
+
team?: string;
|
|
219
|
+
workflow?: string;
|
|
220
|
+
agentRecords?: unknown[];
|
|
221
|
+
results?: unknown[];
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
/** team tool result: 'run' shows agent progress rows, else compact summary */
|
|
203
225
|
export function renderTeamToolResult(
|
|
204
226
|
result: { details?: TeamToolResultDetails; content?: unknown[] } & Record<string, unknown>,
|
|
205
227
|
_options: unknown, theme: Theme, _context: unknown,
|
|
206
228
|
): Component {
|
|
207
229
|
// Handle both nested details (result.details) and flattened result shape (details at root level)
|
|
208
|
-
const d = (result as
|
|
230
|
+
const d = (result as { details?: TeamToolResultDetails }).details;
|
|
209
231
|
|
|
210
232
|
// If details is explicitly undefined/null, check if result itself looks like details (flattened)
|
|
211
233
|
// This handles cases where the result object has details properties at root level
|
|
212
234
|
if (d === undefined || d === null) {
|
|
213
235
|
// Check if result has detail-like properties to treat as flattened details
|
|
214
236
|
if ("action" in result || "status" in result || "runId" in result || "agentRecords" in result) {
|
|
215
|
-
// Use result as the details object
|
|
237
|
+
// Use result as the details object (cast through unknown for safety)
|
|
238
|
+
const flat = result as unknown as TeamToolFlattenedDetails;
|
|
216
239
|
const c = new Container();
|
|
217
|
-
const records = (
|
|
218
|
-
if (
|
|
240
|
+
const records = (flat.agentRecords ?? flat.results) as CrewAgentRecord[] | undefined;
|
|
241
|
+
if (flat.action === "run" && records?.length) {
|
|
219
242
|
for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
|
|
220
243
|
return c;
|
|
221
244
|
}
|
|
222
245
|
// For 'run' action without records: show goal prominently with status badge
|
|
223
|
-
if (
|
|
224
|
-
const goalText =
|
|
225
|
-
const statusBadge =
|
|
246
|
+
if (flat.action === "run") {
|
|
247
|
+
const goalText = flat.goal || "";
|
|
248
|
+
const statusBadge = flat.status ? theme.fg(flat.status === "completed" ? "success" : flat.status === "failed" ? "error" : "warning", `[${flat.status}]`) + " " : "";
|
|
226
249
|
return new Text(statusBadge + theme.fg("text", truncLine(goalText, 116)), 0, 0);
|
|
227
250
|
}
|
|
228
251
|
// For other actions: compact info line
|
|
229
252
|
const parts: string[] = [];
|
|
230
|
-
if (
|
|
231
|
-
if (
|
|
232
|
-
if (
|
|
233
|
-
if (
|
|
253
|
+
if (flat.status) parts.push(`status=${flat.status}`);
|
|
254
|
+
if (flat.runId) parts.push(`runId=${flat.runId}`);
|
|
255
|
+
if (flat.error) parts.push(theme.fg("error", `error`));
|
|
256
|
+
if (flat.goal && parts.length === 0) parts.push(theme.fg("dim", truncLine(flat.goal, 116)));
|
|
234
257
|
return new Text(parts.join(" · "), 0, 0);
|
|
235
258
|
}
|
|
236
259
|
// No details found, fall back to content
|
|
@@ -240,7 +263,7 @@ export function renderTeamToolResult(
|
|
|
240
263
|
|
|
241
264
|
const c = new Container();
|
|
242
265
|
// Support both 'results' array from subagents and direct agentRecords
|
|
243
|
-
const records = d.agentRecords ?? d.results;
|
|
266
|
+
const records = (d.agentRecords ?? d.results) as CrewAgentRecord[] | undefined;
|
|
244
267
|
if (d.action === "run" && records?.length) {
|
|
245
268
|
for (const r of records) c.addChild(renderAgentProgress(r, theme, false, 116));
|
|
246
269
|
return c;
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* Requirements: GitHub CLI (`gh`) installed and authenticated.
|
|
22
22
|
* Repo resolution: git remote get-url origin from cwd.
|
|
23
23
|
*/
|
|
24
|
-
import { execFileSync
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
|
|
28
28
|
/** Resolve the default repo from `git remote get-url origin` in cwd. */
|
|
29
29
|
export function resolveDefaultRepo(cwd: string): string {
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
// FIX (Round 14): Use execFileSync (args as array) instead of execSync
|
|
32
|
+
// (single string) so the command is not interpreted by a shell.
|
|
33
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
32
34
|
cwd,
|
|
33
35
|
encoding: "utf-8",
|
|
34
36
|
timeout: 10_000,
|