@celestea/tools 2.7.1
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 +21 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +55 -0
- package/dist/attachments/image-header.d.ts +28 -0
- package/dist/attachments/image-header.js +148 -0
- package/dist/attachments/store.d.ts +56 -0
- package/dist/attachments/store.js +148 -0
- package/dist/browser/cdp.d.ts +162 -0
- package/dist/browser/cdp.js +269 -0
- package/dist/browser/launch.d.ts +92 -0
- package/dist/browser/launch.js +232 -0
- package/dist/browser/memory-guard.d.ts +55 -0
- package/dist/browser/memory-guard.js +210 -0
- package/dist/browser/session.d.ts +146 -0
- package/dist/browser/session.js +371 -0
- package/dist/browser/snapshot.d.ts +69 -0
- package/dist/browser/snapshot.js +163 -0
- package/dist/browser/types.d.ts +51 -0
- package/dist/browser/types.js +8 -0
- package/dist/builtin.d.ts +54 -0
- package/dist/builtin.js +77 -0
- package/dist/desc.d.ts +20 -0
- package/dist/desc.js +22 -0
- package/dist/disclosure.d.ts +113 -0
- package/dist/disclosure.js +141 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +33 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +60 -0
- package/dist/exposure.d.ts +145 -0
- package/dist/exposure.js +244 -0
- package/dist/fn-tool.d.ts +14 -0
- package/dist/fn-tool.js +14 -0
- package/dist/fs/file-io.d.ts +78 -0
- package/dist/fs/file-io.js +239 -0
- package/dist/guard/path-guard.d.ts +144 -0
- package/dist/guard/path-guard.js +289 -0
- package/dist/guard/paths.d.ts +35 -0
- package/dist/guard/paths.js +100 -0
- package/dist/http/errors.d.ts +24 -0
- package/dist/http/errors.js +64 -0
- package/dist/http/headers.d.ts +19 -0
- package/dist/http/headers.js +62 -0
- package/dist/http/redirects.d.ts +31 -0
- package/dist/http/redirects.js +76 -0
- package/dist/http/ssrf.d.ts +105 -0
- package/dist/http/ssrf.js +272 -0
- package/dist/http/transport.d.ts +50 -0
- package/dist/http/transport.js +130 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +129 -0
- package/dist/memory/log.d.ts +75 -0
- package/dist/memory/log.js +157 -0
- package/dist/memory/store.d.ts +47 -0
- package/dist/memory/store.js +61 -0
- package/dist/platform/exec.d.ts +79 -0
- package/dist/platform/exec.js +218 -0
- package/dist/platform/index.d.ts +12 -0
- package/dist/platform/index.js +12 -0
- package/dist/platform/paths.d.ts +51 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/quote.d.ts +72 -0
- package/dist/platform/quote.js +102 -0
- package/dist/plugin.d.ts +96 -0
- package/dist/plugin.js +101 -0
- package/dist/process/buffers.d.ts +33 -0
- package/dist/process/buffers.js +86 -0
- package/dist/process/registry.d.ts +98 -0
- package/dist/process/registry.js +282 -0
- package/dist/registry.d.ts +52 -0
- package/dist/registry.js +161 -0
- package/dist/run-code/broker.d.ts +68 -0
- package/dist/run-code/broker.js +465 -0
- package/dist/run-code/limits.d.ts +69 -0
- package/dist/run-code/limits.js +88 -0
- package/dist/run-code/lines.d.ts +69 -0
- package/dist/run-code/lines.js +199 -0
- package/dist/run-code/sdk-ts.d.ts +34 -0
- package/dist/run-code/sdk-ts.js +276 -0
- package/dist/run-code/sdk.d.ts +39 -0
- package/dist/run-code/sdk.js +294 -0
- package/dist/sandbox/async.d.ts +10 -0
- package/dist/sandbox/async.js +26 -0
- package/dist/sandbox/bwrap-argv.d.ts +62 -0
- package/dist/sandbox/bwrap-argv.js +113 -0
- package/dist/sandbox/bwrap.d.ts +94 -0
- package/dist/sandbox/bwrap.js +159 -0
- package/dist/sandbox/child.d.ts +38 -0
- package/dist/sandbox/child.js +98 -0
- package/dist/sandbox/config.d.ts +89 -0
- package/dist/sandbox/config.js +149 -0
- package/dist/sandbox/fake-sandbox.d.ts +57 -0
- package/dist/sandbox/fake-sandbox.js +110 -0
- package/dist/sandbox/launch.d.ts +51 -0
- package/dist/sandbox/launch.js +134 -0
- package/dist/sandbox/limits.d.ts +63 -0
- package/dist/sandbox/limits.js +113 -0
- package/dist/sandbox/probe.d.ts +46 -0
- package/dist/sandbox/probe.js +102 -0
- package/dist/sandbox/provider.d.ts +83 -0
- package/dist/sandbox/provider.js +126 -0
- package/dist/sandbox/rlimit.d.ts +60 -0
- package/dist/sandbox/rlimit.js +76 -0
- package/dist/sandbox/seccomp.d.ts +48 -0
- package/dist/sandbox/seccomp.js +115 -0
- package/dist/sandbox/userspace.d.ts +65 -0
- package/dist/sandbox/userspace.js +107 -0
- package/dist/sandbox/workdir.d.ts +13 -0
- package/dist/sandbox/workdir.js +44 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +135 -0
- package/dist/testing/platform-gates.d.ts +54 -0
- package/dist/testing/platform-gates.js +62 -0
- package/dist/tool-failure.d.ts +13 -0
- package/dist/tool-failure.js +19 -0
- package/dist/tools/ask-user.d.ts +32 -0
- package/dist/tools/ask-user.js +145 -0
- package/dist/tools/browser.d.ts +24 -0
- package/dist/tools/browser.js +132 -0
- package/dist/tools/http-request.d.ts +31 -0
- package/dist/tools/http-request.js +117 -0
- package/dist/tools/list-dir.d.ts +9 -0
- package/dist/tools/list-dir.js +45 -0
- package/dist/tools/load-skill.d.ts +37 -0
- package/dist/tools/load-skill.js +76 -0
- package/dist/tools/memory.d.ts +46 -0
- package/dist/tools/memory.js +131 -0
- package/dist/tools/process-control.d.ts +14 -0
- package/dist/tools/process-control.js +49 -0
- package/dist/tools/read-file.d.ts +11 -0
- package/dist/tools/read-file.js +81 -0
- package/dist/tools/read-image.d.ts +33 -0
- package/dist/tools/read-image.js +144 -0
- package/dist/tools/run-code.d.ts +48 -0
- package/dist/tools/run-code.js +115 -0
- package/dist/tools/run-shell.d.ts +22 -0
- package/dist/tools/run-shell.js +81 -0
- package/dist/tools/write-file.d.ts +8 -0
- package/dist/tools/write-file.js +31 -0
- package/package.json +28 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UserspaceSandbox` — the P2b implementation of the `Sandbox` seam, kept as the
|
|
3
|
+
* explicit **fallback** (and as the "no OS isolation at all" reference the
|
|
4
|
+
* tests compare against).
|
|
5
|
+
*
|
|
6
|
+
* It is honest about what it is: no namespaces, no seccomp, no private /tmp, and
|
|
7
|
+
* — W274 §6.2/§6.3 — a `setsid()`ing grandchild or a SIGKILLed Node parent leaks
|
|
8
|
+
* processes that the bwrap path reaps. What it does enforce, and what the tool
|
|
9
|
+
* contract depends on:
|
|
10
|
+
* - a fixed workdir that must resolve inside the configured root;
|
|
11
|
+
* - an allowlisted child environment (never the host environment, never HOME);
|
|
12
|
+
* - a kill deadline that SIGKILLs the whole process group;
|
|
13
|
+
* - per-stream output caps, with the truncation flag reported;
|
|
14
|
+
* - structured failures (`run_shell-sandbox: code=timeout|workdir|arg|config|spawn`).
|
|
15
|
+
*
|
|
16
|
+
* Which provider a deployment actually gets is decided in `provider.ts`; the
|
|
17
|
+
* OS-isolated one is `bwrap.ts`.
|
|
18
|
+
*/
|
|
19
|
+
import { USERSPACE_SANDBOX_META } from "@celestea/core";
|
|
20
|
+
import { wrapChild } from "./child.js";
|
|
21
|
+
import { shellInvocation, sanitizedEnv, buildSandboxConfig, sandboxConfigFromEnv } from "./config.js";
|
|
22
|
+
import { captureRun, resolveTimeout, spawnPlan, validateSandboxConfig } from "./launch.js";
|
|
23
|
+
import { limitsForCpu, limitsFromEnv, resolveCpuSec, rlimitsEnabled } from "./limits.js";
|
|
24
|
+
import { probeHost } from "./probe.js";
|
|
25
|
+
import { applyLimits, rlimitDiagnostics } from "./rlimit.js";
|
|
26
|
+
import { resolveWorkdir } from "./workdir.js";
|
|
27
|
+
/** The effective mode every result reports (never inferred by the caller). */
|
|
28
|
+
export const USERSPACE_META = USERSPACE_SANDBOX_META;
|
|
29
|
+
export class UserspaceSandbox {
|
|
30
|
+
config;
|
|
31
|
+
probe;
|
|
32
|
+
limits;
|
|
33
|
+
/** W885: injected platform view; `undefined` = the host's own defaults. */
|
|
34
|
+
shell;
|
|
35
|
+
rlimits;
|
|
36
|
+
constructor(config = sandboxConfigFromEnv(), options = {}) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.probe = options.probe ?? probeHost();
|
|
39
|
+
this.limits = options.limits ?? limitsFromEnv(process.env, this.probe.uidThreads);
|
|
40
|
+
this.rlimits = options.rlimits ?? rlimitsEnabled(process.env);
|
|
41
|
+
this.shell = options.shell;
|
|
42
|
+
}
|
|
43
|
+
static fromEnv(env = process.env) {
|
|
44
|
+
return new UserspaceSandbox(sandboxConfigFromEnv(env), {
|
|
45
|
+
probe: probeHost({ env }),
|
|
46
|
+
rlimits: rlimitsEnabled(env),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async run(request) {
|
|
50
|
+
validateSandboxConfig(this.config);
|
|
51
|
+
const timeoutMs = resolveTimeout(this.config, request.timeoutMs);
|
|
52
|
+
const { child, meta } = await this.launch(request.command, request.workdir, false, this.limitsFor(request.cpuSec), request.noAddressSpaceLimit === true);
|
|
53
|
+
return captureRun(this.config, child, timeoutMs, meta);
|
|
54
|
+
}
|
|
55
|
+
async spawn(request) {
|
|
56
|
+
validateSandboxConfig(this.config);
|
|
57
|
+
const { child, meta } = await this.launch(request.command, request.workdir, true, this.limitsFor(request.cpuSec), request.noAddressSpaceLimit === true);
|
|
58
|
+
return { child: wrapChild(child, { detached: true }), sandbox: meta };
|
|
59
|
+
}
|
|
60
|
+
/** W6: the base limits with the per-call `cpu_sec` merged in (clamped). */
|
|
61
|
+
limitsFor(cpuSec) {
|
|
62
|
+
return limitsForCpu(this.limits, resolveCpuSec(this.limits.cpuSec, cpuSec, this.config.maxCpuSec));
|
|
63
|
+
}
|
|
64
|
+
/** F4: diagnostic view of what would be enforced (never SandboxMeta). */
|
|
65
|
+
describe(options = {}) {
|
|
66
|
+
const diag = rlimitDiagnostics(this.probe, this.rlimits, options.noAddressSpaceLimit === true);
|
|
67
|
+
return {
|
|
68
|
+
...USERSPACE_META,
|
|
69
|
+
cpu_sec: this.limits.cpuSec,
|
|
70
|
+
rlimit_via: diag.via,
|
|
71
|
+
address_space_limited: diag.address_space_limited,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async launch(command, requestedWorkdir, withStdin, limits, noAddressSpaceLimit) {
|
|
75
|
+
const workdir = await resolveWorkdir(this.config, requestedWorkdir);
|
|
76
|
+
const { program, args } = shellInvocation(command, this.shell);
|
|
77
|
+
let plan = { program, args };
|
|
78
|
+
if (this.rlimits) {
|
|
79
|
+
try {
|
|
80
|
+
const limited = applyLimits(program, args, limits, this.probe, { enabled: true, noAddressSpaceLimit });
|
|
81
|
+
plan = { program: limited.program, args: limited.args };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// W6: userspace is the DEGRADED fallback: unlike the fail-closed bwrap
|
|
85
|
+
// path, a host with no rlimit mechanism must still run (best effort).
|
|
86
|
+
process.stderr.write("[celestea-tools] userspace sandbox: no rlimit mechanism; running without limits\n");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const child = await spawnPlan({
|
|
90
|
+
program: plan.program,
|
|
91
|
+
args: plan.args,
|
|
92
|
+
workdir,
|
|
93
|
+
env: sanitizedEnv(this.config),
|
|
94
|
+
withStdin,
|
|
95
|
+
label: command,
|
|
96
|
+
});
|
|
97
|
+
return { child, meta: { ...USERSPACE_META, cpu_sec: limits.cpuSec } };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Factory used by the policy layer as the explicit fallback. */
|
|
101
|
+
export function userspaceSandbox(config) {
|
|
102
|
+
return new UserspaceSandbox(config ?? sandboxConfigFromEnv());
|
|
103
|
+
}
|
|
104
|
+
/** Factory with explicit knobs (tests / embeddings). */
|
|
105
|
+
export function userspaceSandboxWith(overrides) {
|
|
106
|
+
return new UserspaceSandbox(buildSandboxConfig(overrides));
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workdir resolution shared by every sandbox provider.
|
|
3
|
+
*
|
|
4
|
+
* The workdir is the *only* thing a provider may assume is writable, so it is
|
|
5
|
+
* resolved once, canonically, and checked against the configured root before a
|
|
6
|
+
* single process is spawned. This is a **lexical** check (the host has no
|
|
7
|
+
* `CAP_SYS_CHROOT`, see W274 §5): it stops accidental escapes and report
|
|
8
|
+
* nonsense, it is not a containment boundary — containment comes from the
|
|
9
|
+
* provider (bwrap read-only root / masks).
|
|
10
|
+
*/
|
|
11
|
+
import type { SandboxConfig } from "@celestea/core";
|
|
12
|
+
/** Resolve the effective workdir: existing, canonical, inside `config.root`. */
|
|
13
|
+
export declare function resolveWorkdir(config: SandboxConfig, override?: string): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workdir resolution shared by every sandbox provider.
|
|
3
|
+
*
|
|
4
|
+
* The workdir is the *only* thing a provider may assume is writable, so it is
|
|
5
|
+
* resolved once, canonically, and checked against the configured root before a
|
|
6
|
+
* single process is spawned. This is a **lexical** check (the host has no
|
|
7
|
+
* `CAP_SYS_CHROOT`, see W274 §5): it stops accidental escapes and report
|
|
8
|
+
* nonsense, it is not a containment boundary — containment comes from the
|
|
9
|
+
* provider (bwrap read-only root / masks).
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, realpath, stat } from "node:fs/promises";
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
|
+
import { SandboxError } from "@celestea/core";
|
|
14
|
+
import { isInside } from "../guard/paths.js";
|
|
15
|
+
import { ENV_SHELL_ROOT, ENV_SHELL_WORKDIR } from "./config.js";
|
|
16
|
+
/** Resolve the effective workdir: existing, canonical, inside `config.root`. */
|
|
17
|
+
export async function resolveWorkdir(config, override) {
|
|
18
|
+
const root = (await canonicalOf(config.root)) ?? resolve(config.root);
|
|
19
|
+
const target = override === undefined ? config.workdir : await resolveOverride(config.workdir, override);
|
|
20
|
+
if (override === undefined)
|
|
21
|
+
await mkdir(target, { recursive: true }).catch(() => undefined);
|
|
22
|
+
const resolved = await canonicalOf(target);
|
|
23
|
+
if (resolved === null) {
|
|
24
|
+
throw new SandboxError("workdir", `workdir '${target}' cannot be resolved`, { requested: target });
|
|
25
|
+
}
|
|
26
|
+
if (!isInside(resolved, root)) {
|
|
27
|
+
throw new SandboxError("workdir", `workdir '${target}' is outside the sandbox root '${root}' (widen with ${ENV_SHELL_ROOT} or adjust ${ENV_SHELL_WORKDIR})`, { requested: target, root });
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
async function resolveOverride(workdir, override) {
|
|
32
|
+
const target = isAbsolute(override) ? override : resolve(workdir, override);
|
|
33
|
+
const info = await stat(target).catch(() => null);
|
|
34
|
+
if (info === null) {
|
|
35
|
+
throw new SandboxError("workdir", `workdir '${override}' does not exist`, { requested: override });
|
|
36
|
+
}
|
|
37
|
+
if (!info.isDirectory()) {
|
|
38
|
+
throw new SandboxError("workdir", `workdir '${override}' is not a directory`, { requested: override });
|
|
39
|
+
}
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
async function canonicalOf(target) {
|
|
43
|
+
return realpath(target).catch(() => null);
|
|
44
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSON-Schema subset validator for the dispatch pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline stage 1 (before guards and execution): the agent-supplied `args`
|
|
5
|
+
* must satisfy the tool's own `spec().parameters`. The subset covers exactly
|
|
6
|
+
* what the frozen tool contracts use — `type`, `properties`, `required`,
|
|
7
|
+
* `additionalProperties`, `enum`, `minimum`, `items` — and is deliberately
|
|
8
|
+
* hand-written: `core` and `tools` ship zero runtime dependencies, and a full
|
|
9
|
+
* validator would be a new dependency for six schemas.
|
|
10
|
+
*
|
|
11
|
+
* A failure is a *result fact*, not an exception: the registry turns it into
|
|
12
|
+
* `toolargs: code=schema msg="…"` in `ToolOutput.error` and never runs the tool.
|
|
13
|
+
*/
|
|
14
|
+
export interface ArgsValidationFailure {
|
|
15
|
+
/** Human/agent-readable reason (single line). */
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
/** Validate `args` against `schema`; `null` means "valid". */
|
|
19
|
+
export declare function validateArgs(schema: unknown, args: unknown): ArgsValidationFailure | null;
|
|
20
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSON-Schema subset validator for the dispatch pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline stage 1 (before guards and execution): the agent-supplied `args`
|
|
5
|
+
* must satisfy the tool's own `spec().parameters`. The subset covers exactly
|
|
6
|
+
* what the frozen tool contracts use — `type`, `properties`, `required`,
|
|
7
|
+
* `additionalProperties`, `enum`, `minimum`, `items` — and is deliberately
|
|
8
|
+
* hand-written: `core` and `tools` ship zero runtime dependencies, and a full
|
|
9
|
+
* validator would be a new dependency for six schemas.
|
|
10
|
+
*
|
|
11
|
+
* A failure is a *result fact*, not an exception: the registry turns it into
|
|
12
|
+
* `toolargs: code=schema msg="…"` in `ToolOutput.error` and never runs the tool.
|
|
13
|
+
*/
|
|
14
|
+
/** Validate `args` against `schema`; `null` means "valid". */
|
|
15
|
+
export function validateArgs(schema, args) {
|
|
16
|
+
if (!isPlainObject(schema))
|
|
17
|
+
return null;
|
|
18
|
+
return validateValue(schema, args, "");
|
|
19
|
+
}
|
|
20
|
+
function validateValue(schema, value, path) {
|
|
21
|
+
const type = schema["type"];
|
|
22
|
+
if (typeof type === "string" && !matchesType(type, value)) {
|
|
23
|
+
return { message: `${label(path)} must be ${type} (got ${typeName(value)})` };
|
|
24
|
+
}
|
|
25
|
+
const enumFail = checkEnum(schema, value, path);
|
|
26
|
+
if (enumFail !== null)
|
|
27
|
+
return enumFail;
|
|
28
|
+
const minimumFail = checkMinimum(schema, value, path);
|
|
29
|
+
if (minimumFail !== null)
|
|
30
|
+
return minimumFail;
|
|
31
|
+
if (isPlainObject(value))
|
|
32
|
+
return validateObject(schema, value, path);
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return validateItems(schema, value, path);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function checkEnum(schema, value, path) {
|
|
38
|
+
const allowed = schema["enum"];
|
|
39
|
+
if (!Array.isArray(allowed))
|
|
40
|
+
return null;
|
|
41
|
+
if (allowed.some((candidate) => deepEqual(candidate, value)))
|
|
42
|
+
return null;
|
|
43
|
+
return { message: `${label(path)} must be one of [${allowed.map(show).join(", ")}]` };
|
|
44
|
+
}
|
|
45
|
+
function checkMinimum(schema, value, path) {
|
|
46
|
+
const minimum = schema["minimum"];
|
|
47
|
+
if (typeof minimum !== "number" || typeof value !== "number")
|
|
48
|
+
return null;
|
|
49
|
+
if (value >= minimum)
|
|
50
|
+
return null;
|
|
51
|
+
return { message: `${label(path)} must be >= ${minimum} (got ${value})` };
|
|
52
|
+
}
|
|
53
|
+
function validateObject(schema, value, path) {
|
|
54
|
+
const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
|
|
55
|
+
const required = Array.isArray(schema["required"]) ? schema["required"] : [];
|
|
56
|
+
for (const key of required) {
|
|
57
|
+
if (typeof key === "string" && !(key in value)) {
|
|
58
|
+
return { message: `missing required property '${key}'${at(path)}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [key, item] of Object.entries(value)) {
|
|
62
|
+
const fail = validateProperty(schema, properties, key, item, path);
|
|
63
|
+
if (fail !== null)
|
|
64
|
+
return fail;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function validateProperty(schema, properties, key, item, path) {
|
|
69
|
+
const propertySchema = properties[key];
|
|
70
|
+
const child = path === "" ? key : `${path}.${key}`;
|
|
71
|
+
if (propertySchema !== undefined) {
|
|
72
|
+
return validateValue(isPlainObject(propertySchema) ? propertySchema : {}, item, child);
|
|
73
|
+
}
|
|
74
|
+
const extra = schema["additionalProperties"];
|
|
75
|
+
if (extra === false)
|
|
76
|
+
return { message: `unexpected property '${key}' (additionalProperties: false)${at(path)}` };
|
|
77
|
+
// `additionalProperties: {schema}` constrains every unnamed property (the
|
|
78
|
+
// http_request `headers` map relies on it).
|
|
79
|
+
if (isPlainObject(extra))
|
|
80
|
+
return validateValue(extra, item, child);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function validateItems(schema, value, path) {
|
|
84
|
+
const items = schema["items"];
|
|
85
|
+
if (!isPlainObject(items))
|
|
86
|
+
return null;
|
|
87
|
+
for (const [index, item] of value.entries()) {
|
|
88
|
+
const fail = validateValue(items, item, path === "" ? `[${index}]` : `${path}[${index}]`);
|
|
89
|
+
if (fail !== null)
|
|
90
|
+
return fail;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function matchesType(type, value) {
|
|
95
|
+
switch (type) {
|
|
96
|
+
case "object":
|
|
97
|
+
return isPlainObject(value);
|
|
98
|
+
case "array":
|
|
99
|
+
return Array.isArray(value);
|
|
100
|
+
case "string":
|
|
101
|
+
return typeof value === "string";
|
|
102
|
+
case "integer":
|
|
103
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
104
|
+
case "number":
|
|
105
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
106
|
+
case "boolean":
|
|
107
|
+
return typeof value === "boolean";
|
|
108
|
+
case "null":
|
|
109
|
+
return value === null;
|
|
110
|
+
default:
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function typeName(value) {
|
|
115
|
+
if (value === null)
|
|
116
|
+
return "null";
|
|
117
|
+
if (Array.isArray(value))
|
|
118
|
+
return "array";
|
|
119
|
+
return typeof value;
|
|
120
|
+
}
|
|
121
|
+
export function isPlainObject(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
function deepEqual(a, b) {
|
|
125
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
126
|
+
}
|
|
127
|
+
function show(value) {
|
|
128
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
129
|
+
}
|
|
130
|
+
function label(path) {
|
|
131
|
+
return path === "" ? "args" : `property '${path}'`;
|
|
132
|
+
}
|
|
133
|
+
function at(path) {
|
|
134
|
+
return path === "" ? "" : ` in '${path}'`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W885 — the shared platform capability gates (W883 §7).
|
|
3
|
+
*
|
|
4
|
+
* The repo already gates OS-specific suites with `describe.skipIf` /
|
|
5
|
+
* `it.skipIf` (`bwrap-live.test.ts`, `auth.test.ts`, `broker.test-util.ts`).
|
|
6
|
+
* This module is the ONE place those predicates live, so a Windows host can run
|
|
7
|
+
* `pnpm test` green with VISIBLE skips instead of a scattering of hardcoded
|
|
8
|
+
* `process.platform` checks — and so a new OS-specific assumption cannot be
|
|
9
|
+
* written as a bare `if (!ready) return` (which the repo counts as a PASS, not a
|
|
10
|
+
* skip).
|
|
11
|
+
*
|
|
12
|
+
* The snapshot is a **memoized function**, not four module-level constants:
|
|
13
|
+
* probeHost() costs two short execFileSyncs, and importing `@celestea/tools`
|
|
14
|
+
* must not pay for them. Call `platformGates()` once at the top of a test file:
|
|
15
|
+
*
|
|
16
|
+
* const gates = platformGates();
|
|
17
|
+
* describe.skipIf(!gates.posixScripts)("...", () => { ... });
|
|
18
|
+
*/
|
|
19
|
+
/** The capabilities a test suite may depend on, honestly measured. */
|
|
20
|
+
export interface PlatformGates {
|
|
21
|
+
/** bubblewrap really works (ordered mount + device smoke already passed). */
|
|
22
|
+
readonly bwrapUsable: boolean;
|
|
23
|
+
/** `prlimit` is present (util-linux; absent on macOS and Windows). */
|
|
24
|
+
readonly prlimitUsable: boolean;
|
|
25
|
+
/** A POSIX `/bin/sh` exists (false on Windows). */
|
|
26
|
+
readonly posixShell: boolean;
|
|
27
|
+
/** `#!/bin/sh` scripts and `sh -c` are real (false on Windows). */
|
|
28
|
+
readonly posixScripts: boolean;
|
|
29
|
+
/** Signal / process-group semantics exist (false on Windows). */
|
|
30
|
+
readonly posixProcessGroups: boolean;
|
|
31
|
+
/** File permission bits are meaningful (false on Windows). */
|
|
32
|
+
readonly fileModesMeaningful: boolean;
|
|
33
|
+
/** The external `htpasswd` binary the login gate shells out to. */
|
|
34
|
+
readonly htpasswdUsable: boolean;
|
|
35
|
+
/** `python3` answers on the host (the Python matrix / broker probes). */
|
|
36
|
+
readonly python3Usable: boolean;
|
|
37
|
+
/** Everything a POSIX-only suite needs at once. */
|
|
38
|
+
readonly posixOnly: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Measure (once per process) what this host can honestly do. */
|
|
41
|
+
export declare function platformGates(): PlatformGates;
|
|
42
|
+
/** `spawnSync(bin, ["--version"])` exits 0 — an honest "the binary runs" test. */
|
|
43
|
+
export declare function whichUsable(bin: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* W885 — cheap, probe-free capability flags for tests that only need the
|
|
46
|
+
* platform truth (no filesystem probe). Prefer [platformGates] when the answer
|
|
47
|
+
* needs the host probe; these two are safe to read at module scope.
|
|
48
|
+
*/
|
|
49
|
+
/** File permission bits are meaningful (false on Windows). */
|
|
50
|
+
export declare const FILE_MODES_MEANINGFUL: boolean;
|
|
51
|
+
/** POSIX signal / process-group semantics exist (false on Windows). */
|
|
52
|
+
export declare const POSIX_PROCESS_GROUPS: boolean;
|
|
53
|
+
/** A POSIX `/bin/sh` exists (false on Windows). */
|
|
54
|
+
export declare const POSIX_SHELL: boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W885 — the shared platform capability gates (W883 §7).
|
|
3
|
+
*
|
|
4
|
+
* The repo already gates OS-specific suites with `describe.skipIf` /
|
|
5
|
+
* `it.skipIf` (`bwrap-live.test.ts`, `auth.test.ts`, `broker.test-util.ts`).
|
|
6
|
+
* This module is the ONE place those predicates live, so a Windows host can run
|
|
7
|
+
* `pnpm test` green with VISIBLE skips instead of a scattering of hardcoded
|
|
8
|
+
* `process.platform` checks — and so a new OS-specific assumption cannot be
|
|
9
|
+
* written as a bare `if (!ready) return` (which the repo counts as a PASS, not a
|
|
10
|
+
* skip).
|
|
11
|
+
*
|
|
12
|
+
* The snapshot is a **memoized function**, not four module-level constants:
|
|
13
|
+
* probeHost() costs two short execFileSyncs, and importing `@celestea/tools`
|
|
14
|
+
* must not pay for them. Call `platformGates()` once at the top of a test file:
|
|
15
|
+
*
|
|
16
|
+
* const gates = platformGates();
|
|
17
|
+
* describe.skipIf(!gates.posixScripts)("...", () => { ... });
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { probeHost } from "../sandbox/probe.js";
|
|
22
|
+
let cached = null;
|
|
23
|
+
/** Measure (once per process) what this host can honestly do. */
|
|
24
|
+
export function platformGates() {
|
|
25
|
+
if (cached !== null)
|
|
26
|
+
return cached;
|
|
27
|
+
const probe = probeHost();
|
|
28
|
+
const posixShell = process.platform !== "win32" && existsSync("/bin/sh");
|
|
29
|
+
const posixProcessGroups = process.platform !== "win32";
|
|
30
|
+
cached = {
|
|
31
|
+
bwrapUsable: probe.bwrapUsable,
|
|
32
|
+
prlimitUsable: probe.prlimitPath !== null,
|
|
33
|
+
posixShell,
|
|
34
|
+
posixScripts: posixShell,
|
|
35
|
+
posixProcessGroups,
|
|
36
|
+
fileModesMeaningful: posixProcessGroups,
|
|
37
|
+
htpasswdUsable: whichUsable("htpasswd"),
|
|
38
|
+
python3Usable: whichUsable("python3"),
|
|
39
|
+
posixOnly: posixShell && posixProcessGroups,
|
|
40
|
+
};
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
/** `spawnSync(bin, ["--version"])` exits 0 — an honest "the binary runs" test. */
|
|
44
|
+
export function whichUsable(bin) {
|
|
45
|
+
try {
|
|
46
|
+
return spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 5_000 }).status === 0;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* W885 — cheap, probe-free capability flags for tests that only need the
|
|
54
|
+
* platform truth (no filesystem probe). Prefer [platformGates] when the answer
|
|
55
|
+
* needs the host probe; these two are safe to read at module scope.
|
|
56
|
+
*/
|
|
57
|
+
/** File permission bits are meaningful (false on Windows). */
|
|
58
|
+
export const FILE_MODES_MEANINGFUL = process.platform !== "win32";
|
|
59
|
+
/** POSIX signal / process-group semantics exist (false on Windows). */
|
|
60
|
+
export const POSIX_PROCESS_GROUPS = process.platform !== "win32";
|
|
61
|
+
/** A POSIX `/bin/sh` exists (false on Windows). */
|
|
62
|
+
export const POSIX_SHELL = process.platform !== "win32" && existsSync("/bin/sh");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ToolFailure` — the single rejection type of a tool executor.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the legacy `Result<Value, String>`: the seam rejects with an `Error`
|
|
5
|
+
* (never a bare string) whose `message` is the structured contract error, so
|
|
6
|
+
* `ToolOutput.error` stays parseable while callers keep a typed handle.
|
|
7
|
+
*/
|
|
8
|
+
export declare class ToolFailure extends Error {
|
|
9
|
+
/** Stable machine-readable code (`schema`, `invalid_arg`, `binary_file`, …). */
|
|
10
|
+
readonly kind: string;
|
|
11
|
+
constructor(kind: string, message: string);
|
|
12
|
+
}
|
|
13
|
+
export declare function isToolFailure(value: unknown): value is ToolFailure;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ToolFailure` — the single rejection type of a tool executor.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the legacy `Result<Value, String>`: the seam rejects with an `Error`
|
|
5
|
+
* (never a bare string) whose `message` is the structured contract error, so
|
|
6
|
+
* `ToolOutput.error` stays parseable while callers keep a typed handle.
|
|
7
|
+
*/
|
|
8
|
+
export class ToolFailure extends Error {
|
|
9
|
+
/** Stable machine-readable code (`schema`, `invalid_arg`, `binary_file`, …). */
|
|
10
|
+
kind;
|
|
11
|
+
constructor(kind, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ToolFailure";
|
|
14
|
+
this.kind = kind;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function isToolFailure(value) {
|
|
18
|
+
return value instanceof ToolFailure;
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_user_question` — the model-facing half of the user-question seam (W783).
|
|
3
|
+
*
|
|
4
|
+
* The schema is copied from the official DSH implementation
|
|
5
|
+
* (`dsh-tool-ask-user/lib/index.js`) so both hosts put the SAME request shape in
|
|
6
|
+
* front of the model; `packages/tools` may only depend on `@celestea/core`
|
|
7
|
+
* (L1), so the service arrives by CONSTRUCTION — exactly like
|
|
8
|
+
* `runShellTool({sandbox, processes})` — and never from a Context.
|
|
9
|
+
*
|
|
10
|
+
* The description carries two things the schema cannot:
|
|
11
|
+
* - the `(Recommended)` convention, localised to 「(推荐)」 (§8);
|
|
12
|
+
* - the TIMEOUT semantics (§6.3). This repo gives the wait a maximum, and the
|
|
13
|
+
* system deliberately does NOT decide for the model when it expires: the
|
|
14
|
+
* result is an empty answer set, and the model is told to carry on with its
|
|
15
|
+
* own judgement instead of asking again.
|
|
16
|
+
*/
|
|
17
|
+
import type { Tool, ToolSpec, UserQuestionService } from "@celestea/core";
|
|
18
|
+
/** What the tool needs: the seam, injected (never resolved from a Context). */
|
|
19
|
+
export interface AskUserToolOptions {
|
|
20
|
+
/** The host's user-question service (`null` = the feature is not mounted). */
|
|
21
|
+
questions: UserQuestionService | null;
|
|
22
|
+
}
|
|
23
|
+
/** The tool description (localised DSH wording + the §6.3 timeout semantics). */
|
|
24
|
+
export declare const ASK_USER_DESCRIPTION: string;
|
|
25
|
+
/** The frozen spec of `ask_user_question`. */
|
|
26
|
+
export declare function askUserSpec(): ToolSpec;
|
|
27
|
+
/**
|
|
28
|
+
* The `ask_user_question` tool. A host that did not mount the service gets a
|
|
29
|
+
* structured failure instead of a silent no-op: the model must be able to tell
|
|
30
|
+
* "nobody can answer here" from "the user answered nothing".
|
|
31
|
+
*/
|
|
32
|
+
export declare function askUserTool(options: AskUserToolOptions): Tool;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_user_question` — the model-facing half of the user-question seam (W783).
|
|
3
|
+
*
|
|
4
|
+
* The schema is copied from the official DSH implementation
|
|
5
|
+
* (`dsh-tool-ask-user/lib/index.js`) so both hosts put the SAME request shape in
|
|
6
|
+
* front of the model; `packages/tools` may only depend on `@celestea/core`
|
|
7
|
+
* (L1), so the service arrives by CONSTRUCTION — exactly like
|
|
8
|
+
* `runShellTool({sandbox, processes})` — and never from a Context.
|
|
9
|
+
*
|
|
10
|
+
* The description carries two things the schema cannot:
|
|
11
|
+
* - the `(Recommended)` convention, localised to 「(推荐)」 (§8);
|
|
12
|
+
* - the TIMEOUT semantics (§6.3). This repo gives the wait a maximum, and the
|
|
13
|
+
* system deliberately does NOT decide for the model when it expires: the
|
|
14
|
+
* result is an empty answer set, and the model is told to carry on with its
|
|
15
|
+
* own judgement instead of asking again.
|
|
16
|
+
*/
|
|
17
|
+
import { descParam } from "../desc.js";
|
|
18
|
+
import { fnTool } from "../fn-tool.js";
|
|
19
|
+
/** The tool description (localised DSH wording + the §6.3 timeout semantics). */
|
|
20
|
+
export const ASK_USER_DESCRIPTION = "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. "
|
|
21
|
+
+ "Send one or more questions, each with a stable id that will be echoed in the answer. The call PAUSES until the "
|
|
22
|
+
+ "user answers, then returns `{answers:[{id,selected,custom}],timed_out:false}` as an ordinary tool result and you "
|
|
23
|
+
+ "continue. If you recommend one option, put it first and append \"(推荐)\" to that label. The user may always "
|
|
24
|
+
+ "type a free-text answer instead of choosing. Waiting is bounded (default 300000 ms, overridable with `timeout_ms`): "
|
|
25
|
+
+ "on expiry the call returns `{answers:[],timed_out:true}` and NO choice is made for you — do not ask the same "
|
|
26
|
+
+ "question again; state the assumption you are proceeding on (or stop and report) and continue with your own judgement.";
|
|
27
|
+
/** The `questions` parameter schema (DSH, field for field). */
|
|
28
|
+
function questionsParam() {
|
|
29
|
+
return {
|
|
30
|
+
type: "array",
|
|
31
|
+
description: "Questions to ask the user before continuing.",
|
|
32
|
+
items: {
|
|
33
|
+
type: "object",
|
|
34
|
+
additionalProperties: true,
|
|
35
|
+
properties: {
|
|
36
|
+
id: { type: "string", description: "Stable id for this question; echoed in the answer." },
|
|
37
|
+
question: { type: "string", description: "The specific question to ask the user." },
|
|
38
|
+
header: { type: "string", description: "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." },
|
|
39
|
+
detail: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Optional supporting detail shown with the question. Kept out of the option labels, so it never takes part in label matching.",
|
|
42
|
+
},
|
|
43
|
+
options: {
|
|
44
|
+
type: "array",
|
|
45
|
+
description: "Optional choices to show the user. If you recommend one, put it first and append \"(推荐)\" to that label.",
|
|
46
|
+
items: {
|
|
47
|
+
type: "object",
|
|
48
|
+
additionalProperties: true,
|
|
49
|
+
properties: {
|
|
50
|
+
label: { type: "string", description: "Short user-facing option label." },
|
|
51
|
+
description: { type: "string", description: "One sentence explaining the tradeoff or impact." },
|
|
52
|
+
},
|
|
53
|
+
required: ["label"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
multi_select: { type: "boolean", description: "Whether the user may select more than one option. Defaults to false." },
|
|
57
|
+
intent: {
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
description: "Optional presentation intent. It changes how a capable UI renders the question, never the answer encoding; a UI that does not know the tag shows the generic option list.",
|
|
61
|
+
properties: {
|
|
62
|
+
kind: { type: "string", enum: ["plan-review"], description: "A plan submitted for review." },
|
|
63
|
+
approve: { type: "string", description: "The option label that approves the plan; every other option declines it." },
|
|
64
|
+
},
|
|
65
|
+
required: ["kind", "approve"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ["id", "question"],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The frozen spec of `ask_user_question`. */
|
|
73
|
+
export function askUserSpec() {
|
|
74
|
+
return {
|
|
75
|
+
name: "ask_user_question",
|
|
76
|
+
description: ASK_USER_DESCRIPTION,
|
|
77
|
+
parameters: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
questions: questionsParam(),
|
|
81
|
+
timeout_ms: {
|
|
82
|
+
type: "integer",
|
|
83
|
+
minimum: 1,
|
|
84
|
+
description: "Optional maximum wait in milliseconds for the whole batch. Default 300000; capped at 3600000. On expiry the call returns an empty answer set with timed_out:true.",
|
|
85
|
+
},
|
|
86
|
+
desc: descParam(),
|
|
87
|
+
},
|
|
88
|
+
required: ["questions"],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Copy one model-supplied question into the seam's shape (optional keys kept absent). */
|
|
94
|
+
function toQuestion(raw) {
|
|
95
|
+
const item = { id: String(raw.id), question: String(raw.question) };
|
|
96
|
+
if (raw.header !== undefined)
|
|
97
|
+
item.header = String(raw.header);
|
|
98
|
+
if (raw.detail !== undefined)
|
|
99
|
+
item.detail = String(raw.detail);
|
|
100
|
+
if (Array.isArray(raw.options))
|
|
101
|
+
item.options = raw.options;
|
|
102
|
+
if (raw.multi_select !== undefined)
|
|
103
|
+
item.multiSelect = raw.multi_select === true;
|
|
104
|
+
if (raw.intent !== undefined)
|
|
105
|
+
item.intent = raw.intent;
|
|
106
|
+
return item;
|
|
107
|
+
}
|
|
108
|
+
/** The model-supplied questions, in order. */
|
|
109
|
+
function questionsOf(args) {
|
|
110
|
+
const list = args.questions;
|
|
111
|
+
if (!Array.isArray(list))
|
|
112
|
+
return [];
|
|
113
|
+
return list.map((raw) => toQuestion(raw));
|
|
114
|
+
}
|
|
115
|
+
/** `timeout_ms` when it is a positive integer, else undefined (the default). */
|
|
116
|
+
function timeoutOf(args) {
|
|
117
|
+
const value = args.timeout_ms;
|
|
118
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The `ask_user_question` tool. A host that did not mount the service gets a
|
|
122
|
+
* structured failure instead of a silent no-op: the model must be able to tell
|
|
123
|
+
* "nobody can answer here" from "the user answered nothing".
|
|
124
|
+
*/
|
|
125
|
+
export function askUserTool(options) {
|
|
126
|
+
const service = options.questions;
|
|
127
|
+
return fnTool(askUserSpec(), async (args) => {
|
|
128
|
+
if (service === null) {
|
|
129
|
+
throw new Error("ask_user_question: code=no_provider msg=\"no user-questions service is mounted in this host\"");
|
|
130
|
+
}
|
|
131
|
+
const requested = timeoutOf(args);
|
|
132
|
+
const outcome = await service.ask({
|
|
133
|
+
questions: questionsOf(args),
|
|
134
|
+
...(requested === undefined ? {} : { timeoutMs: requested }),
|
|
135
|
+
});
|
|
136
|
+
return { answers: outcome.answers.map(copyAnswer), timed_out: outcome.timed_out };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** One answer item, with `custom` omitted rather than written as null. */
|
|
140
|
+
function copyAnswer(answer) {
|
|
141
|
+
const out = { id: answer.id, selected: [...answer.selected] };
|
|
142
|
+
if (answer.custom !== undefined)
|
|
143
|
+
out.custom = answer.custom;
|
|
144
|
+
return out;
|
|
145
|
+
}
|