@crewhaus/tool-proc 0.7.0 → 0.8.0
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +202 -90
- package/dist/lib/addr.d.ts +129 -0
- package/dist/lib/addr.js +391 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +133 -0
- package/dist/lib/env.d.ts +18 -5
- package/dist/lib/env.js +18 -4
- package/dist/lib/format.d.ts +12 -7
- package/dist/lib/format.js +31 -81
- package/dist/lib/which.d.ts +17 -0
- package/dist/lib/which.js +80 -0
- package/dist/registry.d.ts +39 -0
- package/dist/registry.js +66 -2
- package/dist/safe-path.d.ts +11 -0
- package/dist/safe-path.js +62 -10
- package/dist/spawn.d.ts +39 -0
- package/dist/spawn.js +85 -121
- package/package.json +11 -6
package/dist/index.js
CHANGED
|
@@ -26,14 +26,21 @@ import { accessSync, constants as fsConstants, statSync } from "node:fs";
|
|
|
26
26
|
import { connect } from "node:net";
|
|
27
27
|
import * as path from "node:path";
|
|
28
28
|
import { buildTool } from "@crewhaus/tool-builder";
|
|
29
|
+
import { checkEnvReveal } from "@crewhaus/tool-safety/env";
|
|
30
|
+
import { describeRegexOutcome, openRegexSession } from "@crewhaus/tool-safety/regex";
|
|
29
31
|
import { z } from "zod";
|
|
32
|
+
import { portTarget, resolveListedHost } from "./lib/addr";
|
|
30
33
|
import { backoffDelayMs, totalBackoffMs } from "./lib/backoff";
|
|
34
|
+
import { ENV_REVEAL_KEY, PORT_HOSTS_KEY, portHostsFor, revealAllowFor } from "./lib/config";
|
|
31
35
|
import { FALLBACK_PATH, buildSpawnEnv, inspectEnv } from "./lib/env";
|
|
32
36
|
import { capText, compileSafePattern, formatArgv } from "./lib/format";
|
|
33
|
-
import {
|
|
37
|
+
import { hostPlatform, searchPath } from "./lib/which";
|
|
38
|
+
import { __resetRegistryForTest, __stopAllForTest, getProc, listProcs, markKilled, reapProc, startBackground, watchFinished, } from "./registry";
|
|
34
39
|
import { recheckContainment, resolveSafe, resolveSafeDir } from "./safe-path";
|
|
35
40
|
import { runOnce, sleep } from "./spawn";
|
|
36
|
-
export { __resetRegistryForTest };
|
|
41
|
+
export { __resetRegistryForTest, __stopAllForTest };
|
|
42
|
+
export { _resetProcConfig, registerProcConfig } from "./lib/config";
|
|
43
|
+
export { _setDnsLookup } from "./lib/addr";
|
|
37
44
|
/** Compact JSON — the reader is a model, not a person. */
|
|
38
45
|
const json = (value) => JSON.stringify(value);
|
|
39
46
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -43,6 +50,8 @@ const MAX_WAIT_MS = 600_000;
|
|
|
43
50
|
const DEFAULT_MAX_OUTPUT = 100_000;
|
|
44
51
|
const MAX_MAX_OUTPUT = 1_000_000;
|
|
45
52
|
const DEFAULT_POLL_MS = 100;
|
|
53
|
+
/** The longest one WaitForOutput match may run in the regex worker. */
|
|
54
|
+
const MATCH_BUDGET_MS = 2_000;
|
|
46
55
|
// ---------------------------------------------------------------------------
|
|
47
56
|
// shared schema pieces
|
|
48
57
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +138,9 @@ function outcomeJson(outcome) {
|
|
|
129
138
|
stderr: outcome.stderr,
|
|
130
139
|
...(outcome.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
|
131
140
|
...(outcome.stderrTruncated ? { stderrTruncated: true } : {}),
|
|
141
|
+
// The child exited, but a process it started held its output open past
|
|
142
|
+
// the drain grace: what came back is what arrived, perhaps not all of it.
|
|
143
|
+
...(outcome.outputIncomplete === true ? { outputIncomplete: true } : {}),
|
|
132
144
|
};
|
|
133
145
|
}
|
|
134
146
|
// ---------------------------------------------------------------------------
|
|
@@ -136,7 +148,8 @@ function outcomeJson(outcome) {
|
|
|
136
148
|
// ---------------------------------------------------------------------------
|
|
137
149
|
export const runCommand = buildTool({
|
|
138
150
|
name: "RunCommand",
|
|
139
|
-
|
|
151
|
+
operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
|
|
152
|
+
description: "Run a program from an argv array — the program and each argument as separate strings, with no shell anywhere, so an argument containing a space, a quote or $(...) stays an argument. Use it whenever a harness needs a program's exit code and output without the injection surface of a shell command line. The child inherits no environment except the names you forward, always has a timeout, and runs in a session of its own with no terminal: a program that prompts (sudo, ssh, gpg) fails instead of waiting for input.",
|
|
140
153
|
inputSchema: z.object({
|
|
141
154
|
argv: argvSchema,
|
|
142
155
|
cwd: z.string().optional().describe("working directory, inside the workspace root"),
|
|
@@ -172,7 +185,10 @@ export const runCommand = buildTool({
|
|
|
172
185
|
});
|
|
173
186
|
export const runPipeline = buildTool({
|
|
174
187
|
name: "RunPipeline",
|
|
175
|
-
|
|
188
|
+
operativeArgs: [
|
|
189
|
+
{ field: "steps.argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" },
|
|
190
|
+
],
|
|
191
|
+
description: "Run several argv commands in order, stopping at the first non-zero exit, and return every step's result. Use it for a short ordered chain — install, then build, then test — without spending a model turn between the steps. Steps run in sequence and do not pipe into each other; each gets its own stdin and its own timeout, and runs with no terminal, so a step that prompts fails instead of waiting.",
|
|
176
192
|
inputSchema: z.object({
|
|
177
193
|
steps: z
|
|
178
194
|
.array(z.object({
|
|
@@ -271,7 +287,8 @@ const backoffSchema = z
|
|
|
271
287
|
.describe("the wait between attempts; declared by the caller, with no jitter, so a retry plan replays identically");
|
|
272
288
|
export const retry = buildTool({
|
|
273
289
|
name: "Retry",
|
|
274
|
-
|
|
290
|
+
operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
|
|
291
|
+
description: "Re-run an argv command until it succeeds or a bounded attempt count runs out, waiting a caller-declared backoff between attempts. Use it for a flaky step — a service still starting, a lock still held — instead of asking a model to decide when to try again. The backoff is fixed or exponential with an explicit base and carries no jitter, and every attempt is reported. Each attempt runs with no terminal, so one that prompts fails instead of waiting.",
|
|
275
292
|
inputSchema: z.object({
|
|
276
293
|
argv: argvSchema,
|
|
277
294
|
maxAttempts: z.number().int().min(1).max(10),
|
|
@@ -390,6 +407,7 @@ function noSuchProc(toolName, id) {
|
|
|
390
407
|
}
|
|
391
408
|
export const processStart = buildTool({
|
|
392
409
|
name: "ProcessStart",
|
|
410
|
+
operativeArgs: [{ field: "argv", kind: "command", within: "cwd", env: "envSet", stdin: "stdin" }],
|
|
393
411
|
description: "Start a program in the background from an argv array and return an id immediately, without waiting for it to finish. Use it for something that must outlive one tool call — a dev server, a watcher, a tail — then poll it with ProcessStatus and ProcessOutput and end it with ProcessStop. The process is killed if the harness exits, so a session never leaks children.",
|
|
394
412
|
inputSchema: z.object({
|
|
395
413
|
argv: argvSchema,
|
|
@@ -439,6 +457,7 @@ export const processStatus = buildTool({
|
|
|
439
457
|
});
|
|
440
458
|
export const processOutput = buildTool({
|
|
441
459
|
name: "ProcessOutput",
|
|
460
|
+
operativeArgs: [],
|
|
442
461
|
description: "Return what a background process has written since the last poll, and its current status. Use it to follow a long-running process incrementally instead of buffering everything until it exits. Each call advances a per-stream cursor, so output is returned exactly once — use ProcessStatus for a look that consumes nothing.",
|
|
443
462
|
inputSchema: z.object({
|
|
444
463
|
id: procIdSchema,
|
|
@@ -475,6 +494,7 @@ export const processOutput = buildTool({
|
|
|
475
494
|
});
|
|
476
495
|
export const processStop = buildTool({
|
|
477
496
|
name: "ProcessStop",
|
|
497
|
+
operativeArgs: [],
|
|
478
498
|
description: "Stop a background process: signal it, wait a bounded grace period, then SIGKILL it if it is still alive. Use it to end a dev server or watcher deterministically rather than leaving it running past the task. Pass reap to drop the entry entirely; otherwise it stays readable so its final output can still be collected.",
|
|
479
499
|
inputSchema: z.object({
|
|
480
500
|
id: procIdSchema,
|
|
@@ -618,7 +638,8 @@ function probePort(host, port, timeoutMs) {
|
|
|
618
638
|
const HOSTNAME = /^[A-Za-z0-9._:-]+$/;
|
|
619
639
|
export const waitForPort = buildTool({
|
|
620
640
|
name: "WaitForPort",
|
|
621
|
-
|
|
641
|
+
operativeArgs: [{ field: "host", kind: "recipient", default: "127.0.0.1" }],
|
|
642
|
+
description: "Poll a TCP host and port until it is accepting connections, or until it stops, within a required deadline. Use it to wait for a server the harness just started to be ready, instead of guessing with a sleep. It reports whether the condition was met and how many probes it took, and never waits past the deadline. It probes loopback (localhost, 127.0.0.1, ::1; 0.0.0.0 and :: are probed as loopback); any other host is refused unless the operator lists it in tool_config.proc.wait_for_port_hosts, and a listed name that does not resolve yet counts as not accepting connections.",
|
|
622
643
|
inputSchema: z.object({
|
|
623
644
|
port: z.number().int().min(1).max(65_535),
|
|
624
645
|
host: z.string().max(255).optional().describe("default 127.0.0.1"),
|
|
@@ -637,16 +658,39 @@ export const waitForPort = buildTool({
|
|
|
637
658
|
if (!HOSTNAME.test(host)) {
|
|
638
659
|
return `[WaitForPort error] "${host}" is not a hostname or address.`;
|
|
639
660
|
}
|
|
661
|
+
// Loopback, or a host the operator listed — decided before any DNS or
|
|
662
|
+
// socket, and dialled by the address checked (C144).
|
|
663
|
+
const allowed = portHostsFor(ctx?.toolConfig);
|
|
664
|
+
const target = portTarget(host, allowed, PORT_HOSTS_KEY);
|
|
665
|
+
if (!target.ok)
|
|
666
|
+
return target.message;
|
|
640
667
|
const want = input.state ?? "open";
|
|
641
668
|
const interval = input.intervalMs ?? DEFAULT_POLL_MS;
|
|
642
669
|
const startedAt = Date.now();
|
|
643
670
|
const deadline = startedAt + input.timeoutMs;
|
|
644
671
|
let attempts = 0;
|
|
645
672
|
let open = false;
|
|
673
|
+
// A listed name is looked up at each probe until it answers, then that
|
|
674
|
+
// address is dialled for the rest of the call. Until then the host
|
|
675
|
+
// accepts no connections: "not open yet", or already "closed".
|
|
676
|
+
let dial = "dial" in target ? target.dial : undefined;
|
|
677
|
+
const unresolvedNote = () => dial === undefined
|
|
678
|
+
? { note: `"${host}" did not resolve; a name with no address accepts no connections` }
|
|
679
|
+
: {};
|
|
646
680
|
while (Date.now() < deadline) {
|
|
647
681
|
const remaining = deadline - Date.now();
|
|
648
682
|
attempts++;
|
|
649
|
-
|
|
683
|
+
if (dial === undefined && "resolve" in target) {
|
|
684
|
+
const resolved = await resolveListedHost(target.resolve, allowed, PORT_HOSTS_KEY);
|
|
685
|
+
if (!resolved.ok)
|
|
686
|
+
return resolved.message;
|
|
687
|
+
if ("dial" in resolved)
|
|
688
|
+
dial = resolved.dial;
|
|
689
|
+
}
|
|
690
|
+
open =
|
|
691
|
+
dial === undefined
|
|
692
|
+
? false
|
|
693
|
+
: await probePort(dial, input.port, Math.max(1, Math.min(1_000, remaining)));
|
|
650
694
|
if ((want === "open") === open) {
|
|
651
695
|
return json({
|
|
652
696
|
satisfied: true,
|
|
@@ -655,6 +699,7 @@ export const waitForPort = buildTool({
|
|
|
655
699
|
port: input.port,
|
|
656
700
|
attempts,
|
|
657
701
|
waitedMs: Date.now() - startedAt,
|
|
702
|
+
...unresolvedNote(),
|
|
658
703
|
});
|
|
659
704
|
}
|
|
660
705
|
if (ctx?.signal?.aborted === true)
|
|
@@ -673,6 +718,7 @@ export const waitForPort = buildTool({
|
|
|
673
718
|
attempts,
|
|
674
719
|
waitedMs: Date.now() - startedAt,
|
|
675
720
|
reason: ctx?.signal?.aborted === true ? "aborted" : "deadline",
|
|
721
|
+
...unresolvedNote(),
|
|
676
722
|
});
|
|
677
723
|
},
|
|
678
724
|
});
|
|
@@ -788,7 +834,7 @@ export const waitForFile = buildTool({
|
|
|
788
834
|
});
|
|
789
835
|
export const waitForOutput = buildTool({
|
|
790
836
|
name: "WaitForOutput",
|
|
791
|
-
description: "Watch a background process's output until a pattern matches, a failure pattern matches first, or a required deadline passes. Use it to wait for the line that means ready — 'Listening on', 'compiled successfully' — and to give up early when the line that means broken shows up instead. It reads without consuming, so ProcessOutput still returns everything afterwards.",
|
|
837
|
+
description: "Watch a background process's output until a pattern matches, a failure pattern matches first, or a required deadline passes. Use it to wait for the line that means ready — 'Listening on', 'compiled successfully' — and to give up early when the line that means broken shows up instead. It reads without consuming, so ProcessOutput still returns everything afterwards. A pattern that could not be evaluated ends the wait as undetermined (matched: null), never as a miss.",
|
|
792
838
|
inputSchema: z.object({
|
|
793
839
|
id: procIdSchema,
|
|
794
840
|
pattern: z.string().min(1).describe("a JavaScript regular expression source"),
|
|
@@ -810,83 +856,142 @@ export const waitForOutput = buildTool({
|
|
|
810
856
|
const bg = getProc(input.id);
|
|
811
857
|
if (bg === undefined)
|
|
812
858
|
return noSuchProc("WaitForOutput", input.id);
|
|
813
|
-
const
|
|
859
|
+
const flags = input.flags ?? "";
|
|
860
|
+
// Screened up front so a refused pattern never runs (C079)…
|
|
861
|
+
const success = compileSafePattern(input.pattern, flags);
|
|
814
862
|
if (!success.ok)
|
|
815
863
|
return `[WaitForOutput error] ${success.message}`;
|
|
816
|
-
let failure;
|
|
817
864
|
if (input.failurePattern !== undefined) {
|
|
818
|
-
const compiled = compileSafePattern(input.failurePattern,
|
|
865
|
+
const compiled = compileSafePattern(input.failurePattern, flags);
|
|
819
866
|
if (!compiled.ok)
|
|
820
867
|
return `[WaitForOutput error] failurePattern — ${compiled.message}`;
|
|
821
|
-
failure = compiled.regex;
|
|
822
868
|
}
|
|
823
869
|
const which = input.stream ?? "both";
|
|
824
870
|
const interval = input.intervalMs ?? DEFAULT_POLL_MS;
|
|
825
871
|
const startedAt = Date.now();
|
|
826
872
|
const deadline = startedAt + input.timeoutMs;
|
|
873
|
+
// Whether a look is the last one is decided BEFORE it is taken. The
|
|
874
|
+
// exit, and the pipes' last bytes, can land while a match is running in
|
|
875
|
+
// the worker, so a process seen finished only afterwards gets one more
|
|
876
|
+
// look. What "finished" means for an exited process (its watched pipe has
|
|
877
|
+
// ended too) and a stopped one (its exit has been seen) is
|
|
878
|
+
// watchFinished's.
|
|
879
|
+
const finished = () => watchFinished(bg, which);
|
|
827
880
|
const haystack = () => which === "stdout"
|
|
828
881
|
? bg.stdout
|
|
829
882
|
: which === "stderr"
|
|
830
883
|
? bg.stderr
|
|
831
884
|
: `${bg.stdout}\n${bg.stderr}`;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
885
|
+
// …and every match runs in @crewhaus/tool-safety's regex worker, under
|
|
886
|
+
// a deadline: a synchronous exec over this buffer could not be
|
|
887
|
+
// interrupted, and JavaScriptCore abandons a runaway one as "no match".
|
|
888
|
+
// An answer the worker could not give ends the wait as undetermined —
|
|
889
|
+
// never as "deadline", and never as a match that did not happen.
|
|
890
|
+
const session = openRegexSession();
|
|
891
|
+
const firstMatch = async (pattern, text) => {
|
|
892
|
+
const outcome = await session.run({
|
|
893
|
+
op: "matchAll",
|
|
894
|
+
pattern,
|
|
895
|
+
flags,
|
|
896
|
+
input: text,
|
|
897
|
+
maxMatches: 1,
|
|
898
|
+
deadlineMs: Math.max(100, Math.min(MATCH_BUDGET_MS, deadline - Date.now())),
|
|
899
|
+
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
900
|
+
...(ctx?.runContext !== undefined ? { runawayKey: ctx.runContext.sessionId } : {}),
|
|
901
|
+
});
|
|
902
|
+
if (outcome.status !== "ok")
|
|
903
|
+
return { ok: false, why: describeRegexOutcome(outcome) };
|
|
904
|
+
return { ok: true, match: outcome.result.matches[0]?.match };
|
|
905
|
+
};
|
|
906
|
+
const undetermined = (which, why) =>
|
|
907
|
+
// A run the caller's own abort ended is the abort, as before.
|
|
908
|
+
ctx?.signal?.aborted === true
|
|
909
|
+
? json({
|
|
910
|
+
matched: false,
|
|
911
|
+
id: bg.id,
|
|
912
|
+
reason: "aborted",
|
|
913
|
+
status: bg.status,
|
|
914
|
+
waitedMs: Date.now() - startedAt,
|
|
915
|
+
})
|
|
916
|
+
: json({
|
|
917
|
+
matched: null,
|
|
918
|
+
id: bg.id,
|
|
919
|
+
reason: "undetermined",
|
|
920
|
+
detail: `${which} could not be evaluated against the output: ${why}`,
|
|
921
|
+
status: bg.status,
|
|
922
|
+
exitCode: bg.exitCode,
|
|
923
|
+
waitedMs: Date.now() - startedAt,
|
|
924
|
+
});
|
|
925
|
+
try {
|
|
926
|
+
for (;;) {
|
|
927
|
+
const last = finished();
|
|
928
|
+
const text = haystack();
|
|
929
|
+
if (input.failurePattern !== undefined) {
|
|
930
|
+
const failed = await firstMatch(input.failurePattern, text);
|
|
931
|
+
if (!failed.ok)
|
|
932
|
+
return undetermined("failurePattern", failed.why);
|
|
933
|
+
if (failed.match !== undefined) {
|
|
934
|
+
return json({
|
|
935
|
+
matched: false,
|
|
936
|
+
matchedFailure: true,
|
|
937
|
+
id: bg.id,
|
|
938
|
+
match: failed.match,
|
|
939
|
+
status: bg.status,
|
|
940
|
+
exitCode: bg.exitCode,
|
|
941
|
+
waitedMs: Date.now() - startedAt,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const hit = await firstMatch(input.pattern, text);
|
|
946
|
+
if (!hit.ok)
|
|
947
|
+
return undetermined("pattern", hit.why);
|
|
948
|
+
if (hit.match !== undefined) {
|
|
949
|
+
return json({
|
|
950
|
+
matched: true,
|
|
951
|
+
id: bg.id,
|
|
952
|
+
match: hit.match,
|
|
953
|
+
status: bg.status,
|
|
954
|
+
exitCode: bg.exitCode,
|
|
955
|
+
waitedMs: Date.now() - startedAt,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
// A process that had finished before this look will never produce the
|
|
959
|
+
// line, so stop instead of burning the deadline.
|
|
960
|
+
if (last) {
|
|
961
|
+
return json({
|
|
962
|
+
matched: false,
|
|
963
|
+
id: bg.id,
|
|
964
|
+
reason: "process finished without matching",
|
|
965
|
+
status: bg.status,
|
|
966
|
+
exitCode: bg.exitCode,
|
|
967
|
+
waitedMs: Date.now() - startedAt,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
if (ctx?.signal?.aborted === true) {
|
|
971
|
+
return json({
|
|
972
|
+
matched: false,
|
|
973
|
+
id: bg.id,
|
|
974
|
+
reason: "aborted",
|
|
975
|
+
status: bg.status,
|
|
976
|
+
waitedMs: Date.now() - startedAt,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
const left = deadline - Date.now();
|
|
980
|
+
if (left <= 0) {
|
|
981
|
+
return json({
|
|
982
|
+
matched: false,
|
|
983
|
+
id: bg.id,
|
|
984
|
+
reason: "deadline",
|
|
985
|
+
status: bg.status,
|
|
986
|
+
exitCode: bg.exitCode,
|
|
987
|
+
waitedMs: Date.now() - startedAt,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
await sleep(Math.min(interval, left), ctx?.signal);
|
|
888
991
|
}
|
|
889
|
-
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
session.close();
|
|
890
995
|
}
|
|
891
996
|
},
|
|
892
997
|
});
|
|
@@ -895,7 +1000,7 @@ export const waitForOutput = buildTool({
|
|
|
895
1000
|
// ---------------------------------------------------------------------------
|
|
896
1001
|
export const commandExists = buildTool({
|
|
897
1002
|
name: "CommandExists",
|
|
898
|
-
description: "Report whether a program is on PATH and where it resolves, without running it. Use it to check a prerequisite before building a plan around it, so a missing binary is a clear answer rather than a failed command. It searches the same PATH RunCommand would use, in order, and returns the first executable match.",
|
|
1003
|
+
description: "Report whether a program is on PATH and where it resolves, without running it. Use it to check a prerequisite before building a plan around it, so a missing binary is a clear answer rather than a failed command. It searches the same PATH RunCommand would use, in order, and returns the first executable match; on Windows it tries PATHEXT's extensions (git finds git.exe) as the shell does.",
|
|
899
1004
|
inputSchema: z.object({
|
|
900
1005
|
name: z.string().min(1).max(255).describe("a bare program name such as 'git' — not a path"),
|
|
901
1006
|
}),
|
|
@@ -905,26 +1010,26 @@ export const commandExists = buildTool({
|
|
|
905
1010
|
if (input.name.includes("/") || input.name.includes("\\") || input.name.includes("\0")) {
|
|
906
1011
|
return `[CommandExists error] "${input.name}" is a path, not a program name — pass a bare name such as "git".`;
|
|
907
1012
|
}
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1013
|
+
const platform = hostPlatform();
|
|
1014
|
+
// The PATH RunCommand hands its child (buildSpawnEnv). FALLBACK_PATH is
|
|
1015
|
+
// a POSIX list; a Windows host with no PATH has nothing to search.
|
|
1016
|
+
const raw = process.env["PATH"] ?? (platform === "win32" ? "" : FALLBACK_PATH);
|
|
1017
|
+
const found = searchPath(input.name, {
|
|
1018
|
+
pathValue: raw,
|
|
1019
|
+
platform,
|
|
1020
|
+
pathext: process.env["PATHEXT"],
|
|
1021
|
+
});
|
|
1022
|
+
return json({
|
|
1023
|
+
name: input.name,
|
|
1024
|
+
found: found.path !== null,
|
|
1025
|
+
path: found.path,
|
|
1026
|
+
searchedDirs: found.searchedDirs,
|
|
1027
|
+
});
|
|
923
1028
|
},
|
|
924
1029
|
});
|
|
925
1030
|
export const envInspect = buildTool({
|
|
926
1031
|
name: "EnvInspect",
|
|
927
|
-
description: "Report whether named environment variables are set, and how long their values are
|
|
1032
|
+
description: "Report whether named environment variables are set, and how long their values are. Use it to check that a credential or configuration variable is present before running something that needs it, without pulling the secret into context. A value is shown only for a variable named in `reveal` that the operator listed in tool_config.proc.env_reveal, and never for one whose name looks like a credential (a key, token, secret, password or credential URL), listed or not; any other name in `reveal` comes back with `withheld` and the reason. There is no way to list the environment: a name you do not ask for is a name you learn nothing about.",
|
|
928
1033
|
inputSchema: z.object({
|
|
929
1034
|
names: z
|
|
930
1035
|
.array(z.string().min(1).max(255))
|
|
@@ -935,22 +1040,29 @@ export const envInspect = buildTool({
|
|
|
935
1040
|
.array(z.string().min(1).max(255))
|
|
936
1041
|
.max(16)
|
|
937
1042
|
.optional()
|
|
938
|
-
.describe("variables whose
|
|
1043
|
+
.describe("variables whose value should be returned; must also appear in names, and each must be listed in tool_config.proc.env_reveal and not look like a credential"),
|
|
939
1044
|
}),
|
|
940
1045
|
readOnly: true,
|
|
941
1046
|
concurrencySafe: true,
|
|
942
|
-
execute: async (input) => {
|
|
1047
|
+
execute: async (input, ctx) => {
|
|
943
1048
|
const named = new Set(input.names);
|
|
944
1049
|
const reveal = input.reveal ?? [];
|
|
945
1050
|
const notNamed = reveal.filter((r) => !named.has(r));
|
|
946
1051
|
if (notNamed.length > 0) {
|
|
947
1052
|
return `[EnvInspect error] reveal lists ${notNamed.join(", ")}, which names does not — add them to names to inspect them.`;
|
|
948
1053
|
}
|
|
949
|
-
|
|
1054
|
+
// The list comes from the operator (tool_config), never from the call.
|
|
1055
|
+
const allowed = revealAllowFor(ctx?.toolConfig);
|
|
1056
|
+
const { views, invalid } = inspectEnv(process.env, input.names, reveal, (name) => checkEnvReveal(name, { allowed, configKey: ENV_REVEAL_KEY }));
|
|
1057
|
+
const withheld = views.filter((v) => v.withheld !== undefined);
|
|
950
1058
|
return json({
|
|
951
1059
|
variables: views,
|
|
952
1060
|
...(invalid.length > 0 ? { notEnvironmentNames: invalid } : {}),
|
|
953
|
-
note:
|
|
1061
|
+
note: withheld.length === 0
|
|
1062
|
+
? "values are shown only for names in reveal that tool_config.proc.env_reveal lists"
|
|
1063
|
+
: `values are shown only for names in reveal that ${ENV_REVEAL_KEY} lists, and never for a name that looks like a credential; ${withheld
|
|
1064
|
+
.map((v) => `${v.name}: ${v.withheld}`)
|
|
1065
|
+
.join(", ")}`,
|
|
954
1066
|
});
|
|
955
1067
|
},
|
|
956
1068
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What WaitForPort may dial (C144).
|
|
3
|
+
*
|
|
4
|
+
* WaitForPort is read-only, so plan and auto mode run it unasked, and it
|
|
5
|
+
* used to connect() to any host a call named: the cloud metadata address,
|
|
6
|
+
* the LAN, any port on loopback in an inet_aton spelling — a port-scan
|
|
7
|
+
* oracle, and a DNS query for any name the model chose. Its purpose is
|
|
8
|
+
* waiting for a server the harness just started, which is loopback. So
|
|
9
|
+
* loopback is allowed without configuration, and anything else only when
|
|
10
|
+
* the operator lists it in `tool_config.proc.wait_for_port_hosts`.
|
|
11
|
+
*
|
|
12
|
+
* Addresses are classified NUMERICALLY by the repository's shared
|
|
13
|
+
* private-address classifier, carried here as the byte-identical block
|
|
14
|
+
* apps/cli/src/tool-registry.test.ts hashes across every copy (so
|
|
15
|
+
* `0x7f.1`, `2130706433` and `::ffff:127.0.0.1` are all loopback, and
|
|
16
|
+
* `::ffff:169.254.169.254` is link-local).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Canonicalise an IPv4 literal.
|
|
20
|
+
*
|
|
21
|
+
* `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
|
|
22
|
+
* `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
|
|
23
|
+
* only understands dotted-decimal waves every one of them through. Returns
|
|
24
|
+
* `null` when the string is not an IPv4 literal at all.
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeIpv4(raw: string): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
|
|
29
|
+
* is not one.
|
|
30
|
+
*
|
|
31
|
+
* Classifying IPv6 by its TEXT is where the bypasses live, because one address
|
|
32
|
+
* has many spellings and the one a check was written against is rarely the one
|
|
33
|
+
* that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
|
|
34
|
+
* that form: the WHATWG URL parser re-serialises the embedded quad as hex
|
|
35
|
+
* pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
|
|
36
|
+
* first means the prefix tests below are arithmetic, and spelling stops
|
|
37
|
+
* mattering.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseIpv6(raw: string): ReadonlyArray<number> | null;
|
|
40
|
+
/** Ranges that are never a public RPC endpoint. */
|
|
41
|
+
export declare function isPrivateIp(address: string): boolean;
|
|
42
|
+
/** True for 127.0.0.0/8, ::1, and a v6 spelling that carries a 127/8 address. */
|
|
43
|
+
export declare function isLoopbackIp(address: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* True for link-local addresses, where cloud metadata services live:
|
|
46
|
+
* 169.254.0.0/16, fe80::/10, AWS's fd00:ec2::254, and a v6 spelling that
|
|
47
|
+
* carries a 169.254/16 address.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isLinkLocalIp(address: string): boolean;
|
|
50
|
+
/** The canonical spelling of an IP literal (dotted quad, or v6 as written), or null. */
|
|
51
|
+
export declare function canonicalIp(address: string): string | null;
|
|
52
|
+
export type DnsLookup = (host: string) => Promise<ReadonlyArray<string>>;
|
|
53
|
+
/** Test seam: replace the resolver; `undefined` restores the system one. */
|
|
54
|
+
export declare function _setDnsLookup(fn: DnsLookup | undefined): void;
|
|
55
|
+
/**
|
|
56
|
+
* True for the unspecified address in any spelling (0.0.0.0, ::, an
|
|
57
|
+
* IPv4-mapped 0.0.0.0). A server that binds it prints it ("running on
|
|
58
|
+
* http://0.0.0.0:8000"), and dialling it reaches only this machine on Linux
|
|
59
|
+
* and macOS, so WaitForPort treats it as loopback and dials loopback itself,
|
|
60
|
+
* which also works where connecting to 0.0.0.0 does not (Windows).
|
|
61
|
+
*/
|
|
62
|
+
export declare function unspecifiedDial(address: string): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* An IPv6 literal in the form WaitForPort dials it, or `null` when it is not
|
|
65
|
+
* one or may not be dialled.
|
|
66
|
+
*
|
|
67
|
+
* The form is the URL parser's (`0:0:0:0:0:ffff:127.0.0.1` is
|
|
68
|
+
* `::ffff:7f00:1`), which is how a permission rule reads a host too, so the
|
|
69
|
+
* probe goes to the address classified here and decided on there, and no
|
|
70
|
+
* resolver reads the string again. A spelling the URL parser does not read
|
|
71
|
+
* as the address {@link parseIpv6} does has no one reading, and is not
|
|
72
|
+
* dialled: an IPv4 part with a leading zero is octal to parseIpv6, decimal
|
|
73
|
+
* to macOS's resolver and invalid to the URL parser, so `::ffff:0177.0.0.1`
|
|
74
|
+
* was classified as loopback and reached 177.0.0.1.
|
|
75
|
+
*/
|
|
76
|
+
export declare function ipv6Dial(address: string): string | null;
|
|
77
|
+
export type PortTarget = {
|
|
78
|
+
readonly ok: true;
|
|
79
|
+
readonly dial: string;
|
|
80
|
+
}
|
|
81
|
+
/** A listed name, resolved at probe time by {@link resolveListedHost}. */
|
|
82
|
+
| {
|
|
83
|
+
readonly ok: true;
|
|
84
|
+
readonly resolve: string;
|
|
85
|
+
} | {
|
|
86
|
+
readonly ok: false;
|
|
87
|
+
readonly message: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Decide what one WaitForPort call dials for `host`, or why it may not.
|
|
91
|
+
* Nothing here touches DNS.
|
|
92
|
+
*
|
|
93
|
+
* - `localhost`, and any IP literal that is loopback in any spelling, is
|
|
94
|
+
* allowed with no configuration (a literal is dialled in its canonical
|
|
95
|
+
* form, so `0x7f.1` dials 127.0.0.1, and an IPv6 one in the URL parser's,
|
|
96
|
+
* see {@link ipv6Dial}). So is the unspecified address (0.0.0.0, ::),
|
|
97
|
+
* dialled as 127.0.0.1 / ::1.
|
|
98
|
+
* - An IPv6 literal with no one reading (`::ffff:0177.0.0.1`) is refused,
|
|
99
|
+
* listed or not.
|
|
100
|
+
* - Any other host must be listed in `allowed` (the operator's
|
|
101
|
+
* `tool_config.proc.wait_for_port_hosts`), and is refused BEFORE any DNS
|
|
102
|
+
* query, so an unlisted name is not a way to send one either.
|
|
103
|
+
* - A listed literal is dialled in that canonical form; a listed NAME is
|
|
104
|
+
* resolved at probe time (`resolveListedHost`).
|
|
105
|
+
*/
|
|
106
|
+
export declare function portTarget(host: string, allowed: ReadonlyArray<string>, configKey: string): PortTarget;
|
|
107
|
+
export type ListedResolution = {
|
|
108
|
+
readonly ok: true;
|
|
109
|
+
readonly dial: string;
|
|
110
|
+
}
|
|
111
|
+
/** No address yet: a name that does not resolve accepts no connections. */
|
|
112
|
+
| {
|
|
113
|
+
readonly ok: true;
|
|
114
|
+
readonly unresolved: true;
|
|
115
|
+
} | {
|
|
116
|
+
readonly ok: false;
|
|
117
|
+
readonly message: string;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a listed name for one probe. An empty answer, or a failed lookup,
|
|
121
|
+
* is "not accepting connections" for that probe, not a refusal: a compose
|
|
122
|
+
* service's name exists only once its container does, and a torn-down one
|
|
123
|
+
* stops resolving — which is exactly "closed". Every answer is checked: a
|
|
124
|
+
* name that resolves to a link-local address (the cloud metadata service) is
|
|
125
|
+
* refused unless that address itself is listed. The caller dials the address
|
|
126
|
+
* returned, and keeps dialling it for the rest of the call, so a resolver
|
|
127
|
+
* that answers differently later cannot move the probe.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveListedHost(host: string, allowed: ReadonlyArray<string>, configKey: string): Promise<ListedResolution>;
|