@toolu/core 6.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/package.json +40 -0
- package/src/bridge/__tests__/bridge.integration.test.ts +29 -0
- package/src/bridge/__tests__/bridge.test.ts +83 -0
- package/src/bridge/bridge.ts +130 -0
- package/src/bridge/hook-map.ts +178 -0
- package/src/bridge/test-helpers.ts +32 -0
- package/src/config/__tests__/config.test.ts +28 -0
- package/src/config/config.ts +112 -0
- package/src/decision/__tests__/decision.test.ts +30 -0
- package/src/decision/decision.ts +22 -0
- package/src/events/__tests__/events.test.ts +38 -0
- package/src/events/events.ts +65 -0
- package/src/policy/__tests__/policy.test.ts +33 -0
- package/src/policy/policy.ts +42 -0
- package/src/runner/__tests__/runner.test.ts +83 -0
- package/src/runner/runner-exec.ts +212 -0
- package/src/runner/runner-read.ts +46 -0
- package/src/runner/runner-types.ts +35 -0
- package/src/runner/runner.ts +19 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toolu/core",
|
|
3
|
+
"version": "6.8.0",
|
|
4
|
+
"description": "Portable decision, policy and bridge core shared by toolu's host adapters.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"toolu",
|
|
7
|
+
"opencode",
|
|
8
|
+
"hooks",
|
|
9
|
+
"policy"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Falconiere/toolu.git",
|
|
14
|
+
"directory": "packages/toolu-core"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/Falconiere/toolu#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/Falconiere/toolu/issues"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"provenance": true
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
"./decision": "./src/decision/decision.ts",
|
|
31
|
+
"./events": "./src/events/events.ts",
|
|
32
|
+
"./bridge": "./src/bridge/bridge.ts",
|
|
33
|
+
"./policy": "./src/policy/policy.ts",
|
|
34
|
+
"./config": "./src/config/config.ts",
|
|
35
|
+
"./runner": "./src/runner/runner.ts"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"zod": "4.1.5"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runPreToolBridge } from "../bridge.ts";
|
|
5
|
+
import { bridgeEnv, createProtectedFilesProject, REPO_ROOT } from "../test-helpers.ts";
|
|
6
|
+
|
|
7
|
+
test("runPreToolBridge real mod.sh protected .env edit yields deny or ask", async () => {
|
|
8
|
+
const { projectRoot, envPath } = await createProtectedFilesProject();
|
|
9
|
+
const fixturePath = join(REPO_ROOT, "tooling/fixtures/portable-core/protected-files-pre.json");
|
|
10
|
+
const fixtureRaw: unknown = JSON.parse(readFileSync(fixturePath, "utf8"));
|
|
11
|
+
const spread =
|
|
12
|
+
typeof fixtureRaw === "object" && fixtureRaw !== null && !Array.isArray(fixtureRaw)
|
|
13
|
+
? fixtureRaw
|
|
14
|
+
: {};
|
|
15
|
+
|
|
16
|
+
const response = await runPreToolBridge(
|
|
17
|
+
{
|
|
18
|
+
...spread,
|
|
19
|
+
cwd: projectRoot,
|
|
20
|
+
projectRoot,
|
|
21
|
+
worktree: projectRoot,
|
|
22
|
+
toolInput: { file_path: envPath },
|
|
23
|
+
},
|
|
24
|
+
{ repoRoot: REPO_ROOT, env: bridgeEnv() },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(response.ok).toBe(true);
|
|
28
|
+
expect(response.decision.kind === "deny" || response.decision.kind === "ask").toBe(true);
|
|
29
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { decisionFromHookResult, parseBridgeRequest, toHookStdin } from "../bridge.ts";
|
|
3
|
+
|
|
4
|
+
test("parseBridgeRequest accepts protected-files fixture shape", () => {
|
|
5
|
+
const req = parseBridgeRequest({
|
|
6
|
+
protocolVersion: 1,
|
|
7
|
+
event: "tool/pre",
|
|
8
|
+
sessionId: "s",
|
|
9
|
+
toolCallId: "c",
|
|
10
|
+
cwd: "/repo",
|
|
11
|
+
projectRoot: "/repo",
|
|
12
|
+
worktree: "/repo",
|
|
13
|
+
toolName: "Edit",
|
|
14
|
+
toolInput: { file_path: "/repo/.env" },
|
|
15
|
+
host: "opencode",
|
|
16
|
+
deadlineMs: 15000,
|
|
17
|
+
maxStdoutBytes: 1048576,
|
|
18
|
+
});
|
|
19
|
+
expect(req.toolName).toBe("Edit");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parseBridgeRequest rejects bad protocolVersion", () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
parseBridgeRequest({
|
|
25
|
+
protocolVersion: 2,
|
|
26
|
+
event: "tool/pre",
|
|
27
|
+
sessionId: "s",
|
|
28
|
+
toolCallId: "c",
|
|
29
|
+
cwd: "/r",
|
|
30
|
+
projectRoot: "/r",
|
|
31
|
+
worktree: "/r",
|
|
32
|
+
toolName: "Edit",
|
|
33
|
+
host: "x",
|
|
34
|
+
deadlineMs: 1,
|
|
35
|
+
maxStdoutBytes: 1,
|
|
36
|
+
}),
|
|
37
|
+
).toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("toHookStdin maps Claude PreToolUse fields", () => {
|
|
41
|
+
expect(toHookStdin({ toolName: "Edit", toolInput: { file_path: "/a" } })).toBe(
|
|
42
|
+
JSON.stringify({ tool_name: "Edit", tool_input: { file_path: "/a" } }),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("decisionFromHookResult exit 2 pre yields deny with stderr reason", () => {
|
|
47
|
+
expect(decisionFromHookResult(2, "", "protected file", "tool/pre")).toEqual({
|
|
48
|
+
kind: "deny",
|
|
49
|
+
reason: "protected file",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("decisionFromHookResult exit 2 post yields post_block", () => {
|
|
54
|
+
expect(decisionFromHookResult(2, "", "blocked", "tool/post")).toEqual({
|
|
55
|
+
kind: "post_block",
|
|
56
|
+
reason: "blocked",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("decisionFromHookResult exit 0 empty stdout yields allow", () => {
|
|
61
|
+
expect(decisionFromHookResult(0, "", "", "tool/pre")).toEqual({ kind: "allow" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("decisionFromHookResult permissionDecision deny", () => {
|
|
65
|
+
const stdout = JSON.stringify({
|
|
66
|
+
hookSpecificOutput: {
|
|
67
|
+
permissionDecision: "deny",
|
|
68
|
+
permissionDecisionReason: "no",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
expect(decisionFromHookResult(0, stdout, "", "tool/pre")).toEqual({
|
|
72
|
+
kind: "deny",
|
|
73
|
+
reason: "no",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("decisionFromHookResult malformed stdout yields runtime_failure parse", () => {
|
|
78
|
+
expect(decisionFromHookResult(0, "not-json", "", "tool/pre")).toEqual({
|
|
79
|
+
kind: "runtime_failure",
|
|
80
|
+
reason: "hook stdout was not valid JSON",
|
|
81
|
+
code: "parse",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Bash bridge protocol v1 (#210). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type Decision, DecisionSchema } from "../decision/decision.ts";
|
|
4
|
+
import { BridgeEventSchema } from "../events/events.ts";
|
|
5
|
+
import { createBunBashRunner, type BashRunner, type RawProcessResult } from "../runner/runner.ts";
|
|
6
|
+
import { decisionFromHookResult, decisionFromRunnerFailure, toHookStdin } from "./hook-map.ts";
|
|
7
|
+
|
|
8
|
+
export const BridgeRequestSchema = z.object({
|
|
9
|
+
protocolVersion: z.literal(1),
|
|
10
|
+
event: BridgeEventSchema,
|
|
11
|
+
sessionId: z.string().min(1),
|
|
12
|
+
toolCallId: z.string().min(1),
|
|
13
|
+
cwd: z.string().min(1),
|
|
14
|
+
projectRoot: z.string().min(1),
|
|
15
|
+
worktree: z.string().min(1),
|
|
16
|
+
toolName: z.string().min(1),
|
|
17
|
+
toolInput: z.record(z.string(), z.unknown()).default({}),
|
|
18
|
+
host: z.string().min(1),
|
|
19
|
+
deadlineMs: z.number().int().positive(),
|
|
20
|
+
maxStdoutBytes: z.number().int().positive(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type BridgeRequest = z.infer<typeof BridgeRequestSchema>;
|
|
24
|
+
|
|
25
|
+
const BridgeMetaSchema = z.object({
|
|
26
|
+
exitCode: z.number().int().nullable(),
|
|
27
|
+
stdout: z.string(),
|
|
28
|
+
stderr: z.string(),
|
|
29
|
+
truncated: z.boolean().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const BridgeResponseSchema = z.discriminatedUnion("ok", [
|
|
33
|
+
z
|
|
34
|
+
.object({
|
|
35
|
+
ok: z.literal(true),
|
|
36
|
+
decision: DecisionSchema,
|
|
37
|
+
meta: BridgeMetaSchema,
|
|
38
|
+
})
|
|
39
|
+
.strict(),
|
|
40
|
+
z
|
|
41
|
+
.object({
|
|
42
|
+
ok: z.literal(false),
|
|
43
|
+
decision: DecisionSchema,
|
|
44
|
+
meta: BridgeMetaSchema,
|
|
45
|
+
})
|
|
46
|
+
.strict(),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export type BridgeResponse = z.infer<typeof BridgeResponseSchema>;
|
|
50
|
+
|
|
51
|
+
/** Parse a bridge request envelope. */
|
|
52
|
+
export function parseBridgeRequest(input: unknown): BridgeRequest {
|
|
53
|
+
return BridgeRequestSchema.parse(input);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { toHookStdin, decisionFromHookResult } from "./hook-map.ts";
|
|
57
|
+
|
|
58
|
+
function responseFromProcess(event: BridgeRequest["event"], raw: RawProcessResult): BridgeResponse {
|
|
59
|
+
const meta = {
|
|
60
|
+
exitCode: raw.exitCode,
|
|
61
|
+
stdout: raw.stdout,
|
|
62
|
+
stderr: raw.stderr,
|
|
63
|
+
truncated: raw.truncated,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!raw.ok) {
|
|
67
|
+
const decision = decisionFromRunnerFailure(raw.code, raw.message);
|
|
68
|
+
return { ok: false, decision, meta };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const decision = decisionFromHookResult(raw.exitCode, raw.stdout, raw.stderr, event);
|
|
72
|
+
if (decision.kind === "runtime_failure") {
|
|
73
|
+
return { ok: false, decision, meta };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, decision, meta };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type PreToolBridgeOptions = {
|
|
79
|
+
repoRoot: string;
|
|
80
|
+
runner?: BashRunner;
|
|
81
|
+
env?: Record<string, string>;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Resolve pre-tools/mod.sh under a toolu checkout. */
|
|
86
|
+
export function resolvePreToolsModSh(repoRoot: string): string {
|
|
87
|
+
return `${repoRoot.replace(/\/$/, "")}/plugins/toolu/hooks/pre-tools/mod.sh`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run the real pre-tools dispatcher and map to BridgeResponse. */
|
|
91
|
+
export async function runPreToolBridge(
|
|
92
|
+
requestInput: unknown,
|
|
93
|
+
opts: PreToolBridgeOptions,
|
|
94
|
+
): Promise<BridgeResponse> {
|
|
95
|
+
const request = parseBridgeRequest(requestInput);
|
|
96
|
+
if (request.event !== "tool/pre") {
|
|
97
|
+
const decision: Decision = {
|
|
98
|
+
kind: "runtime_failure",
|
|
99
|
+
reason: `runPreToolBridge supports tool/pre only, got ${request.event}`,
|
|
100
|
+
code: "parse",
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
decision,
|
|
105
|
+
meta: { exitCode: null, stdout: "", stderr: decision.reason, truncated: false },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const modSh = resolvePreToolsModSh(opts.repoRoot);
|
|
110
|
+
const runner = opts.runner ?? createBunBashRunner();
|
|
111
|
+
const stdin = toHookStdin({ toolName: request.toolName, toolInput: request.toolInput });
|
|
112
|
+
|
|
113
|
+
const env: Record<string, string> = {
|
|
114
|
+
...opts.env,
|
|
115
|
+
TOOLU_PROJECT_DIR: request.projectRoot,
|
|
116
|
+
CLAUDE_PROJECT_DIR: request.projectRoot,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const raw = await runner.run({
|
|
120
|
+
argv: ["bash", modSh],
|
|
121
|
+
cwd: request.cwd,
|
|
122
|
+
env,
|
|
123
|
+
stdin,
|
|
124
|
+
deadlineMs: request.deadlineMs,
|
|
125
|
+
maxStdoutBytes: request.maxStdoutBytes,
|
|
126
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return responseFromProcess(request.event, raw);
|
|
130
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/** Map hook stdout/exit codes to portable decisions (#210). */
|
|
2
|
+
import { type Decision } from "../decision/decision.ts";
|
|
3
|
+
import { type BridgeEvent } from "../events/events.ts";
|
|
4
|
+
|
|
5
|
+
type HookJson = {
|
|
6
|
+
hookSpecificOutput?: {
|
|
7
|
+
permissionDecision?: string;
|
|
8
|
+
permissionDecisionReason?: string;
|
|
9
|
+
additionalContext?: string;
|
|
10
|
+
};
|
|
11
|
+
decision?: string;
|
|
12
|
+
reason?: string;
|
|
13
|
+
systemMessage?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function readStringField(obj: Record<string, unknown>, key: string): string | undefined {
|
|
17
|
+
const v = obj[key];
|
|
18
|
+
return typeof v === "string" ? v : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function objectToRecord(value: object): Record<string, unknown> {
|
|
22
|
+
const out: Record<string, unknown> = {};
|
|
23
|
+
for (const [key, val] of Object.entries(value)) {
|
|
24
|
+
out[key] = val;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseHookObject(parsed: Record<string, unknown>): HookJson {
|
|
30
|
+
const out: HookJson = {};
|
|
31
|
+
const hso = parsed.hookSpecificOutput;
|
|
32
|
+
if (typeof hso === "object" && hso !== null && !Array.isArray(hso)) {
|
|
33
|
+
const h = objectToRecord(hso);
|
|
34
|
+
const hookOut: NonNullable<HookJson["hookSpecificOutput"]> = {};
|
|
35
|
+
const permissionDecision = readStringField(h, "permissionDecision");
|
|
36
|
+
const permissionDecisionReason = readStringField(h, "permissionDecisionReason");
|
|
37
|
+
const additionalContext = readStringField(h, "additionalContext");
|
|
38
|
+
if (permissionDecision !== undefined) {
|
|
39
|
+
hookOut.permissionDecision = permissionDecision;
|
|
40
|
+
}
|
|
41
|
+
if (permissionDecisionReason !== undefined) {
|
|
42
|
+
hookOut.permissionDecisionReason = permissionDecisionReason;
|
|
43
|
+
}
|
|
44
|
+
if (additionalContext !== undefined) {
|
|
45
|
+
hookOut.additionalContext = additionalContext;
|
|
46
|
+
}
|
|
47
|
+
out.hookSpecificOutput = hookOut;
|
|
48
|
+
}
|
|
49
|
+
const decision = readStringField(parsed, "decision");
|
|
50
|
+
const reason = readStringField(parsed, "reason");
|
|
51
|
+
const systemMessage = readStringField(parsed, "systemMessage");
|
|
52
|
+
if (decision !== undefined) {
|
|
53
|
+
out.decision = decision;
|
|
54
|
+
}
|
|
55
|
+
if (reason !== undefined) {
|
|
56
|
+
out.reason = reason;
|
|
57
|
+
}
|
|
58
|
+
if (systemMessage !== undefined) {
|
|
59
|
+
out.systemMessage = systemMessage;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseHookStdout(stdout: string): HookJson | null {
|
|
65
|
+
const trimmed = stdout.trim();
|
|
66
|
+
if (trimmed === "") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return parseHookObject(objectToRecord(parsed));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runtimeFailure(
|
|
81
|
+
reason: string,
|
|
82
|
+
code: Extract<Decision, { kind: "runtime_failure" }>["code"],
|
|
83
|
+
): Decision {
|
|
84
|
+
return { kind: "runtime_failure", reason, code };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPostEvent(event: BridgeEvent): boolean {
|
|
88
|
+
return event === "tool/post";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Claude PreToolUse / PostToolUse stdin JSON from a bridge request. */
|
|
92
|
+
export function toHookStdin(request: {
|
|
93
|
+
toolName: string;
|
|
94
|
+
toolInput: Record<string, unknown>;
|
|
95
|
+
}): string {
|
|
96
|
+
return JSON.stringify({
|
|
97
|
+
tool_name: request.toolName,
|
|
98
|
+
tool_input: request.toolInput,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Normalize bash dispatch stdout + exit code into a Decision. */
|
|
103
|
+
export function decisionFromHookResult(
|
|
104
|
+
exitCode: number,
|
|
105
|
+
stdout: string,
|
|
106
|
+
stderr: string,
|
|
107
|
+
event: BridgeEvent,
|
|
108
|
+
): Decision {
|
|
109
|
+
if (exitCode === 2) {
|
|
110
|
+
const reason = stderr.trim() || "hook blocked via exit code 2";
|
|
111
|
+
if (isPostEvent(event)) {
|
|
112
|
+
return { kind: "post_block", reason };
|
|
113
|
+
}
|
|
114
|
+
return { kind: "deny", reason };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (exitCode !== 0) {
|
|
118
|
+
return runtimeFailure(stderr.trim() || `hook exited ${exitCode}`, "nonzero");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const payload = parseHookStdout(stdout);
|
|
122
|
+
if (stdout.trim() !== "" && payload === null) {
|
|
123
|
+
return runtimeFailure("hook stdout was not valid JSON", "parse");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (payload === null) {
|
|
127
|
+
return { kind: "allow" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const permission = payload.hookSpecificOutput?.permissionDecision;
|
|
131
|
+
if (permission === "deny") {
|
|
132
|
+
const fromHook = payload.hookSpecificOutput?.permissionDecisionReason?.trim();
|
|
133
|
+
const fromReason = payload.reason?.trim();
|
|
134
|
+
const reason =
|
|
135
|
+
fromHook && fromHook.length > 0
|
|
136
|
+
? fromHook
|
|
137
|
+
: fromReason && fromReason.length > 0
|
|
138
|
+
? fromReason
|
|
139
|
+
: "permission denied";
|
|
140
|
+
return { kind: "deny", reason };
|
|
141
|
+
}
|
|
142
|
+
if (permission === "ask") {
|
|
143
|
+
const fromHook = payload.hookSpecificOutput?.permissionDecisionReason?.trim();
|
|
144
|
+
const reason = fromHook && fromHook.length > 0 ? fromHook : "permission ask";
|
|
145
|
+
return { kind: "ask", reason };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isPostEvent(event) && payload.decision === "block") {
|
|
149
|
+
const fromReason = payload.reason?.trim();
|
|
150
|
+
const reason = fromReason && fromReason.length > 0 ? fromReason : "post-tool block";
|
|
151
|
+
return { kind: "post_block", reason };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ctx = payload.hookSpecificOutput?.additionalContext?.trim();
|
|
155
|
+
const sys = payload.systemMessage?.trim();
|
|
156
|
+
const message = [ctx, sys].filter((part) => part && part.length > 0).join("\n\n");
|
|
157
|
+
if (message.length > 0) {
|
|
158
|
+
return { kind: "advisory", message };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { kind: "allow" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function decisionFromRunnerFailure(code: string, message: string): Decision {
|
|
165
|
+
if (code === "timeout") {
|
|
166
|
+
return runtimeFailure(message, "timeout");
|
|
167
|
+
}
|
|
168
|
+
if (code === "spawn") {
|
|
169
|
+
return runtimeFailure(message, "spawn");
|
|
170
|
+
}
|
|
171
|
+
if (code === "truncated") {
|
|
172
|
+
return runtimeFailure(message, "truncated");
|
|
173
|
+
}
|
|
174
|
+
if (code === "cancelled") {
|
|
175
|
+
return runtimeFailure(message, "cancelled");
|
|
176
|
+
}
|
|
177
|
+
return runtimeFailure(message, "nonzero");
|
|
178
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Shared temp-repo setup for real bash bridge tests. */
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const REPO_ROOT = join(import.meta.dir, "../../../..");
|
|
6
|
+
|
|
7
|
+
export async function createProtectedFilesProject(): Promise<{
|
|
8
|
+
projectRoot: string;
|
|
9
|
+
envPath: string;
|
|
10
|
+
}> {
|
|
11
|
+
const base = process.env.TMPDIR ?? "/tmp";
|
|
12
|
+
const projectRoot = await mkdtemp(join(base, "toolu-bridge-"));
|
|
13
|
+
const envPath = join(projectRoot, ".env");
|
|
14
|
+
await writeFile(envPath, "SECRET=1\n", "utf8");
|
|
15
|
+
await mkdir(join(projectRoot, ".claude"), { recursive: true });
|
|
16
|
+
await writeFile(
|
|
17
|
+
join(projectRoot, ".claude/toolu.config.json"),
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
version: 1,
|
|
20
|
+
gates: { protectedFiles: { mode: "block" } },
|
|
21
|
+
}),
|
|
22
|
+
"utf8",
|
|
23
|
+
);
|
|
24
|
+
return { projectRoot, envPath };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function bridgeEnv(): Record<string, string> {
|
|
28
|
+
return {
|
|
29
|
+
TOOLU_SETTINGS_DIR: join(REPO_ROOT, "plugins/toolu/settings"),
|
|
30
|
+
TOOLU_HOST_OVERRIDE: "claude",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { parseTooluConfig } from "../config.ts";
|
|
3
|
+
|
|
4
|
+
test("parseTooluConfig requires version 1", () => {
|
|
5
|
+
expect(parseTooluConfig({ version: 1 }).version).toBe(1);
|
|
6
|
+
expect(() => parseTooluConfig({ version: 2 })).toThrow();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("parseTooluConfig rejects unknown top-level keys", () => {
|
|
10
|
+
expect(() => parseTooluConfig({ version: 1, extra: true })).toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("parseTooluConfig accepts gates protectedFiles block", () => {
|
|
14
|
+
const cfg = parseTooluConfig({
|
|
15
|
+
version: 1,
|
|
16
|
+
gates: { protectedFiles: { mode: "block" } },
|
|
17
|
+
});
|
|
18
|
+
expect(cfg.gates?.protectedFiles?.mode).toBe("block");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("parseTooluConfig rejects invalid gate mode", () => {
|
|
22
|
+
expect(() =>
|
|
23
|
+
parseTooluConfig({
|
|
24
|
+
version: 1,
|
|
25
|
+
gates: { protectedFiles: { mode: "maybe" } },
|
|
26
|
+
}),
|
|
27
|
+
).toThrow();
|
|
28
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** toolu.config.json v1 strict Zod (#210). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const GateModeSchema = z.enum(["block", "ask", "advise", "off"]);
|
|
5
|
+
const GatePresetSchema = z.enum(["strict", "balanced", "relaxed"]);
|
|
6
|
+
const DocsSyncModeSchema = z.enum(["advise", "block", "off"]);
|
|
7
|
+
const AgentTierModeSchema = z.enum(["advise", "block", "off"]);
|
|
8
|
+
const ModelClassSchema = z.enum(["haiku", "sonnet", "opus", "fable", "inherit"]);
|
|
9
|
+
const ReasoningEffortSchema = z.enum(["low", "medium", "high", "xhigh", "max", "ultra"]);
|
|
10
|
+
|
|
11
|
+
const GateEntrySchema = z.object({ mode: GateModeSchema }).strict();
|
|
12
|
+
|
|
13
|
+
const LangEntrySchema = z
|
|
14
|
+
.object({
|
|
15
|
+
maxFileLines: z.number().int().positive().optional(),
|
|
16
|
+
maxFnLines: z.number().int().positive().optional(),
|
|
17
|
+
maxImplLines: z.number().int().positive().optional(),
|
|
18
|
+
noMocks: z.boolean().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
|
|
22
|
+
const CodexModelEntrySchema = z
|
|
23
|
+
.object({
|
|
24
|
+
model: z.string().min(1),
|
|
25
|
+
reasoningEffort: ReasoningEffortSchema.optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
export const TooluConfigSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
version: z.literal(1),
|
|
32
|
+
skills: z.record(z.string(), z.boolean()).optional(),
|
|
33
|
+
hooks: z.record(z.string(), z.boolean()).optional(),
|
|
34
|
+
mcp: z.record(z.string(), z.boolean()).optional(),
|
|
35
|
+
agents: z.record(z.string(), z.boolean()).optional(),
|
|
36
|
+
models: z
|
|
37
|
+
.object({
|
|
38
|
+
enabled: z.boolean().optional(),
|
|
39
|
+
mechanical: ModelClassSchema.optional(),
|
|
40
|
+
exploration: ModelClassSchema.optional(),
|
|
41
|
+
implementation: ModelClassSchema.optional(),
|
|
42
|
+
review: ModelClassSchema.optional(),
|
|
43
|
+
synthesis: ModelClassSchema.optional(),
|
|
44
|
+
architecture: ModelClassSchema.optional(),
|
|
45
|
+
codex: z.record(z.string(), CodexModelEntrySchema).optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict()
|
|
48
|
+
.optional(),
|
|
49
|
+
lang: z
|
|
50
|
+
.object({
|
|
51
|
+
ts: LangEntrySchema.optional(),
|
|
52
|
+
rust: LangEntrySchema.optional(),
|
|
53
|
+
python: LangEntrySchema.optional(),
|
|
54
|
+
})
|
|
55
|
+
.strict()
|
|
56
|
+
.optional(),
|
|
57
|
+
docsSync: z
|
|
58
|
+
.object({
|
|
59
|
+
mode: DocsSyncModeSchema.optional(),
|
|
60
|
+
surfaces: z.array(z.string()).optional(),
|
|
61
|
+
surfaceExcludes: z.array(z.string()).optional(),
|
|
62
|
+
codeSurfaces: z.array(z.string()).optional(),
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
.optional(),
|
|
66
|
+
telemetry: z.object({ enabled: z.boolean().optional() }).strict().optional(),
|
|
67
|
+
agentTier: z.object({ mode: AgentTierModeSchema.optional() }).strict().optional(),
|
|
68
|
+
planLedger: z.object({ blockOnUncoveredAcs: z.boolean().optional() }).strict().optional(),
|
|
69
|
+
gates: z
|
|
70
|
+
.object({
|
|
71
|
+
preset: GatePresetSchema.optional(),
|
|
72
|
+
pushReview: GateEntrySchema.optional(),
|
|
73
|
+
qualityGate: GateEntrySchema.optional(),
|
|
74
|
+
commitGate: GateEntrySchema.optional(),
|
|
75
|
+
bashCommands: GateEntrySchema.optional(),
|
|
76
|
+
planLedger: GateEntrySchema.optional(),
|
|
77
|
+
docsSync: GateEntrySchema.optional(),
|
|
78
|
+
agentTier: GateEntrySchema.optional(),
|
|
79
|
+
protectedFiles: GateEntrySchema.optional(),
|
|
80
|
+
mcpBlocker: GateEntrySchema.optional(),
|
|
81
|
+
sweep: z.boolean().optional(),
|
|
82
|
+
stateTtlHours: z.number().int().positive().optional(),
|
|
83
|
+
telemetryRetentionDays: z.number().int().positive().optional(),
|
|
84
|
+
})
|
|
85
|
+
.strict()
|
|
86
|
+
.optional(),
|
|
87
|
+
permissions: z
|
|
88
|
+
.object({
|
|
89
|
+
autoAllow: z.boolean().optional(),
|
|
90
|
+
allow: z.array(z.string()).optional(),
|
|
91
|
+
deny: z.array(z.string()).optional(),
|
|
92
|
+
})
|
|
93
|
+
.strict()
|
|
94
|
+
.optional(),
|
|
95
|
+
projectSkills: z
|
|
96
|
+
.object({
|
|
97
|
+
enabled: z.boolean().optional(),
|
|
98
|
+
staleAfterDays: z.number().int().positive().optional(),
|
|
99
|
+
archiveAfterDays: z.number().int().positive().optional(),
|
|
100
|
+
indexCap: z.number().int().positive().optional(),
|
|
101
|
+
})
|
|
102
|
+
.strict()
|
|
103
|
+
.optional(),
|
|
104
|
+
})
|
|
105
|
+
.strict();
|
|
106
|
+
|
|
107
|
+
export type TooluConfig = z.infer<typeof TooluConfigSchema>;
|
|
108
|
+
|
|
109
|
+
/** Parse toolu.config.json; rejects unknown top-level keys. */
|
|
110
|
+
export function parseTooluConfig(input: unknown): TooluConfig {
|
|
111
|
+
return TooluConfigSchema.parse(input);
|
|
112
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { DecisionSchema, parseDecision } from "../decision.ts";
|
|
3
|
+
|
|
4
|
+
test("parseDecision accepts allow", () => {
|
|
5
|
+
expect(parseDecision({ kind: "allow" })).toEqual({ kind: "allow" });
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
test("parseDecision accepts deny with reason", () => {
|
|
9
|
+
expect(parseDecision({ kind: "deny", reason: "blocked" })).toEqual({
|
|
10
|
+
kind: "deny",
|
|
11
|
+
reason: "blocked",
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("parseDecision rejects unknown kind", () => {
|
|
16
|
+
expect(() => parseDecision({ kind: "maybe" })).toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parseDecision rejects deny without reason", () => {
|
|
20
|
+
expect(() => parseDecision({ kind: "deny" })).toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("DecisionSchema accepts runtime_failure codes", () => {
|
|
24
|
+
const parsed = DecisionSchema.parse({
|
|
25
|
+
kind: "runtime_failure",
|
|
26
|
+
reason: "timeout",
|
|
27
|
+
code: "timeout",
|
|
28
|
+
});
|
|
29
|
+
expect(parsed.kind === "runtime_failure" && parsed.code).toBe("timeout");
|
|
30
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Discriminated decision union (portable-core #210). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const DecisionSchema = z.discriminatedUnion("kind", [
|
|
5
|
+
z.object({ kind: z.literal("allow") }),
|
|
6
|
+
z.object({ kind: z.literal("ask"), reason: z.string().min(1) }),
|
|
7
|
+
z.object({ kind: z.literal("deny"), reason: z.string().min(1) }),
|
|
8
|
+
z.object({ kind: z.literal("advisory"), message: z.string().min(1) }),
|
|
9
|
+
z.object({ kind: z.literal("post_block"), reason: z.string().min(1) }),
|
|
10
|
+
z.object({
|
|
11
|
+
kind: z.literal("runtime_failure"),
|
|
12
|
+
reason: z.string().min(1),
|
|
13
|
+
code: z.enum(["timeout", "spawn", "parse", "truncated", "cancelled", "nonzero"]),
|
|
14
|
+
}),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export type Decision = z.infer<typeof DecisionSchema>;
|
|
18
|
+
|
|
19
|
+
/** Parse an unknown decision envelope. */
|
|
20
|
+
export function parseDecision(input: unknown): Decision {
|
|
21
|
+
return DecisionSchema.parse(input);
|
|
22
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { parseNormalizedEvent } from "../events.ts";
|
|
3
|
+
|
|
4
|
+
const base = {
|
|
5
|
+
sessionId: "s1",
|
|
6
|
+
cwd: "/repo",
|
|
7
|
+
projectRoot: "/repo",
|
|
8
|
+
worktree: "/repo",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
test("parseNormalizedEvent accepts tool/pre", () => {
|
|
12
|
+
expect(
|
|
13
|
+
parseNormalizedEvent({
|
|
14
|
+
type: "tool/pre",
|
|
15
|
+
...base,
|
|
16
|
+
toolCallId: "c1",
|
|
17
|
+
toolName: "Edit",
|
|
18
|
+
toolInput: { file_path: "/repo/a.ts" },
|
|
19
|
+
}).type,
|
|
20
|
+
).toBe("tool/pre");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parseNormalizedEvent rejects missing sessionId", () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
parseNormalizedEvent({
|
|
26
|
+
type: "tool/pre",
|
|
27
|
+
cwd: "/repo",
|
|
28
|
+
projectRoot: "/repo",
|
|
29
|
+
worktree: "/repo",
|
|
30
|
+
toolCallId: "c1",
|
|
31
|
+
toolName: "Edit",
|
|
32
|
+
}),
|
|
33
|
+
).toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("parseNormalizedEvent accepts session/start", () => {
|
|
37
|
+
expect(parseNormalizedEvent({ type: "session/start", ...base }).type).toBe("session/start");
|
|
38
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Normalized host event vocabulary (portable-core #210). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const SessionContextSchema = z.object({
|
|
5
|
+
sessionId: z.string().min(1),
|
|
6
|
+
cwd: z.string().min(1),
|
|
7
|
+
projectRoot: z.string().min(1),
|
|
8
|
+
worktree: z.string().min(1),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const ToolContextSchema = SessionContextSchema.extend({
|
|
12
|
+
toolCallId: z.string().min(1),
|
|
13
|
+
toolName: z.string().min(1),
|
|
14
|
+
toolInput: z.record(z.string(), z.unknown()).default({}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const NormalizedEventSchema = z.discriminatedUnion("type", [
|
|
18
|
+
SessionContextSchema.extend({ type: z.literal("session/start") }),
|
|
19
|
+
SessionContextSchema.extend({ type: z.literal("session/resume") }),
|
|
20
|
+
SessionContextSchema.extend({ type: z.literal("session/clear") }),
|
|
21
|
+
SessionContextSchema.extend({ type: z.literal("session/unload") }),
|
|
22
|
+
SessionContextSchema.extend({
|
|
23
|
+
type: z.literal("prompt"),
|
|
24
|
+
prompt: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
SessionContextSchema.extend({ type: z.literal("pre_compact") }),
|
|
27
|
+
SessionContextSchema.extend({ type: z.literal("compaction") }),
|
|
28
|
+
SessionContextSchema.extend({
|
|
29
|
+
type: z.literal("permission/evaluate"),
|
|
30
|
+
permission: z.string().min(1),
|
|
31
|
+
}),
|
|
32
|
+
ToolContextSchema.extend({ type: z.literal("tool/pre") }),
|
|
33
|
+
ToolContextSchema.extend({
|
|
34
|
+
type: z.literal("tool/post"),
|
|
35
|
+
toolOutput: z.unknown().optional(),
|
|
36
|
+
}),
|
|
37
|
+
ToolContextSchema.extend({
|
|
38
|
+
type: z.literal("shell/pre"),
|
|
39
|
+
command: z.string().min(1),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export type NormalizedEvent = z.infer<typeof NormalizedEventSchema>;
|
|
44
|
+
|
|
45
|
+
/** Bridge-facing event slug (subset used in BridgeRequest.event). */
|
|
46
|
+
export const BridgeEventSchema = z.enum([
|
|
47
|
+
"tool/pre",
|
|
48
|
+
"tool/post",
|
|
49
|
+
"shell/pre",
|
|
50
|
+
"session/start",
|
|
51
|
+
"session/resume",
|
|
52
|
+
"session/clear",
|
|
53
|
+
"session/unload",
|
|
54
|
+
"prompt",
|
|
55
|
+
"pre_compact",
|
|
56
|
+
"compaction",
|
|
57
|
+
"permission/evaluate",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export type BridgeEvent = z.infer<typeof BridgeEventSchema>;
|
|
61
|
+
|
|
62
|
+
/** Parse a normalized event envelope. */
|
|
63
|
+
export function parseNormalizedEvent(input: unknown): NormalizedEvent {
|
|
64
|
+
return NormalizedEventSchema.parse(input);
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mergeDecisions, parseClassification } from "../policy.ts";
|
|
3
|
+
|
|
4
|
+
test("parseClassification accepts shell-out", () => {
|
|
5
|
+
expect(parseClassification("shell-out")).toBe("shell-out");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
test("mergeDecisions deny beats ask beats advisory beats allow", () => {
|
|
9
|
+
expect(
|
|
10
|
+
mergeDecisions([
|
|
11
|
+
{ kind: "allow" },
|
|
12
|
+
{ kind: "advisory", message: "note" },
|
|
13
|
+
{ kind: "ask", reason: "prompt" },
|
|
14
|
+
{ kind: "deny", reason: "no" },
|
|
15
|
+
]).kind,
|
|
16
|
+
).toBe("deny");
|
|
17
|
+
|
|
18
|
+
expect(
|
|
19
|
+
mergeDecisions([
|
|
20
|
+
{ kind: "allow" },
|
|
21
|
+
{ kind: "advisory", message: "note" },
|
|
22
|
+
{ kind: "ask", reason: "prompt" },
|
|
23
|
+
]).kind,
|
|
24
|
+
).toBe("ask");
|
|
25
|
+
|
|
26
|
+
expect(mergeDecisions([{ kind: "allow" }, { kind: "advisory", message: "note" }]).kind).toBe(
|
|
27
|
+
"advisory",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("mergeDecisions empty yields allow", () => {
|
|
32
|
+
expect(mergeDecisions([])).toEqual({ kind: "allow" });
|
|
33
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Classification enum + decision precedence (#210). */
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type Decision, DecisionSchema } from "../decision/decision.ts";
|
|
4
|
+
|
|
5
|
+
export const ClassificationSchema = z.enum(["shell-out", "port-native", "port-new", "no-map"]);
|
|
6
|
+
|
|
7
|
+
export type Classification = z.infer<typeof ClassificationSchema>;
|
|
8
|
+
|
|
9
|
+
/** Parse a portable-core classification token. */
|
|
10
|
+
export function parseClassification(input: unknown): Classification {
|
|
11
|
+
return ClassificationSchema.parse(input);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MERGE_RANK: Record<Decision["kind"], number> = {
|
|
15
|
+
runtime_failure: 6,
|
|
16
|
+
deny: 5,
|
|
17
|
+
post_block: 5,
|
|
18
|
+
ask: 4,
|
|
19
|
+
advisory: 3,
|
|
20
|
+
allow: 1,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** deny > ask > advisory; allow loses to any of those. */
|
|
24
|
+
export function mergeDecisions(decisions: Decision[]): Decision {
|
|
25
|
+
if (decisions.length === 0) {
|
|
26
|
+
return { kind: "allow" };
|
|
27
|
+
}
|
|
28
|
+
let best = decisions[0];
|
|
29
|
+
if (best === undefined) {
|
|
30
|
+
return { kind: "allow" };
|
|
31
|
+
}
|
|
32
|
+
for (let i = 1; i < decisions.length; i++) {
|
|
33
|
+
const next = decisions[i];
|
|
34
|
+
if (next === undefined) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (MERGE_RANK[next.kind] > MERGE_RANK[best.kind]) {
|
|
38
|
+
best = next;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return DecisionSchema.parse(best);
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, realpath } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createBunBashRunner } from "../runner.ts";
|
|
5
|
+
|
|
6
|
+
const runner = createBunBashRunner();
|
|
7
|
+
|
|
8
|
+
test("runner timeout yields runtime_failure-capable spawn result not ok timeout", async () => {
|
|
9
|
+
const result = await runner.run({
|
|
10
|
+
argv: ["bash", "-c", "sleep 5"],
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
env: {},
|
|
13
|
+
stdin: "",
|
|
14
|
+
deadlineMs: 200,
|
|
15
|
+
maxStdoutBytes: 65536,
|
|
16
|
+
});
|
|
17
|
+
expect(result.ok).toBe(false);
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
expect(result.code).toBe("timeout");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("runner exit 2 returns ok true exitCode 2 for bridge mapping", async () => {
|
|
24
|
+
const result = await runner.run({
|
|
25
|
+
argv: ["bash", "-c", "echo blocked 1>&2; exit 2"],
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
env: {},
|
|
28
|
+
stdin: "",
|
|
29
|
+
deadlineMs: 5000,
|
|
30
|
+
maxStdoutBytes: 65536,
|
|
31
|
+
});
|
|
32
|
+
expect(result.ok).toBe(true);
|
|
33
|
+
if (result.ok) {
|
|
34
|
+
expect(result.exitCode).toBe(2);
|
|
35
|
+
expect(result.stderr).toContain("blocked");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("runner spawn failure missing binary yields spawn code", async () => {
|
|
40
|
+
const result = await runner.run({
|
|
41
|
+
argv: ["toolu-nonexistent-binary-210"],
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
env: {},
|
|
44
|
+
stdin: "",
|
|
45
|
+
deadlineMs: 5000,
|
|
46
|
+
maxStdoutBytes: 65536,
|
|
47
|
+
});
|
|
48
|
+
expect(result.ok).toBe(false);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
expect(result.code).toBe("spawn");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("runner path with spaces cwd runs echo ok", async () => {
|
|
55
|
+
const base = process.env.TMPDIR ?? "/tmp";
|
|
56
|
+
const cwd = await mkdtemp(join(base, "toolu spaces "));
|
|
57
|
+
await mkdir(cwd, { recursive: true });
|
|
58
|
+
const result = await runner.run({
|
|
59
|
+
argv: ["bash", "-c", "pwd"],
|
|
60
|
+
cwd,
|
|
61
|
+
env: {},
|
|
62
|
+
stdin: "",
|
|
63
|
+
deadlineMs: 5000,
|
|
64
|
+
maxStdoutBytes: 65536,
|
|
65
|
+
});
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
if (result.ok) {
|
|
68
|
+
// macOS exposes TMPDIR as /var/... while pwd resolves /private/var/...
|
|
69
|
+
expect(result.stdout.trim()).toBe(await realpath(cwd));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("runner malformed handled at bridge not runner", async () => {
|
|
74
|
+
const result = await runner.run({
|
|
75
|
+
argv: ["bash", "-c", "echo not-json"],
|
|
76
|
+
cwd: process.cwd(),
|
|
77
|
+
env: {},
|
|
78
|
+
stdin: "",
|
|
79
|
+
deadlineMs: 5000,
|
|
80
|
+
maxStdoutBytes: 65536,
|
|
81
|
+
});
|
|
82
|
+
expect(result.ok).toBe(true);
|
|
83
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** Execute one argv spawn with deadline and output limits. */
|
|
2
|
+
import type { Subprocess } from "bun";
|
|
3
|
+
import { readLimitedStream } from "./runner-read.ts";
|
|
4
|
+
import type { BashRunArgs, RawProcessResult } from "./runner-types.ts";
|
|
5
|
+
|
|
6
|
+
type Spawned = {
|
|
7
|
+
proc: Subprocess<"pipe", "pipe", "pipe">;
|
|
8
|
+
sessionLeader: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function spawnFailure(message: string): RawProcessResult {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
code: "spawn",
|
|
15
|
+
message,
|
|
16
|
+
exitCode: null,
|
|
17
|
+
stdout: "",
|
|
18
|
+
stderr: message,
|
|
19
|
+
truncated: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function killSpawned(spawned: Spawned): void {
|
|
24
|
+
const { proc, sessionLeader } = spawned;
|
|
25
|
+
if (sessionLeader) {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
28
|
+
return;
|
|
29
|
+
} catch {
|
|
30
|
+
/* fall through to PID kill */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
process.kill(proc.pid, "SIGTERM");
|
|
35
|
+
} catch {
|
|
36
|
+
/* already exited */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withSession(argv: string[]): { argv: string[]; sessionLeader: boolean } {
|
|
41
|
+
if (argv[0] === "setsid") {
|
|
42
|
+
return { argv, sessionLeader: true };
|
|
43
|
+
}
|
|
44
|
+
const setsidBin = Bun.which("setsid");
|
|
45
|
+
if (setsidBin !== null) {
|
|
46
|
+
return { argv: [setsidBin, ...argv], sessionLeader: true };
|
|
47
|
+
}
|
|
48
|
+
const python = Bun.which("python3") ?? Bun.which("python");
|
|
49
|
+
if (python !== null) {
|
|
50
|
+
return {
|
|
51
|
+
argv: [
|
|
52
|
+
python,
|
|
53
|
+
"-c",
|
|
54
|
+
"import os, sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])",
|
|
55
|
+
...argv,
|
|
56
|
+
],
|
|
57
|
+
sessionLeader: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { argv, sessionLeader: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function raceAbort(
|
|
64
|
+
deadlineMs: number,
|
|
65
|
+
signal: AbortSignal | undefined,
|
|
66
|
+
): { promise: Promise<"timeout" | "cancelled">; clear: () => void } {
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
const deadline = new Promise<"timeout">((resolve) => {
|
|
69
|
+
timer = setTimeout(() => resolve("timeout"), deadlineMs);
|
|
70
|
+
});
|
|
71
|
+
const cancel =
|
|
72
|
+
signal === undefined
|
|
73
|
+
? null
|
|
74
|
+
: signal.aborted
|
|
75
|
+
? Promise.resolve("cancelled" as const)
|
|
76
|
+
: new Promise<"cancelled">((resolve) => {
|
|
77
|
+
signal.addEventListener("abort", () => resolve("cancelled"), { once: true });
|
|
78
|
+
});
|
|
79
|
+
const racers: Promise<"timeout" | "cancelled">[] = [deadline];
|
|
80
|
+
if (cancel !== null) {
|
|
81
|
+
racers.push(cancel);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
promise: Promise.race(racers),
|
|
85
|
+
clear: () => {
|
|
86
|
+
if (timer !== undefined) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function collectOutputs(
|
|
94
|
+
proc: Subprocess<"pipe", "pipe", "pipe">,
|
|
95
|
+
maxStdoutBytes: number,
|
|
96
|
+
): Promise<{
|
|
97
|
+
stdoutOut: Awaited<ReturnType<typeof readLimitedStream>>;
|
|
98
|
+
stderrOut: Awaited<ReturnType<typeof readLimitedStream>>;
|
|
99
|
+
exitCode: number;
|
|
100
|
+
}> {
|
|
101
|
+
const [stdoutOut, stderrOut, exitCode] = await Promise.all([
|
|
102
|
+
readLimitedStream(proc.stdout, maxStdoutBytes),
|
|
103
|
+
readLimitedStream(proc.stderr, maxStdoutBytes),
|
|
104
|
+
proc.exited,
|
|
105
|
+
]);
|
|
106
|
+
return { stdoutOut, stderrOut, exitCode };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function settleMaybePromise(value: number | Promise<number> | undefined): Promise<void> {
|
|
110
|
+
if (value === undefined || typeof value === "number") {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function feedStdin(proc: Subprocess<"pipe", "pipe", "pipe">, stdin: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
proc.stdin.write(stdin);
|
|
119
|
+
await settleMaybePromise(proc.stdin.flush());
|
|
120
|
+
await settleMaybePromise(proc.stdin.end());
|
|
121
|
+
} catch {
|
|
122
|
+
// Child already closed stdin / exited — runner still collects exit.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function openSpawn(args: BashRunArgs): Promise<Spawned | RawProcessResult> {
|
|
127
|
+
const cmd = args.argv[0];
|
|
128
|
+
if (cmd === undefined || cmd.length === 0) {
|
|
129
|
+
return spawnFailure("empty argv");
|
|
130
|
+
}
|
|
131
|
+
if (!cmd.includes("/") && Bun.which(cmd) === null) {
|
|
132
|
+
return spawnFailure(`command not found: ${cmd}`);
|
|
133
|
+
}
|
|
134
|
+
const wrapped = withSession(args.argv);
|
|
135
|
+
try {
|
|
136
|
+
const proc = Bun.spawn(wrapped.argv, {
|
|
137
|
+
cwd: args.cwd,
|
|
138
|
+
env: { ...process.env, ...args.env },
|
|
139
|
+
stdin: "pipe",
|
|
140
|
+
stdout: "pipe",
|
|
141
|
+
stderr: "pipe",
|
|
142
|
+
});
|
|
143
|
+
await feedStdin(proc, args.stdin);
|
|
144
|
+
return { proc, sessionLeader: wrapped.sessionLeader };
|
|
145
|
+
} catch (err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147
|
+
return spawnFailure(message);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function finishTimedOut(
|
|
152
|
+
spawned: Spawned,
|
|
153
|
+
code: "timeout" | "cancelled",
|
|
154
|
+
maxStdoutBytes: number,
|
|
155
|
+
): Promise<RawProcessResult> {
|
|
156
|
+
killSpawned(spawned);
|
|
157
|
+
await spawned.proc.exited;
|
|
158
|
+
const { stdoutOut, stderrOut } = await collectOutputs(spawned.proc, maxStdoutBytes);
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
code,
|
|
162
|
+
message: code === "cancelled" ? "process cancelled" : "process timed out",
|
|
163
|
+
exitCode: null,
|
|
164
|
+
stdout: stdoutOut.text,
|
|
165
|
+
stderr: stderrOut.text,
|
|
166
|
+
truncated: stdoutOut.truncated || stderrOut.truncated,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function execBashRun(args: BashRunArgs): Promise<RawProcessResult> {
|
|
171
|
+
const opened = await openSpawn(args);
|
|
172
|
+
if (!("proc" in opened)) {
|
|
173
|
+
return opened;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const abort = raceAbort(args.deadlineMs, args.signal);
|
|
177
|
+
// Attach both fulfill/reject so a losing racer cannot raise unhandledRejection.
|
|
178
|
+
const exitedDone = opened.proc.exited.then(
|
|
179
|
+
() => "done" as const,
|
|
180
|
+
() => "done" as const,
|
|
181
|
+
);
|
|
182
|
+
const raced = await Promise.race([exitedDone, abort.promise]);
|
|
183
|
+
|
|
184
|
+
if (raced !== "done") {
|
|
185
|
+
abort.clear();
|
|
186
|
+
return finishTimedOut(opened, raced, args.maxStdoutBytes);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
abort.clear();
|
|
190
|
+
await exitedDone;
|
|
191
|
+
const { stdoutOut, stderrOut, exitCode } = await collectOutputs(opened.proc, args.maxStdoutBytes);
|
|
192
|
+
|
|
193
|
+
if (stdoutOut.truncated) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
code: "truncated",
|
|
197
|
+
message: "stdout exceeded maxStdoutBytes",
|
|
198
|
+
exitCode,
|
|
199
|
+
stdout: stdoutOut.text,
|
|
200
|
+
stderr: stderrOut.text,
|
|
201
|
+
truncated: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
exitCode,
|
|
208
|
+
stdout: stdoutOut.text,
|
|
209
|
+
stderr: stderrOut.text,
|
|
210
|
+
truncated: false,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Read subprocess streams with a byte ceiling. */
|
|
2
|
+
|
|
3
|
+
async function readChunk(
|
|
4
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
5
|
+
chunks: Uint8Array[],
|
|
6
|
+
state: { total: number; truncated: boolean },
|
|
7
|
+
maxBytes: number,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
const { done, value } = await reader.read();
|
|
10
|
+
if (done) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (value.byteLength > 0) {
|
|
14
|
+
if (state.total >= maxBytes) {
|
|
15
|
+
state.truncated = true;
|
|
16
|
+
} else {
|
|
17
|
+
const room = maxBytes - state.total;
|
|
18
|
+
if (value.byteLength <= room) {
|
|
19
|
+
chunks.push(value);
|
|
20
|
+
state.total += value.byteLength;
|
|
21
|
+
} else {
|
|
22
|
+
chunks.push(value.subarray(0, room));
|
|
23
|
+
state.total = maxBytes;
|
|
24
|
+
state.truncated = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await readChunk(reader, chunks, state, maxBytes);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readLimitedStream(
|
|
32
|
+
stream: ReadableStream<Uint8Array>,
|
|
33
|
+
maxBytes: number,
|
|
34
|
+
): Promise<{ text: string; truncated: boolean }> {
|
|
35
|
+
const reader = stream.getReader();
|
|
36
|
+
const chunks: Uint8Array[] = [];
|
|
37
|
+
const state = { total: 0, truncated: false };
|
|
38
|
+
await readChunk(reader, chunks, state, maxBytes);
|
|
39
|
+
const merged = new Uint8Array(state.total);
|
|
40
|
+
let offset = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
merged.set(chunk, offset);
|
|
43
|
+
offset += chunk.byteLength;
|
|
44
|
+
}
|
|
45
|
+
return { text: new TextDecoder().decode(merged), truncated: state.truncated };
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared runner result types. */
|
|
2
|
+
|
|
3
|
+
export type RawProcessFailureCode = "timeout" | "spawn" | "truncated" | "cancelled" | "ok";
|
|
4
|
+
|
|
5
|
+
export type RawProcessResult =
|
|
6
|
+
| {
|
|
7
|
+
ok: true;
|
|
8
|
+
exitCode: number;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
ok: false;
|
|
15
|
+
code: Exclude<RawProcessFailureCode, "ok">;
|
|
16
|
+
message: string;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BashRunArgs = {
|
|
24
|
+
argv: string[];
|
|
25
|
+
cwd: string;
|
|
26
|
+
env: Record<string, string>;
|
|
27
|
+
stdin: string;
|
|
28
|
+
deadlineMs: number;
|
|
29
|
+
maxStdoutBytes: number;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface BashRunner {
|
|
34
|
+
run(args: BashRunArgs): Promise<RawProcessResult>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** argv-only bash runner for the portable bridge (#210). */
|
|
2
|
+
import { execBashRun } from "./runner-exec.ts";
|
|
3
|
+
import type { BashRunArgs, BashRunner, RawProcessResult } from "./runner-types.ts";
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
BashRunArgs,
|
|
7
|
+
BashRunner,
|
|
8
|
+
RawProcessFailureCode,
|
|
9
|
+
RawProcessResult,
|
|
10
|
+
} from "./runner-types.ts";
|
|
11
|
+
|
|
12
|
+
/** Bun.spawn runner: argv array only, never a shell string. */
|
|
13
|
+
export function createBunBashRunner(): BashRunner {
|
|
14
|
+
return {
|
|
15
|
+
run(args: BashRunArgs): Promise<RawProcessResult> {
|
|
16
|
+
return execBashRun(args);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|