@dungle-scrubs/harness-cli-normalizer 0.6.2 → 0.6.4
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/README.md +50 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +4 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/check.d.ts +5 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +47 -8
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +23 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect-context.d.ts +5 -0
- package/dist/cli/inspect-context.d.ts.map +1 -0
- package/dist/cli/inspect-context.js +86 -0
- package/dist/cli/inspect-context.js.map +1 -0
- package/dist/cli/inspect-session.d.ts +4 -0
- package/dist/cli/inspect-session.d.ts.map +1 -0
- package/dist/cli/inspect-session.js +71 -0
- package/dist/cli/inspect-session.js.map +1 -0
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +53 -24
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/plan-turn.js +2 -2
- package/dist/cli/plan-turn.js.map +1 -1
- package/dist/cli/runtime-compatibility.d.ts +17 -0
- package/dist/cli/runtime-compatibility.d.ts.map +1 -0
- package/dist/cli/runtime-compatibility.js +46 -0
- package/dist/cli/runtime-compatibility.js.map +1 -0
- package/dist/execution/context-inspection.d.ts +20 -0
- package/dist/execution/context-inspection.d.ts.map +1 -0
- package/dist/execution/context-inspection.js +174 -0
- package/dist/execution/context-inspection.js.map +1 -0
- package/dist/execution/deps.d.ts +3 -0
- package/dist/execution/deps.d.ts.map +1 -1
- package/dist/execution/environment.d.ts +3 -0
- package/dist/execution/environment.d.ts.map +1 -0
- package/dist/execution/environment.js +16 -0
- package/dist/execution/environment.js.map +1 -0
- package/dist/execution/lines.d.ts +3 -1
- package/dist/execution/lines.d.ts.map +1 -1
- package/dist/execution/lines.js +26 -23
- package/dist/execution/lines.js.map +1 -1
- package/dist/execution/node-deps.d.ts.map +1 -1
- package/dist/execution/node-deps.js +14 -19
- package/dist/execution/node-deps.js.map +1 -1
- package/dist/execution/stream-turn.d.ts +1 -1
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +29 -5
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/execution/supervisor.d.ts +5 -0
- package/dist/execution/supervisor.d.ts.map +1 -1
- package/dist/execution/supervisor.js +26 -19
- package/dist/execution/supervisor.js.map +1 -1
- package/dist/interpretation/argv.d.ts +2 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +26 -4
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/context-inspection.d.ts +41 -0
- package/dist/interpretation/context-inspection.d.ts.map +1 -0
- package/dist/interpretation/context-inspection.js +114 -0
- package/dist/interpretation/context-inspection.js.map +1 -0
- package/dist/interpretation/refusal.d.ts +1 -1
- package/dist/interpretation/refusal.d.ts.map +1 -1
- package/dist/interpretation/refusal.js.map +1 -1
- package/dist/interpretation/resolve-options.d.ts +1 -0
- package/dist/interpretation/resolve-options.d.ts.map +1 -1
- package/dist/interpretation/resolve-options.js +21 -28
- package/dist/interpretation/resolve-options.js.map +1 -1
- package/dist/interpretation/turn-options.d.ts +1 -1
- package/dist/interpretation/turn-options.d.ts.map +1 -1
- package/dist/interpretation/turn-options.js +9 -1
- package/dist/interpretation/turn-options.js.map +1 -1
- package/dist/knowledge/claude-code.d.ts +1 -1
- package/dist/knowledge/claude-code.d.ts.map +1 -1
- package/dist/knowledge/claude-code.js +22 -7
- package/dist/knowledge/claude-code.js.map +1 -1
- package/dist/knowledge/codex.d.ts.map +1 -1
- package/dist/knowledge/codex.js +1 -0
- package/dist/knowledge/codex.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +12 -0
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/muse.d.ts.map +1 -1
- package/dist/knowledge/muse.js +1 -0
- package/dist/knowledge/muse.js.map +1 -1
- package/dist/knowledge/pi.d.ts.map +1 -1
- package/dist/knowledge/pi.js +1 -0
- package/dist/knowledge/pi.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/args.ts +4 -0
- package/src/cli/check.ts +56 -11
- package/src/cli/help.ts +23 -1
- package/src/cli/inspect-context.ts +96 -0
- package/src/cli/inspect-session.ts +85 -0
- package/src/cli/inspect.ts +65 -28
- package/src/cli/plan-turn.ts +2 -2
- package/src/cli/runtime-compatibility.ts +55 -0
- package/src/execution/context-inspection.ts +177 -0
- package/src/execution/deps.ts +3 -0
- package/src/execution/environment.ts +15 -0
- package/src/execution/lines.ts +22 -23
- package/src/execution/node-deps.ts +14 -17
- package/src/execution/stream-turn.ts +31 -4
- package/src/execution/supervisor.ts +27 -17
- package/src/interpretation/argv.ts +24 -4
- package/src/interpretation/context-inspection.ts +169 -0
- package/src/interpretation/refusal.ts +1 -0
- package/src/interpretation/resolve-options.ts +29 -30
- package/src/interpretation/turn-options.ts +8 -0
- package/src/knowledge/claude-code.ts +23 -7
- package/src/knowledge/codex.ts +1 -0
- package/src/knowledge/descriptor.ts +9 -0
- package/src/knowledge/muse.ts +1 -0
- package/src/knowledge/pi.ts +1 -0
package/src/cli/inspect.ts
CHANGED
|
@@ -2,18 +2,33 @@ import { capabilitiesOf } from "../interpretation/capabilities.js";
|
|
|
2
2
|
import { canonicalTable, mergeToolMaps } from "../interpretation/tool-vocabulary.js";
|
|
3
3
|
import { HARNESS_MODES, type HarnessMode } from "../knowledge/descriptor.js";
|
|
4
4
|
import { defaultDescriptors } from "../knowledge/overrides.js";
|
|
5
|
-
import {
|
|
5
|
+
import { parseCommonFlags } from "./args.js";
|
|
6
6
|
import { ConfigError, loadProjectConfig, loadUserConfig } from "./config.js";
|
|
7
7
|
import { EXIT_REFUSAL } from "./exit-codes.js";
|
|
8
|
+
import { inspectContextCommand } from "./inspect-context.js";
|
|
9
|
+
import { inspectSessionRuntime } from "./inspect-session.js";
|
|
8
10
|
import { planTurn, writePlanDiagnostics } from "./plan-turn.js";
|
|
9
11
|
import { refuse } from "./refuse.js";
|
|
10
12
|
import { resolveHarness } from "./resolve-harness.js";
|
|
13
|
+
import { runtimeCompatibility } from "./runtime-compatibility.js";
|
|
11
14
|
|
|
12
15
|
export const inspect = async (harnessName: string, rawArgs: string[]): Promise<void> => {
|
|
13
16
|
const h = resolveHarness(harnessName);
|
|
14
|
-
|
|
17
|
+
let parsed: ReturnType<typeof parseCommonFlags>;
|
|
18
|
+
try {
|
|
19
|
+
parsed = parseCommonFlags(rawArgs);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
// Preserve the shared prompt-injection and native-spelling diagnostics.
|
|
22
|
+
const outcome = await planTurn(h, rawArgs, { command: "inspect" });
|
|
23
|
+
if (outcome.kind === "refusal") {
|
|
24
|
+
refuse(outcome.refusal, outcome.wantJson && rawArgs.includes("--context"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
const values = parsed.values as Record<string, unknown>;
|
|
15
30
|
|
|
16
|
-
if (
|
|
31
|
+
if (values.help === true) {
|
|
17
32
|
const { INSPECT_HELP } = await import("./help.js");
|
|
18
33
|
process.stdout.write(INSPECT_HELP);
|
|
19
34
|
return;
|
|
@@ -22,41 +37,61 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
22
37
|
// --argv: the preview is the plan the run command would spawn from, so
|
|
23
38
|
// the two agree by construction (RFC-02 change 10). Refusals go through
|
|
24
39
|
// the shared refuse path like every other command's.
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
if (
|
|
41
|
+
(values.capabilities && (values.argv || values.runtime || values.context)) ||
|
|
42
|
+
(values.context && (values.argv || values.runtime))
|
|
43
|
+
) {
|
|
44
|
+
refuse(
|
|
45
|
+
{
|
|
46
|
+
issue: "invalid-option-value",
|
|
47
|
+
message: "--capabilities and --context are mutually exclusive with other inspection modes",
|
|
48
|
+
},
|
|
49
|
+
values.context === true && values.json === true,
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (values.context && values.mode !== undefined && values.mode !== "headless-turn") {
|
|
54
|
+
refuse(
|
|
55
|
+
{ issue: "invalid-option-value", message: "--context supports headless-turn only" },
|
|
56
|
+
values.json === true,
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (values.context === true) {
|
|
61
|
+
await inspectContextCommand(h, rawArgs);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (values.argv === true || values.runtime === true) {
|
|
65
|
+
if (values.runtime === true && (await inspectSessionRuntime(h, rawArgs))) return;
|
|
31
66
|
const outcome = await planTurn(h, rawArgs, { command: "inspect" });
|
|
32
67
|
if (outcome.kind === "refusal") {
|
|
33
68
|
refuse(outcome.refusal, false);
|
|
34
69
|
return;
|
|
35
70
|
}
|
|
36
71
|
writePlanDiagnostics(h, outcome.plan, "argv");
|
|
72
|
+
if (values.runtime === true) {
|
|
73
|
+
const mode = parseCommonFlags(rawArgs).values.mode ?? "headless-turn";
|
|
74
|
+
if (mode !== "headless-turn" && mode !== "headless-session") {
|
|
75
|
+
refuse(
|
|
76
|
+
{
|
|
77
|
+
message: `invalid runtime mode: ${String(mode)}`,
|
|
78
|
+
issue: "invalid-option-value",
|
|
79
|
+
supported: ["headless-turn", "headless-session"],
|
|
80
|
+
},
|
|
81
|
+
false,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const argv = outcome.plan.redactedArgv;
|
|
86
|
+
process.stdout.write(
|
|
87
|
+
`${JSON.stringify({ v: 1, argvKind: "redacted-preview", argv, ...(await runtimeCompatibility(h, outcome.plan.options)) })}\n`,
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
37
91
|
process.stdout.write(`${JSON.stringify(outcome.plan.redactedArgv)}\n`);
|
|
38
92
|
return;
|
|
39
93
|
}
|
|
40
94
|
|
|
41
|
-
// --capabilities path: pure capability record, no spawn, no config, no prompt
|
|
42
|
-
const { parseCommonFlags } = await import("./args.js");
|
|
43
|
-
let parsed: ReturnType<typeof parseCommonFlags>;
|
|
44
|
-
try {
|
|
45
|
-
parsed = parseCommonFlags(rawArgs);
|
|
46
|
-
} catch (err) {
|
|
47
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
-
refuse(
|
|
49
|
-
{
|
|
50
|
-
message: `unknown flag: ${message}`,
|
|
51
|
-
issue: "invalid-option-value",
|
|
52
|
-
trailer: ["Run 'hcn inspect --help' for usage."],
|
|
53
|
-
},
|
|
54
|
-
false,
|
|
55
|
-
);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const values = parsed.values as Record<string, unknown>;
|
|
59
|
-
|
|
60
95
|
if (values.capabilities === true) {
|
|
61
96
|
const mode = values.mode === undefined ? "headless-turn" : String(values.mode);
|
|
62
97
|
if (!(HARNESS_MODES as readonly string[]).includes(mode)) {
|
|
@@ -118,11 +153,13 @@ export const inspect = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
118
153
|
baseFlags: h.launch.baseFlags,
|
|
119
154
|
subcommands: h.launch.subcommands,
|
|
120
155
|
streamFlags: h.launch.streamFlags,
|
|
156
|
+
stdinPrompt: h.launch.stdinPrompt ?? null,
|
|
121
157
|
promptStyle: h.launch.promptStyle,
|
|
122
158
|
toolsFlag: h.tools.includeFlag,
|
|
123
159
|
idFlag: h.launch.idFlag,
|
|
124
160
|
},
|
|
125
161
|
resume: h.resume,
|
|
162
|
+
contextInspection: h.contextInspection ?? null,
|
|
126
163
|
sessionMode: h.sessionMode,
|
|
127
164
|
vocabulary: {
|
|
128
165
|
models: h.vocabulary.models,
|
package/src/cli/plan-turn.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* preview agree by construction, skill tokens and passthrough included.
|
|
8
8
|
*/
|
|
9
9
|
import { redactArgv, type TurnRunOptions } from "../execution/stream-turn.js";
|
|
10
|
-
import { buildSpawnArgv, promptTextOf } from "../interpretation/argv.js";
|
|
10
|
+
import { buildSpawnArgv, promptTextOf, stdinPromptOf } from "../interpretation/argv.js";
|
|
11
11
|
import { assertIsolationCombination } from "../interpretation/isolation.js";
|
|
12
12
|
import { composeEscalatedPrompt } from "../interpretation/question.js";
|
|
13
13
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
@@ -320,7 +320,7 @@ export const planTurn = async (
|
|
|
320
320
|
wantJson,
|
|
321
321
|
options,
|
|
322
322
|
argv,
|
|
323
|
-
redactedArgv: redactArgv(argv, promptTextOf(options)),
|
|
323
|
+
redactedArgv: redactArgv(argv, promptTextOf(options), stdinPromptOf(h, options) !== null),
|
|
324
324
|
provenance,
|
|
325
325
|
unrenderable,
|
|
326
326
|
behavior,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { mergeEnvironment } from "../execution/environment.js";
|
|
4
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
5
|
+
import { installedVersion } from "./check.js";
|
|
6
|
+
|
|
7
|
+
function executablePath(bin: string, cwd: string, searchPath: string): string | null {
|
|
8
|
+
const candidates =
|
|
9
|
+
isAbsolute(bin) || bin.includes("/")
|
|
10
|
+
? [resolve(cwd, bin)]
|
|
11
|
+
: searchPath.split(delimiter).map((dir) => resolve(cwd, dir, bin));
|
|
12
|
+
for (const candidate of candidates) {
|
|
13
|
+
try {
|
|
14
|
+
accessSync(candidate, constants.X_OK);
|
|
15
|
+
if (statSync(candidate).isFile()) return realpathSync(candidate);
|
|
16
|
+
} catch {
|
|
17
|
+
// PATH lookup continues when an earlier directory has no executable.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Exact verified versions establish support. Newer is not evidence of compatibility. */
|
|
24
|
+
export async function runtimeCompatibility(
|
|
25
|
+
harness: HarnessDescriptor,
|
|
26
|
+
options: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>> } = {},
|
|
27
|
+
): Promise<{
|
|
28
|
+
readonly executable: { readonly path: string | null; readonly version: string | null };
|
|
29
|
+
readonly resume: { readonly status: "supported" | "unknown"; readonly reason: string | null };
|
|
30
|
+
readonly verifiedAgainst: string;
|
|
31
|
+
}> {
|
|
32
|
+
const { cwd = process.cwd(), env: environment } = options;
|
|
33
|
+
const inheritedEnvironment = process.env;
|
|
34
|
+
const effectiveEnvironment = mergeEnvironment(inheritedEnvironment, environment);
|
|
35
|
+
// Node's Unix lookup uses this default when PATH has been removed.
|
|
36
|
+
const searchPath = effectiveEnvironment.PATH ?? "/usr/bin:/bin";
|
|
37
|
+
const path = executablePath(harness.bin, cwd, searchPath);
|
|
38
|
+
const version =
|
|
39
|
+
path === null ? null : await installedVersion(path, { cwd, env: effectiveEnvironment });
|
|
40
|
+
const supported = version !== null && version === harness.verifiedAgainst;
|
|
41
|
+
return {
|
|
42
|
+
executable: { path, version },
|
|
43
|
+
resume: {
|
|
44
|
+
status: supported ? "supported" : "unknown",
|
|
45
|
+
reason: supported
|
|
46
|
+
? null
|
|
47
|
+
: path === null
|
|
48
|
+
? "The selected executable could not be resolved."
|
|
49
|
+
: version === null
|
|
50
|
+
? "The selected executable did not report a usable version."
|
|
51
|
+
: "The selected executable version does not match the verified adapter. Verify this version before native takeover.",
|
|
52
|
+
},
|
|
53
|
+
verifiedAgainst: harness.verifiedAgainst,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { ContextInspection } from "../interpretation/context-inspection.js";
|
|
2
|
+
import { createContextProbe } from "../interpretation/context-inspection.js";
|
|
3
|
+
import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
|
|
4
|
+
import type { HarnessDescriptor } from "../knowledge/descriptor.js";
|
|
5
|
+
|
|
6
|
+
export type { ContextInspection } from "../interpretation/context-inspection.js";
|
|
7
|
+
|
|
8
|
+
import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
|
|
9
|
+
import { LineBuffer } from "./lines.js";
|
|
10
|
+
import { KILL_GRACE_MS, superviseTermination } from "./supervisor.js";
|
|
11
|
+
|
|
12
|
+
export interface ContextInspectionOptions {
|
|
13
|
+
/** Already validated, version-bound ephemeral argv. The CLI adapter owns
|
|
14
|
+
* no-persistence and fork-on-resume enforcement before this layer runs. */
|
|
15
|
+
readonly argv: readonly string[];
|
|
16
|
+
readonly cwd?: string;
|
|
17
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
18
|
+
readonly harness: HarnessDescriptor;
|
|
19
|
+
readonly inputId: string;
|
|
20
|
+
readonly prompt: string;
|
|
21
|
+
readonly signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Bounds serialized transport, not tokens. Native replay echoes the complete
|
|
25
|
+
// user message; the ordinary event line bound would silently drop its ack.
|
|
26
|
+
export const CONTEXT_TRANSPORT_MAX = 8 * 1024 * 1024;
|
|
27
|
+
const INSPECTION_TIMEOUT_MS = 30_000;
|
|
28
|
+
function deferred<TValue>(): {
|
|
29
|
+
readonly promise: Promise<TValue>;
|
|
30
|
+
readonly resolve: (value: TValue) => void;
|
|
31
|
+
} {
|
|
32
|
+
let resolve!: (value: TValue) => void;
|
|
33
|
+
const promise = new Promise<TValue>((done) => {
|
|
34
|
+
resolve = done;
|
|
35
|
+
});
|
|
36
|
+
return { promise, resolve };
|
|
37
|
+
}
|
|
38
|
+
/** One disposable native control channel. shouldQuery:false stages the
|
|
39
|
+
* pending task without asking the assistant to execute it. */
|
|
40
|
+
export async function inspectContext(
|
|
41
|
+
options: ContextInspectionOptions,
|
|
42
|
+
deps: RunnerDeps,
|
|
43
|
+
): Promise<ContextInspection> {
|
|
44
|
+
if (options.signal?.aborted) return { status: "unavailable", reason: "cancelled" };
|
|
45
|
+
const probe = createContextProbe(options.inputId, options.prompt);
|
|
46
|
+
if (new TextEncoder().encode(probe.staged).byteLength > CONTEXT_TRANSPORT_MAX) {
|
|
47
|
+
return { status: "unavailable", reason: "transport-limit" };
|
|
48
|
+
}
|
|
49
|
+
let child: SpawnedProcess;
|
|
50
|
+
try {
|
|
51
|
+
child = deps.spawn(options.argv, { cwd: options.cwd, env: options.env, stdin: "pipe" });
|
|
52
|
+
} catch {
|
|
53
|
+
return { status: "unavailable", reason: "transport" };
|
|
54
|
+
}
|
|
55
|
+
let finished = false;
|
|
56
|
+
let exited = false;
|
|
57
|
+
let exitCode: number | null = null;
|
|
58
|
+
let nativeFailure: "auth" | "limit" | null = null;
|
|
59
|
+
let disposalTimer: TimerHandle | undefined;
|
|
60
|
+
const result = deferred<ContextInspection>();
|
|
61
|
+
const stopped = deferred<boolean>();
|
|
62
|
+
const signal = (kind: "SIGTERM" | "SIGKILL"): void => {
|
|
63
|
+
try {
|
|
64
|
+
deps.signal(child, kind);
|
|
65
|
+
} catch {
|
|
66
|
+
/* Exit races are handled by exited. */
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const termination = superviseTermination(deps.clock, signal);
|
|
70
|
+
const finish = (value: ContextInspection): void => {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
finished = true;
|
|
73
|
+
result.resolve(value);
|
|
74
|
+
try {
|
|
75
|
+
child.stdin?.end();
|
|
76
|
+
} catch {
|
|
77
|
+
/* A broken pipe still needs cleanup. */
|
|
78
|
+
}
|
|
79
|
+
if (exited) {
|
|
80
|
+
stopped.resolve(true);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
termination.escalate();
|
|
84
|
+
disposalTimer = deps.clock.setTimeout(() => {
|
|
85
|
+
child.disposeOutput();
|
|
86
|
+
termination.settle();
|
|
87
|
+
stopped.resolve(false);
|
|
88
|
+
}, 2 * KILL_GRACE_MS);
|
|
89
|
+
};
|
|
90
|
+
const write = (line: string): void => {
|
|
91
|
+
try {
|
|
92
|
+
if (child.stdin === undefined) {
|
|
93
|
+
finish({ status: "unavailable", reason: "transport" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
child.stdin.write(`${line}\n`);
|
|
97
|
+
} catch {
|
|
98
|
+
finish({ status: "unavailable", reason: "transport" });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const onLine = (line: string): void => {
|
|
102
|
+
if (finished) return;
|
|
103
|
+
const action = probe.accept(line);
|
|
104
|
+
if (action?.kind === "send") write(action.line);
|
|
105
|
+
else if (action?.kind === "complete") finish(action.accounting);
|
|
106
|
+
};
|
|
107
|
+
const deadline = deps.clock.setTimeout(
|
|
108
|
+
() => finish({ status: "unavailable", reason: "timeout" }),
|
|
109
|
+
deps.turnTimeoutMs && deps.turnTimeoutMs > 0 ? deps.turnTimeoutMs : INSPECTION_TIMEOUT_MS,
|
|
110
|
+
);
|
|
111
|
+
const abort = (): void => finish({ status: "unavailable", reason: "cancelled" });
|
|
112
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
113
|
+
const stdout = (async (): Promise<void> => {
|
|
114
|
+
const lines = new LineBuffer(CONTEXT_TRANSPORT_MAX + 65_536);
|
|
115
|
+
try {
|
|
116
|
+
for await (const chunk of child.stdout) {
|
|
117
|
+
if (!finished) for (const line of lines.push(chunk)) onLine(line);
|
|
118
|
+
}
|
|
119
|
+
const tail = lines.flush();
|
|
120
|
+
if (tail !== null) onLine(tail);
|
|
121
|
+
finish({ status: "unavailable", reason: "transport" });
|
|
122
|
+
} catch {
|
|
123
|
+
finish({ status: "unavailable", reason: "transport" });
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
const stderr = (async (): Promise<void> => {
|
|
127
|
+
const lines = new LineBuffer();
|
|
128
|
+
const classify = (line: string): void => {
|
|
129
|
+
if (detectAuthFailureInLine(options.harness, line) !== null) nativeFailure = "auth";
|
|
130
|
+
else if (nativeFailure === null && detectLimitInLine(options.harness, line) !== null)
|
|
131
|
+
nativeFailure = "limit";
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
for await (const chunk of child.stderr) for (const line of lines.push(chunk)) classify(line);
|
|
135
|
+
const tail = lines.flush();
|
|
136
|
+
if (tail !== null) classify(tail);
|
|
137
|
+
} catch {
|
|
138
|
+
finish({ status: "unavailable", reason: "transport" });
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
void child.inputError?.then(() => finish({ status: "unavailable", reason: "transport" }));
|
|
142
|
+
void child.exited.then(async (code) => {
|
|
143
|
+
exited = true;
|
|
144
|
+
exitCode = code;
|
|
145
|
+
if (code !== null && code !== 0) finish({ status: "unavailable", reason: "transport" });
|
|
146
|
+
termination.settle();
|
|
147
|
+
if (disposalTimer !== undefined) deps.clock.clearTimeout(disposalTimer);
|
|
148
|
+
// Exit can precede the final bytes reaching the runtime's read buffer.
|
|
149
|
+
// Drain naturally first; inherited pipes get a bounded disposal fallback.
|
|
150
|
+
let drainTimer: TimerHandle | undefined;
|
|
151
|
+
await Promise.race([
|
|
152
|
+
Promise.all([stdout, stderr]),
|
|
153
|
+
new Promise<void>((resolve) => {
|
|
154
|
+
drainTimer = deps.clock.setTimeout(resolve, KILL_GRACE_MS);
|
|
155
|
+
}),
|
|
156
|
+
]);
|
|
157
|
+
if (drainTimer !== undefined) deps.clock.clearTimeout(drainTimer);
|
|
158
|
+
child.disposeOutput();
|
|
159
|
+
stopped.resolve(true);
|
|
160
|
+
});
|
|
161
|
+
write(probe.initial);
|
|
162
|
+
// Abort can arrive during spawn or listener registration.
|
|
163
|
+
if (options.signal?.aborted) abort();
|
|
164
|
+
const value = await result.promise;
|
|
165
|
+
deps.clock.clearTimeout(deadline);
|
|
166
|
+
options.signal?.removeEventListener("abort", abort);
|
|
167
|
+
const clean = await stopped.promise;
|
|
168
|
+
if (clean) await Promise.all([stdout, stderr]);
|
|
169
|
+
if (!clean) return { status: "unavailable", reason: "cleanup" };
|
|
170
|
+
if (value.status === "unavailable" && value.reason === "transport") {
|
|
171
|
+
return {
|
|
172
|
+
status: "unavailable",
|
|
173
|
+
reason: nativeFailure ?? (exitCode !== null && exitCode !== 0 ? "native-exit" : "transport"),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
package/src/execution/deps.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export interface SpawnedProcess {
|
|
9
|
+
/** Resolves on asynchronous stdin failure (for example EPIPE). The
|
|
10
|
+
* adapter always handles the native error event, even without a caller. */
|
|
11
|
+
readonly inputError?: Promise<void>;
|
|
9
12
|
readonly stdout: AsyncIterable<string | Uint8Array>;
|
|
10
13
|
readonly stderr: AsyncIterable<string | Uint8Array>;
|
|
11
14
|
/** Idempotently terminate stdout and stderr reads without signalling the
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Apply the spawn contract: an empty override removes the inherited key. */
|
|
2
|
+
export function mergeEnvironment(
|
|
3
|
+
inherited: Readonly<Record<string, string | undefined>>,
|
|
4
|
+
overrides: Readonly<Record<string, string>> = {},
|
|
5
|
+
): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (const [key, value] of Object.entries(inherited)) {
|
|
8
|
+
if (value !== undefined) result[key] = value;
|
|
9
|
+
}
|
|
10
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
11
|
+
if (value === "") delete result[key];
|
|
12
|
+
else result[key] = value;
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
package/src/execution/lines.ts
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
export const LINE_MAX = 65_536;
|
|
12
12
|
|
|
13
13
|
export class LineBuffer {
|
|
14
|
+
constructor(private readonly limit = LINE_MAX) {}
|
|
14
15
|
// Per-instance and stateful ({stream:true}): a shared decoder would carry
|
|
15
16
|
// partial-sequence state across two streams and corrupt both.
|
|
16
17
|
private readonly decoder = new TextDecoder();
|
|
17
18
|
private pending = "";
|
|
18
|
-
private scanFrom = 0;
|
|
19
19
|
private discarding = false;
|
|
20
20
|
|
|
21
21
|
push(chunk: string | Uint8Array): string[] {
|
|
@@ -24,39 +24,38 @@ export class LineBuffer {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
private ingest(text: string): string[] {
|
|
27
|
-
this.pending += text;
|
|
28
27
|
const lines: string[] = [];
|
|
28
|
+
let start = 0;
|
|
29
29
|
while (true) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
this.
|
|
34
|
-
|
|
35
|
-
if (this.discarding)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
// The bound applies to COMPLETE lines too - an oversized line whose
|
|
41
|
-
// newline arrived in the same chunk must not bypass it.
|
|
42
|
-
if (line.length <= LINE_MAX && !isBlank(line)) lines.push(line);
|
|
30
|
+
// Search only new bytes. Searching the accumulated rope on each pipe
|
|
31
|
+
// chunk repeatedly flattens large echoed prompts.
|
|
32
|
+
const at = text.indexOf("\n", start);
|
|
33
|
+
this.append(text.slice(start, at === -1 ? text.length : at));
|
|
34
|
+
if (at === -1) return lines;
|
|
35
|
+
if (!this.discarding && !isBlank(this.pending)) lines.push(this.pending);
|
|
36
|
+
this.pending = "";
|
|
37
|
+
this.discarding = false;
|
|
38
|
+
start = at + 1;
|
|
43
39
|
}
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private append(text: string): void {
|
|
43
|
+
if (this.discarding) return;
|
|
44
|
+
if (this.pending.length + text.length > this.limit) {
|
|
46
45
|
this.pending = "";
|
|
47
|
-
this.scanFrom = 0;
|
|
48
46
|
this.discarding = true;
|
|
49
|
-
}
|
|
50
|
-
return lines;
|
|
47
|
+
} else this.pending += text;
|
|
51
48
|
}
|
|
52
49
|
|
|
53
50
|
/** The final partial line at stream close, if any. */
|
|
54
51
|
flush(): string | null {
|
|
55
52
|
const tail = this.decoder.decode();
|
|
56
|
-
if (tail !== "") this.
|
|
57
|
-
const rest =
|
|
53
|
+
if (tail !== "") this.append(tail);
|
|
54
|
+
const rest =
|
|
55
|
+
this.discarding || this.pending.length > this.limit || isBlank(this.pending)
|
|
56
|
+
? null
|
|
57
|
+
: this.pending;
|
|
58
58
|
this.pending = "";
|
|
59
|
-
this.scanFrom = 0;
|
|
60
59
|
this.discarding = false;
|
|
61
60
|
return rest;
|
|
62
61
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
|
|
10
10
|
import type { Readable } from "node:stream";
|
|
11
11
|
import type { Clock, RunnerDeps, SpawnedProcess, SpawnOptions } from "./deps.js";
|
|
12
|
+
import { mergeEnvironment } from "./environment.js";
|
|
12
13
|
|
|
13
14
|
const children = new WeakMap<SpawnedProcess, ChildProcess>();
|
|
14
15
|
|
|
@@ -91,22 +92,7 @@ export const disposableOutputStream = (
|
|
|
91
92
|
const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess => {
|
|
92
93
|
const [bin, ...args] = argv;
|
|
93
94
|
if (bin === undefined) throw new Error("empty argv");
|
|
94
|
-
const env =
|
|
95
|
-
opts.env === undefined
|
|
96
|
-
? undefined
|
|
97
|
-
: (() => {
|
|
98
|
-
const merged: Record<string, string | undefined> = { ...process.env };
|
|
99
|
-
for (const [k, v] of Object.entries(opts.env)) {
|
|
100
|
-
if (v === "") delete merged[k];
|
|
101
|
-
else merged[k] = v;
|
|
102
|
-
}
|
|
103
|
-
// Filter out undefined to satisfy Node's env type (string -> string)
|
|
104
|
-
const out: Record<string, string> = {};
|
|
105
|
-
for (const [k, v] of Object.entries(merged)) {
|
|
106
|
-
if (v !== undefined) out[k] = v;
|
|
107
|
-
}
|
|
108
|
-
return out;
|
|
109
|
-
})();
|
|
95
|
+
const env = mergeEnvironment(process.env, opts.env);
|
|
110
96
|
const child = nodeSpawn(bin, args, {
|
|
111
97
|
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
112
98
|
...(env !== undefined ? { env } : {}),
|
|
@@ -116,7 +102,14 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
|
|
|
116
102
|
"pipe",
|
|
117
103
|
],
|
|
118
104
|
});
|
|
105
|
+
const inputError = new Promise<void>((resolve) => {
|
|
106
|
+
child.stdin?.once("error", () => resolve());
|
|
107
|
+
});
|
|
119
108
|
let outputDisposed = false;
|
|
109
|
+
let outputDisposedResolve!: () => void;
|
|
110
|
+
const outputDisposal = new Promise<void>((resolve) => {
|
|
111
|
+
outputDisposedResolve = resolve;
|
|
112
|
+
});
|
|
120
113
|
const stdoutDisposalCause = new Error("stdout disposed by runner");
|
|
121
114
|
const stderrDisposalCause = new Error("stderr disposed by runner");
|
|
122
115
|
let spawnError: Error | null = null;
|
|
@@ -150,10 +143,13 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
|
|
|
150
143
|
// immediately while an async ENOENT remains the single spawn-error signal.
|
|
151
144
|
const stderrWithError = async function* (): AsyncIterable<string | Uint8Array> {
|
|
152
145
|
if (stderrOutput !== null) yield* stderrOutput.stream;
|
|
153
|
-
|
|
146
|
+
// Terminal output disposal must also settle this tail when a child has
|
|
147
|
+
// closed stderr but has not exited. The caller owns signalling it.
|
|
148
|
+
await Promise.race([exited, outputDisposal]);
|
|
154
149
|
if (spawnError !== null) yield `spawn failed: ${(spawnError as Error).message}\n`;
|
|
155
150
|
};
|
|
156
151
|
const proc: SpawnedProcess = {
|
|
152
|
+
inputError,
|
|
157
153
|
stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream,
|
|
158
154
|
stderr: stderrWithError(),
|
|
159
155
|
exited,
|
|
@@ -161,6 +157,7 @@ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess
|
|
|
161
157
|
disposeOutput: (): void => {
|
|
162
158
|
if (outputDisposed) return;
|
|
163
159
|
outputDisposed = true;
|
|
160
|
+
outputDisposedResolve();
|
|
164
161
|
stdoutOutput?.dispose();
|
|
165
162
|
stderrOutput?.dispose();
|
|
166
163
|
},
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
buildSpawnArgv,
|
|
20
20
|
type LaunchOptions,
|
|
21
21
|
promptTextOf,
|
|
22
|
+
stdinPromptOf,
|
|
22
23
|
streamingGranularityOf,
|
|
23
24
|
withPromptText,
|
|
24
25
|
} from "../interpretation/argv.js";
|
|
@@ -72,14 +73,21 @@ const SECRETISH = /(sk-[A-Za-z0-9_-]{8,}|(?:token|key|secret|password)=\S+)/i;
|
|
|
72
73
|
* other token is kept unless it is secret-shaped. Only the prompt's
|
|
73
74
|
* positional slot is masked, so a one-word prompt that equals a flag
|
|
74
75
|
* value does not cause that flag value to be masked. */
|
|
75
|
-
export const redactArgv = (
|
|
76
|
+
export const redactArgv = (
|
|
77
|
+
argv: readonly string[],
|
|
78
|
+
prompt?: string,
|
|
79
|
+
viaStdin = false,
|
|
80
|
+
): string[] => {
|
|
76
81
|
const promptIndex = prompt !== undefined ? argv.lastIndexOf(prompt) : -1;
|
|
77
82
|
const promptLabel = prompt !== undefined ? `[prompt:${prompt.length}ch]` : "";
|
|
78
|
-
|
|
83
|
+
const redacted = argv.map((token, index) => {
|
|
79
84
|
if (index === promptIndex) return promptLabel;
|
|
80
85
|
if (SECRETISH.test(token)) return "[redacted]";
|
|
81
86
|
return token;
|
|
82
87
|
});
|
|
88
|
+
return viaStdin && prompt !== undefined
|
|
89
|
+
? [...redacted, `[stdin prompt:${prompt.length}ch]`]
|
|
90
|
+
: redacted;
|
|
83
91
|
};
|
|
84
92
|
|
|
85
93
|
export interface TurnRunOptions extends LaunchOptions {
|
|
@@ -119,6 +127,7 @@ export async function* streamTurn(
|
|
|
119
127
|
prompt: withPromptText(opts.prompt, composeEscalatedPrompt(promptTextOf(opts), questionMode)),
|
|
120
128
|
};
|
|
121
129
|
const promptText = promptTextOf(effective);
|
|
130
|
+
const stdinPrompt = stdinPromptOf(h, effective);
|
|
122
131
|
let asked = false;
|
|
123
132
|
let escalationDetection: EscalationDetection = "none";
|
|
124
133
|
|
|
@@ -209,7 +218,7 @@ export async function* streamTurn(
|
|
|
209
218
|
event: "spawn",
|
|
210
219
|
turnId,
|
|
211
220
|
harness: h.name,
|
|
212
|
-
argv: redactArgv(argv, promptText),
|
|
221
|
+
argv: redactArgv(argv, promptText, stdinPrompt !== null),
|
|
213
222
|
granularity,
|
|
214
223
|
...(matcherOverrides ? { matcherOverrides } : {}),
|
|
215
224
|
...(envKeys?.length ? { envKeys } : {}),
|
|
@@ -226,7 +235,7 @@ export async function* streamTurn(
|
|
|
226
235
|
let proc: SpawnedProcess;
|
|
227
236
|
try {
|
|
228
237
|
proc = deps.spawn(argv, {
|
|
229
|
-
stdin: h.stdin === "close-required" ? "close" : "inherit",
|
|
238
|
+
stdin: stdinPrompt !== null ? "pipe" : h.stdin === "close-required" ? "close" : "inherit",
|
|
230
239
|
...(effective.cwd !== undefined ? { cwd: effective.cwd } : {}),
|
|
231
240
|
...(effective.env !== undefined ? { env: effective.env } : {}),
|
|
232
241
|
});
|
|
@@ -367,6 +376,24 @@ export async function* streamTurn(
|
|
|
367
376
|
}, PIPE_GRACE_MS);
|
|
368
377
|
});
|
|
369
378
|
|
|
379
|
+
if (stdinPrompt !== null) {
|
|
380
|
+
let inputFailed = false;
|
|
381
|
+
const failInput = (): void => {
|
|
382
|
+
if (inputFailed || cancelled || exited) return;
|
|
383
|
+
inputFailed = true;
|
|
384
|
+
void pushFailure(failureFromTransport("native prompt stdin failed"));
|
|
385
|
+
sup.escalate();
|
|
386
|
+
};
|
|
387
|
+
void proc.inputError?.then(failInput);
|
|
388
|
+
try {
|
|
389
|
+
if (!proc.stdin) throw new Error("native prompt requires stdin");
|
|
390
|
+
proc.stdin.write(stdinPrompt);
|
|
391
|
+
proc.stdin.end();
|
|
392
|
+
} catch {
|
|
393
|
+
failInput();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
370
397
|
const pumpStdout = async (): Promise<void> => {
|
|
371
398
|
const lines = new LineBuffer();
|
|
372
399
|
let identitySeen = false;
|