@exagone313/dsh-podman 0.2.0-rc.2
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/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +17 -0
- package/dist/approval-reasons.d.ts +129 -0
- package/dist/approval-reasons.js +253 -0
- package/dist/approval-reasons.test.d.ts +1 -0
- package/dist/approval-reasons.test.js +115 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.js +412 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +462 -0
- package/dist/card-route.d.ts +15 -0
- package/dist/card-route.js +478 -0
- package/dist/card-route.test.d.ts +1 -0
- package/dist/card-route.test.js +181 -0
- package/dist/card-test-support.d.ts +15 -0
- package/dist/card-test-support.js +160 -0
- package/dist/client/ContainerCard.d.ts +6 -0
- package/dist/client/ContainerCard.js +79 -0
- package/dist/client/card-client.d.ts +6 -0
- package/dist/client/card-client.js +47 -0
- package/dist/client/card-protocol.d.ts +101 -0
- package/dist/client/card-protocol.js +10 -0
- package/dist/client/container-card-caches.d.ts +10 -0
- package/dist/client/container-card-caches.js +31 -0
- package/dist/client/container-card-controller.d.ts +107 -0
- package/dist/client/container-card-controller.js +207 -0
- package/dist/client/container-card-create-modal.d.ts +24 -0
- package/dist/client/container-card-create-modal.js +109 -0
- package/dist/client/container-card-directory-styles.d.ts +5 -0
- package/dist/client/container-card-directory-styles.js +246 -0
- package/dist/client/container-card-directory.d.ts +12 -0
- package/dist/client/container-card-directory.js +144 -0
- package/dist/client/container-card-editors.d.ts +30 -0
- package/dist/client/container-card-editors.js +116 -0
- package/dist/client/container-card-images.d.ts +37 -0
- package/dist/client/container-card-images.js +35 -0
- package/dist/client/container-card-paths.d.ts +20 -0
- package/dist/client/container-card-paths.js +88 -0
- package/dist/client/container-card-row.d.ts +30 -0
- package/dist/client/container-card-row.js +54 -0
- package/dist/client/container-card-secrets.d.ts +21 -0
- package/dist/client/container-card-secrets.js +71 -0
- package/dist/client/container-card-shared.d.ts +55 -0
- package/dist/client/container-card-shared.js +152 -0
- package/dist/client/container-card-styles.d.ts +21 -0
- package/dist/client/container-card-styles.js +143 -0
- package/dist/client/container-card-volumes.d.ts +12 -0
- package/dist/client/container-card-volumes.js +37 -0
- package/dist/client/container-card-workspace.d.ts +31 -0
- package/dist/client/container-card-workspace.js +24 -0
- package/dist/client/directory-picker.d.ts +12 -0
- package/dist/client/directory-picker.js +30 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +4419 -0
- package/dist/client/locales.d.ts +9 -0
- package/dist/client/locales.js +370 -0
- package/dist/client/read-only-approval.d.ts +8 -0
- package/dist/client/read-only-approval.js +63 -0
- package/dist/client/slot-contract.d.ts +12 -0
- package/dist/client/slot-contract.js +4 -0
- package/dist/client/terminal-styles.d.ts +5 -0
- package/dist/client/terminal-styles.js +31 -0
- package/dist/client/tool-views.d.ts +8 -0
- package/dist/client/tool-views.js +241 -0
- package/dist/containers.test.d.ts +1 -0
- package/dist/containers.test.js +443 -0
- package/dist/daemons.test.d.ts +1 -0
- package/dist/daemons.test.js +153 -0
- package/dist/fs-provider.d.ts +23 -0
- package/dist/fs-provider.js +184 -0
- package/dist/fs-provider.test.d.ts +1 -0
- package/dist/fs-provider.test.js +260 -0
- package/dist/generated/version.d.ts +2 -0
- package/dist/generated/version.js +6 -0
- package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
- package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
- package/dist/grpc/runtime-client.d.ts +5 -0
- package/dist/grpc/runtime-client.js +43 -0
- package/dist/guest-rpc.d.ts +62 -0
- package/dist/guest-rpc.js +391 -0
- package/dist/images.test.d.ts +1 -0
- package/dist/images.test.js +79 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +140 -0
- package/dist/misc.test.d.ts +1 -0
- package/dist/misc.test.js +141 -0
- package/dist/mount-enums.d.ts +22 -0
- package/dist/mount-enums.js +81 -0
- package/dist/mount-input.d.ts +14 -0
- package/dist/mount-input.js +64 -0
- package/dist/mounts.test.d.ts +1 -0
- package/dist/mounts.test.js +408 -0
- package/dist/output-reader.d.ts +34 -0
- package/dist/output-reader.js +87 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +74 -0
- package/dist/preferences.d.ts +6 -0
- package/dist/preferences.js +27 -0
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.js +73 -0
- package/dist/project-path.test.d.ts +1 -0
- package/dist/project-path.test.js +52 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +149 -0
- package/dist/public.d.ts +4 -0
- package/dist/public.js +63 -0
- package/dist/read-only-shell.d.ts +14 -0
- package/dist/read-only-shell.js +150 -0
- package/dist/read-only-shell.test.d.ts +1 -0
- package/dist/read-only-shell.test.js +127 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +181 -0
- package/dist/settings-commands.test.d.ts +1 -0
- package/dist/settings-commands.test.js +549 -0
- package/dist/settings-create.test.d.ts +1 -0
- package/dist/settings-create.test.js +394 -0
- package/dist/settings-mounts.test.d.ts +1 -0
- package/dist/settings-mounts.test.js +467 -0
- package/dist/settings-paths.test.d.ts +1 -0
- package/dist/settings-paths.test.js +65 -0
- package/dist/settings-schema.d.ts +14 -0
- package/dist/settings-schema.js +14 -0
- package/dist/settings-workspaces.test.d.ts +1 -0
- package/dist/settings-workspaces.test.js +265 -0
- package/dist/spill-store.d.ts +17 -0
- package/dist/spill-store.js +54 -0
- package/dist/spill-store.test.d.ts +1 -0
- package/dist/spill-store.test.js +59 -0
- package/dist/subprocess.d.ts +9 -0
- package/dist/subprocess.js +369 -0
- package/dist/subprocess.test.d.ts +1 -0
- package/dist/subprocess.test.js +234 -0
- package/dist/test-support.d.ts +328 -0
- package/dist/test-support.js +437 -0
- package/dist/tool-defs.d.ts +26 -0
- package/dist/tool-defs.js +188 -0
- package/dist/tool-handlers.d.ts +2 -0
- package/dist/tool-handlers.js +546 -0
- package/dist/tool-params.d.ts +803 -0
- package/dist/tool-params.js +494 -0
- package/dist/tool-schemas.d.ts +2 -0
- package/dist/tool-schemas.js +5 -0
- package/dist/tool-views.d.ts +2 -0
- package/dist/tool-views.js +56 -0
- package/dist/views.test.d.ts +1 -0
- package/dist/views.test.js +23 -0
- package/dist/volumes.test.d.ts +1 -0
- package/dist/volumes.test.js +40 -0
- package/dist/workspace-binding.d.ts +40 -0
- package/dist/workspace-binding.js +275 -0
- package/dist/workspace-binding.test.d.ts +1 -0
- package/dist/workspace-binding.test.js +337 -0
- package/docs/architecture.md +173 -0
- package/docs/architecture.zh.md +129 -0
- package/docs/configuration.md +160 -0
- package/docs/configuration.zh.md +147 -0
- package/docs/development.md +142 -0
- package/docs/development.zh.md +135 -0
- package/docs/install-dsh-and-dsh-podman.md +165 -0
- package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
- package/docs/usage.md +393 -0
- package/docs/usage.zh.md +315 -0
- package/package.json +119 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const name = "podman";
|
|
2
|
+
export declare const inject: string[];
|
|
3
|
+
export interface PluginConfig {
|
|
4
|
+
socketsRoot?: string;
|
|
5
|
+
defaultImage?: string;
|
|
6
|
+
projectsRoot?: string;
|
|
7
|
+
controlToken?: string;
|
|
8
|
+
imagePrefix?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function apply(ctx: any, config?: PluginConfig): void;
|
|
11
|
+
export { PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, approvalDecision, isSandboxEscalation, mountDestinationsReason, preExecutePolicy, summarizeArgs, } from "./approval.js";
|
|
12
|
+
export type { SessionFacts } from "./approval.js";
|
|
13
|
+
export { READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, createReadOnlyShellGate, } from "./read-only-shell.js";
|
|
14
|
+
export { FilesystemProvider, createFilesystemProvider } from "./fs-provider.js";
|
|
15
|
+
export { resolveGuestCwd, resolveGuestPath, remoteArgv, globCwd } from "./guest-rpc.js";
|
|
16
|
+
export { inferMountKind, projectMountDestinationReason, projectMountMirror, } from "./mount-input.js";
|
|
17
|
+
export { HARNESS_SOURCE_SECTION, PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, ensurePodmanOpsPreset, homePresetsRoot, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
|
|
18
|
+
export { publicContainer, publicDaemon, publicImage, publicMount } from "./public.js";
|
|
19
|
+
export { OutputReader, SubprocessProvider, createSubprocessProvider, outputReader, } from "./subprocess.js";
|
|
20
|
+
export { toolHandlers } from "./tool-handlers.js";
|
|
21
|
+
export { TOOLS, ToolDefinition, containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, toolOutput, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
|
|
22
|
+
export { toolCallView, toolResultView } from "./tool-views.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import { isSandboxEscalation, preExecutePolicy } from "./approval.js";
|
|
5
|
+
import { resolveReasonLocale } from "./approval-reasons.js";
|
|
6
|
+
import { createFilesystemProvider } from "./fs-provider.js";
|
|
7
|
+
import { createSpillStore } from "./spill-store.js";
|
|
8
|
+
import { ensurePodmanOpsPreset, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
|
|
9
|
+
import { createSubprocessProvider } from "./subprocess.js";
|
|
10
|
+
import { createReadOnlyShellGate, REMOUNT_TOOL_NAME } from "./read-only-shell.js";
|
|
11
|
+
import { toolHandlers } from "./tool-handlers.js";
|
|
12
|
+
import { TOOLS, TOOL_DESCRIPTIONS, defineTool, toolOutput } from "./tool-schemas.js";
|
|
13
|
+
import { toolCallView, toolResultView } from "./tool-views.js";
|
|
14
|
+
import { installContainerPreferences } from "./preferences.js";
|
|
15
|
+
import { registerCardRoute } from "./card-route.js";
|
|
16
|
+
import { WorkspaceResolver, normalizeToolError } from "./workspace-binding.js";
|
|
17
|
+
function withTrailingSlash(value) {
|
|
18
|
+
return value.endsWith("/") ? value : `${value}/`;
|
|
19
|
+
}
|
|
20
|
+
export const name = "podman";
|
|
21
|
+
export const inject = ["tools", "workspaceRegistry"];
|
|
22
|
+
export function apply(ctx, config = {}) {
|
|
23
|
+
// Approval text follows the session language: the browser client records its
|
|
24
|
+
// active locale in the plugin namespace, with the durable user preference as
|
|
25
|
+
// the fallback. Without a settings provider everything renders in English.
|
|
26
|
+
let readLocale = () => "en";
|
|
27
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
28
|
+
readLocale = () => resolveReasonLocale(settingsCtx.settings);
|
|
29
|
+
});
|
|
30
|
+
// The session's permission knobs live in the harness's own services: the
|
|
31
|
+
// sandbox policy resolves the effective mode (approved override, last logged
|
|
32
|
+
// mode, then the deployment default), the approval service reports the logged
|
|
33
|
+
// policy override, and the agent-preset projection reports the preset the
|
|
34
|
+
// session currently runs (its header only names the creation-time one).
|
|
35
|
+
const readSession = (session) => ({
|
|
36
|
+
mode: ctx.get("sandboxPolicy")?.resolve({ session })?.mode,
|
|
37
|
+
// The deployment default is private, so an unset override conservatively
|
|
38
|
+
// keeps asking.
|
|
39
|
+
policy: ctx.get("approval")?.overrideOf(session) ?? "ask",
|
|
40
|
+
preset: ctx.get("sessionProjections")?.stateOf(session, "agentPreset") ??
|
|
41
|
+
session?.header?.agentPreset,
|
|
42
|
+
});
|
|
43
|
+
// The read-only shell gate needs the resolver, which is created below, so it
|
|
44
|
+
// is built on first use. Under read-only permission it re-checks the target
|
|
45
|
+
// container's mounts and, when a read-write mount would block the tool, asks
|
|
46
|
+
// the user (through the approval service) to remount them read-only.
|
|
47
|
+
let readOnlyShell;
|
|
48
|
+
const readOnlyShellGate = () => (readOnlyShell ??= createReadOnlyShellGate({
|
|
49
|
+
resolver,
|
|
50
|
+
approve: async (exec, reason) => {
|
|
51
|
+
const approval = ctx.get("approval");
|
|
52
|
+
if (approval === undefined || exec?.agent === undefined)
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
const outcome = await approval.request({
|
|
56
|
+
agent: exec.agent,
|
|
57
|
+
toolName: REMOUNT_TOOL_NAME,
|
|
58
|
+
...(exec.callId !== undefined ? { callId: exec.callId } : {}),
|
|
59
|
+
...(exec.signal !== undefined ? { signal: exec.signal } : {}),
|
|
60
|
+
reason,
|
|
61
|
+
});
|
|
62
|
+
return outcome === "allowed-once";
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// A request that cannot be raised (no open turn, no channel) fails
|
|
66
|
+
// closed, exactly like a rejection.
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
ctx.on("tools/pre-execute", (exec, next) => preExecutePolicy(exec, next, () => resolver.getConfig().projectsRoot, () => readLocale(), readSession, readOnlyShellGate()));
|
|
72
|
+
// The harness asks the user to widen the sandbox before a confined call (for
|
|
73
|
+
// example `bash` with `sandbox_permissions`). This deployment bypasses the
|
|
74
|
+
// sandbox inside the container, so the grant changes nothing: claim the
|
|
75
|
+
// request so a stray `sandbox_permissions` argument raises no prompt. The
|
|
76
|
+
// listener is prepended so it runs before the bridge that forwards approvals
|
|
77
|
+
// to the browser, and it delegates everything else.
|
|
78
|
+
ctx.on("approval/request", (request, next) => isSandboxEscalation(request) ? Promise.resolve("allowed-once") : next(), { prepend: true });
|
|
79
|
+
ensurePodmanOpsPreset(ctx);
|
|
80
|
+
const imagePrefix = withTrailingSlash(config.imagePrefix ?? process.env.DSH_PODMAN_IMAGE_PREFIX ?? "localhost/dsh-podman/");
|
|
81
|
+
const resolver = new WorkspaceResolver({
|
|
82
|
+
socketsRoot: config.socketsRoot ??
|
|
83
|
+
process.env.DSH_PODMAN_SOCKETS_ROOT ??
|
|
84
|
+
"/run/dsh-podman",
|
|
85
|
+
defaultImage: config.defaultImage ?? "archlinux",
|
|
86
|
+
projectsRoot: config.projectsRoot ??
|
|
87
|
+
process.env.DSH_PODMAN_PROJECTS_ROOT ??
|
|
88
|
+
"/projects",
|
|
89
|
+
controlToken: config.controlToken ?? process.env.DSH_PODMAN_ORCHESTRATOR_TOKEN ?? "",
|
|
90
|
+
}, ctx.workspaceRegistry);
|
|
91
|
+
ctx.provide("workspaceResolver", resolver);
|
|
92
|
+
const subprocess = createSubprocessProvider(resolver);
|
|
93
|
+
ctx.provide("subprocess", subprocess);
|
|
94
|
+
ctx.effect(() => () => subprocess.dispose(), "podman: subprocess cleanup");
|
|
95
|
+
ctx.provide("fs", createFilesystemProvider(resolver));
|
|
96
|
+
// Oversized tool results spill inside the session's container, so the agent
|
|
97
|
+
// can read them back with the container file tools; the harness's local
|
|
98
|
+
// (host-filesystem) spill backend is disabled by the bundle patch.
|
|
99
|
+
ctx.provide("spillStore", createSpillStore(ctx, resolver));
|
|
100
|
+
ctx.inject(["systemPrompt"], (promptCtx) => {
|
|
101
|
+
promptCtx.systemPrompt.section(podmanRuntimeSection(promptCtx));
|
|
102
|
+
promptCtx.on("system-prompt/assemble", async (_assembly, _context, next) => withoutHarnessSourceSection(await next()), { global: true, prepend: true });
|
|
103
|
+
});
|
|
104
|
+
registerTools(ctx, resolver);
|
|
105
|
+
installContainerPreferences(ctx, resolver);
|
|
106
|
+
registerCardRoute(ctx, resolver, ctx.workspaceRegistry);
|
|
107
|
+
}
|
|
108
|
+
function registerTools(ctx, resolver) {
|
|
109
|
+
for (const tool of TOOLS) {
|
|
110
|
+
const handler = toolHandlers[tool.name];
|
|
111
|
+
ctx.tools.register(defineTool({
|
|
112
|
+
name: tool.name,
|
|
113
|
+
description: TOOL_DESCRIPTIONS[tool.name],
|
|
114
|
+
parameters: tool.parameters,
|
|
115
|
+
...(tool.approval ? { approval: true } : {}),
|
|
116
|
+
presentCall: (args) => toolCallView(tool.name, args),
|
|
117
|
+
presentResult: (args, result) => toolResultView(tool.name, args, result),
|
|
118
|
+
output: toolOutput,
|
|
119
|
+
execute: async (input, exec) => {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.stringify(await handler(resolver, input, exec, ctx));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw normalizeToolError(error);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export { PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, approvalDecision, isSandboxEscalation, mountDestinationsReason, preExecutePolicy, summarizeArgs, } from "./approval.js";
|
|
131
|
+
export { READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, createReadOnlyShellGate, } from "./read-only-shell.js";
|
|
132
|
+
export { createFilesystemProvider } from "./fs-provider.js";
|
|
133
|
+
export { resolveGuestCwd, resolveGuestPath, remoteArgv, globCwd } from "./guest-rpc.js";
|
|
134
|
+
export { inferMountKind, projectMountDestinationReason, projectMountMirror, } from "./mount-input.js";
|
|
135
|
+
export { HARNESS_SOURCE_SECTION, PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, ensurePodmanOpsPreset, homePresetsRoot, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
|
|
136
|
+
export { publicContainer, publicDaemon, publicImage, publicMount } from "./public.js";
|
|
137
|
+
export { createSubprocessProvider, outputReader, } from "./subprocess.js";
|
|
138
|
+
export { toolHandlers } from "./tool-handlers.js";
|
|
139
|
+
export { TOOLS, containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, toolOutput, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
|
|
140
|
+
export { toolCallView, toolResultView } from "./tool-views.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { fakeToolContext, guestExecRecorder, guestFileRecorder } from "./test-support.js";
|
|
7
|
+
import { HARNESS_SOURCE_SECTION, TOOLS, toolCallView, toolHandlers, toolResultView, withoutHarnessSourceSection, } from "./index.js";
|
|
8
|
+
test("every tool parameters is a valid JSON-Schema object", () => {
|
|
9
|
+
for (const tool of TOOLS) {
|
|
10
|
+
assert.equal(tool.parameters.type, "object", `${tool.name} type`);
|
|
11
|
+
assert.equal(typeof tool.parameters.properties, "object");
|
|
12
|
+
assert.ok(Array.isArray(tool.parameters.required));
|
|
13
|
+
for (const key of Object.keys(tool.parameters.properties)) {
|
|
14
|
+
const property = tool.parameters.properties[key];
|
|
15
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(property, "required"), `${tool.name}.${key} must not carry per-property required`);
|
|
16
|
+
assert.ok(!Array.isArray(property.required), `${tool.name}.${key} must not carry a required array`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
test("file tools resolve relative paths against the session cwd", async () => {
|
|
21
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
22
|
+
const read = guestFileRecorder();
|
|
23
|
+
await toolHandlers.container_read(read.resolver, { container: "default", file_path: "README.md" }, exec, fakeToolContext(read.resolver));
|
|
24
|
+
assert.deepEqual(read.reads, ["/projects/team/README.md"]);
|
|
25
|
+
const absolute = guestFileRecorder();
|
|
26
|
+
await toolHandlers.container_read(absolute.resolver, { container: "default", file_path: "/etc/hosts" }, exec, fakeToolContext(absolute.resolver));
|
|
27
|
+
assert.deepEqual(absolute.reads, ["/etc/hosts"], "absolute paths pass through");
|
|
28
|
+
const write = guestFileRecorder();
|
|
29
|
+
await toolHandlers.container_write(write.resolver, { container: "default", file_path: "out.txt", content: "x" }, exec, fakeToolContext(write.resolver));
|
|
30
|
+
assert.deepEqual(write.writes.map((entry) => entry.path), ["/projects/team/out.txt"]);
|
|
31
|
+
const edit = guestFileRecorder("hello");
|
|
32
|
+
await toolHandlers.container_edit(edit.resolver, { container: "default", file_path: "a.txt", old_string: "hello", new_string: "bye" }, exec, fakeToolContext(edit.resolver));
|
|
33
|
+
assert.deepEqual(edit.reads, ["/projects/team/a.txt"]);
|
|
34
|
+
assert.deepEqual(edit.writes.map((entry) => entry.path), ["/projects/team/a.txt"]);
|
|
35
|
+
});
|
|
36
|
+
test("file tools refuse traversal before reaching the guest", async () => {
|
|
37
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
38
|
+
const { reads, writes, resolver } = guestFileRecorder();
|
|
39
|
+
for (const path of ["../escape", "a/../../b"]) {
|
|
40
|
+
await assert.rejects(() => toolHandlers.container_read(resolver, { container: "default", file_path: path }, exec, fakeToolContext(resolver)), /must not escape/);
|
|
41
|
+
}
|
|
42
|
+
await assert.rejects(() => toolHandlers.container_write(resolver, { container: "default", file_path: "../escape", content: "x" }, exec, fakeToolContext(resolver)), /must not escape/);
|
|
43
|
+
assert.deepEqual(reads, [], "a refused path must not reach the guest");
|
|
44
|
+
assert.deepEqual(writes, []);
|
|
45
|
+
});
|
|
46
|
+
test("tool presenters label every tool and render terminal commands", () => {
|
|
47
|
+
for (const tool of TOOLS) {
|
|
48
|
+
assert.notEqual(toolCallView(tool.name, {}), undefined, `${tool.name} has a call view`);
|
|
49
|
+
}
|
|
50
|
+
assert.deepEqual(toolCallView("container_bash", { command: "ls", description: "List files" }), { card: "terminal", title: "ls", description: "List files" });
|
|
51
|
+
assert.deepEqual(toolCallView("container_exec", { argv: ["ls", "-la"], description: "List all" }), { card: "terminal", title: "ls -la", description: "List all" });
|
|
52
|
+
assert.deepEqual(toolCallView("image_list", {}), { card: "generic", title: "List images", kind: "search" });
|
|
53
|
+
assert.deepEqual(toolCallView("container_read", {}), { card: "generic", title: "Container read", kind: "read" });
|
|
54
|
+
assert.deepEqual(toolCallView("container_write", {}), { card: "generic", title: "Container write", kind: "edit" });
|
|
55
|
+
assert.deepEqual(toolCallView("container_edit", {}), { card: "generic", title: "Container edit", kind: "edit" });
|
|
56
|
+
assert.deepEqual(toolCallView("container_glob", {}), { card: "generic", title: "Container glob", kind: "search" });
|
|
57
|
+
assert.deepEqual(toolCallView("container_grep", {}), { card: "generic", title: "Container grep", kind: "search" });
|
|
58
|
+
});
|
|
59
|
+
test("tool presenters render command results as terminal output", () => {
|
|
60
|
+
const result = (value) => ({
|
|
61
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
62
|
+
isError: false,
|
|
63
|
+
});
|
|
64
|
+
assert.deepEqual(toolResultView("container_bash", {}, result({ exitCode: 0, signal: null, stdout: "hi\n", stderr: "" })), { card: "terminal", output: "hi\n", exitCode: 0 });
|
|
65
|
+
assert.deepEqual(toolResultView("container_bash", {}, result({ exitCode: 0, signal: "SIGTERM", stdout: "", stderr: "" })), { card: "terminal", output: "", signal: "SIGTERM" });
|
|
66
|
+
assert.equal(toolResultView("container_bash", {}, { content: [{ type: "text", text: "boom" }], isError: true }), undefined);
|
|
67
|
+
assert.equal(toolResultView("image_list", {}, result({})), undefined);
|
|
68
|
+
});
|
|
69
|
+
test("command tools resolve a relative working directory", async () => {
|
|
70
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
71
|
+
const bash = guestExecRecorder("/projects/team");
|
|
72
|
+
await toolHandlers.container_bash(bash.resolver, { container: "default", command: "ls", workdir: "sub" }, exec);
|
|
73
|
+
assert.equal(bash.starts[0].cwd, "/projects/team/sub");
|
|
74
|
+
const run = guestExecRecorder("/projects/team");
|
|
75
|
+
await toolHandlers.container_exec(run.resolver, { container: "default", argv: ["ls"], workdir: "/abs" }, exec);
|
|
76
|
+
assert.equal(run.starts[0].cwd, "/abs", "absolute working directories pass through");
|
|
77
|
+
// An unset working directory defaults to the session's (when mounted).
|
|
78
|
+
const bare = guestExecRecorder("/projects/team");
|
|
79
|
+
await toolHandlers.container_exec(bare.resolver, { container: "default", argv: ["ls"] }, exec);
|
|
80
|
+
assert.equal(bare.starts[0].cwd, "/projects/team");
|
|
81
|
+
// Commands are not confined to the projects root, so ".." is allowed.
|
|
82
|
+
const up = guestExecRecorder("/projects/team");
|
|
83
|
+
await toolHandlers.container_bash(up.resolver, { container: "default", command: "ls", workdir: "../sibling" }, exec);
|
|
84
|
+
assert.equal(up.starts[0].cwd, "/projects/sibling");
|
|
85
|
+
});
|
|
86
|
+
test("command tools pass an optional uid, gid, and groups", async () => {
|
|
87
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
88
|
+
const bash = guestExecRecorder("/projects/team");
|
|
89
|
+
await toolHandlers.container_bash(bash.resolver, { container: "default", command: "id", uid: 1000 }, exec);
|
|
90
|
+
// The managed environment is asserted separately; this pins the identity.
|
|
91
|
+
const { env: _bashEnv, ...bashStart } = bash.starts[0];
|
|
92
|
+
assert.deepEqual(bashStart, { argv: ["bash", "-c", "id"], cwd: "/projects/team", uid: 1000 });
|
|
93
|
+
const run = guestExecRecorder("/projects/team");
|
|
94
|
+
await toolHandlers.container_exec(run.resolver, { container: "default", argv: ["id"], uid: 1000, gid: 2000, groups: [3000, 4000] }, exec);
|
|
95
|
+
const { env: _runEnv, ...runStart } = run.starts[0];
|
|
96
|
+
assert.deepEqual(runStart, {
|
|
97
|
+
argv: ["id"],
|
|
98
|
+
cwd: "/projects/team",
|
|
99
|
+
uid: 1000,
|
|
100
|
+
gid: 2000,
|
|
101
|
+
groups: [3000, 4000],
|
|
102
|
+
});
|
|
103
|
+
// No identity leaves the command as the container's default user.
|
|
104
|
+
const none = guestExecRecorder("/projects/team");
|
|
105
|
+
await toolHandlers.container_exec(none.resolver, { container: "default", argv: ["id"] }, exec);
|
|
106
|
+
assert.equal("uid" in none.starts[0], false);
|
|
107
|
+
assert.equal("groups" in none.starts[0], false);
|
|
108
|
+
for (const bad of [
|
|
109
|
+
{ uid: -1 },
|
|
110
|
+
{ gid: -1 },
|
|
111
|
+
{ groups: [1, -2] },
|
|
112
|
+
{ groups: "1" },
|
|
113
|
+
{ uid: 1.5 },
|
|
114
|
+
]) {
|
|
115
|
+
await assert.rejects(toolHandlers.container_exec(guestExecRecorder("/projects/team").resolver, { container: "default", argv: ["id"], ...bad }, exec));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
test("withoutHarnessSourceSection drops only the harness checkout section", () => {
|
|
119
|
+
const assembly = {
|
|
120
|
+
sections: [
|
|
121
|
+
{ name: "harness:identity", text: "identity" },
|
|
122
|
+
{ name: HARNESS_SOURCE_SECTION, text: "checkout at /src" },
|
|
123
|
+
{ name: "deployment:persona", text: "persona" },
|
|
124
|
+
],
|
|
125
|
+
contexts: [{ name: "c", text: "t" }],
|
|
126
|
+
tools: [{ name: "t" }],
|
|
127
|
+
variables: { model: "m" },
|
|
128
|
+
};
|
|
129
|
+
const result = withoutHarnessSourceSection(assembly);
|
|
130
|
+
assert.deepEqual(result.sections.map((section) => section.name), ["harness:identity", "deployment:persona"]);
|
|
131
|
+
assert.deepEqual(result.contexts, assembly.contexts);
|
|
132
|
+
assert.deepEqual(result.tools, assembly.tools);
|
|
133
|
+
assert.deepEqual(result.variables, assembly.variables);
|
|
134
|
+
// The input assembly is not mutated.
|
|
135
|
+
assert.equal(assembly.sections.length, 3);
|
|
136
|
+
});
|
|
137
|
+
test("withoutHarnessSourceSection leaves an assembly without the section intact", () => {
|
|
138
|
+
const assembly = { sections: [{ name: "harness:identity", text: "i" }] };
|
|
139
|
+
const result = withoutHarnessSourceSection(assembly);
|
|
140
|
+
assert.deepEqual(result, assembly);
|
|
141
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare function mountKindToProto(kind: string | undefined): string;
|
|
2
|
+
/**
|
|
3
|
+
* The mount mode to use when a caller does not name one.
|
|
4
|
+
*
|
|
5
|
+
* Adding a mount should not grant write access nobody asked for, so every kind
|
|
6
|
+
* defaults to read-only. tmpfs is the exception: its contents are per-container
|
|
7
|
+
* scratch space and the orchestrator rejects a read-only one outright.
|
|
8
|
+
*/
|
|
9
|
+
export declare function defaultMountMode(kind: string | undefined): string;
|
|
10
|
+
/**
|
|
11
|
+
* The mode a mount kind is fixed to, or undefined when the caller chooses.
|
|
12
|
+
*
|
|
13
|
+
* A tmpfs is scratch space and the orchestrator rejects a read-only one; a
|
|
14
|
+
* secret is exposed read-only and carries no mode at all. Project and volume
|
|
15
|
+
* mounts are the ones a caller may switch.
|
|
16
|
+
*/
|
|
17
|
+
export declare function forcedMountMode(kind: string | undefined): string | undefined;
|
|
18
|
+
export declare function mountModeToProto(mode: string | undefined): string;
|
|
19
|
+
/** The logical mount kind for a proto enum value, or undefined when unspecified. */
|
|
20
|
+
export declare function mountKindFromProto(proto: string | undefined): string | undefined;
|
|
21
|
+
/** The logical mount mode for a proto enum value, or undefined when unspecified. */
|
|
22
|
+
export declare function mountModeFromProto(proto: string | undefined): string | undefined;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
const MOUNT_KINDS = {
|
|
5
|
+
project: "MOUNT_KIND_PROJECT",
|
|
6
|
+
tmpfs: "MOUNT_KIND_TMPFS",
|
|
7
|
+
volume: "MOUNT_KIND_VOLUME",
|
|
8
|
+
secret: "MOUNT_KIND_SECRET",
|
|
9
|
+
};
|
|
10
|
+
const MOUNT_MODES = {
|
|
11
|
+
read_only: "MOUNT_MODE_READ_ONLY",
|
|
12
|
+
read_write: "MOUNT_MODE_READ_WRITE",
|
|
13
|
+
};
|
|
14
|
+
const KIND_FROM_PROTO = {
|
|
15
|
+
MOUNT_KIND_PROJECT: "project",
|
|
16
|
+
MOUNT_KIND_TMPFS: "tmpfs",
|
|
17
|
+
MOUNT_KIND_VOLUME: "volume",
|
|
18
|
+
MOUNT_KIND_SECRET: "secret",
|
|
19
|
+
};
|
|
20
|
+
const MODE_FROM_PROTO = {
|
|
21
|
+
MOUNT_MODE_READ_ONLY: "read_only",
|
|
22
|
+
MOUNT_MODE_READ_WRITE: "read_write",
|
|
23
|
+
};
|
|
24
|
+
export function mountKindToProto(kind) {
|
|
25
|
+
if (kind === undefined)
|
|
26
|
+
return "MOUNT_KIND_PROJECT";
|
|
27
|
+
const proto = Object.prototype.hasOwnProperty.call(MOUNT_KINDS, kind)
|
|
28
|
+
? MOUNT_KINDS[kind]
|
|
29
|
+
: undefined;
|
|
30
|
+
if (proto === undefined)
|
|
31
|
+
throw new Error(`unknown mount kind: ${kind}`);
|
|
32
|
+
return proto;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The mount mode to use when a caller does not name one.
|
|
36
|
+
*
|
|
37
|
+
* Adding a mount should not grant write access nobody asked for, so every kind
|
|
38
|
+
* defaults to read-only. tmpfs is the exception: its contents are per-container
|
|
39
|
+
* scratch space and the orchestrator rejects a read-only one outright.
|
|
40
|
+
*/
|
|
41
|
+
export function defaultMountMode(kind) {
|
|
42
|
+
return kind === "tmpfs" ? "read_write" : "read_only";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The mode a mount kind is fixed to, or undefined when the caller chooses.
|
|
46
|
+
*
|
|
47
|
+
* A tmpfs is scratch space and the orchestrator rejects a read-only one; a
|
|
48
|
+
* secret is exposed read-only and carries no mode at all. Project and volume
|
|
49
|
+
* mounts are the ones a caller may switch.
|
|
50
|
+
*/
|
|
51
|
+
export function forcedMountMode(kind) {
|
|
52
|
+
if (kind === "tmpfs")
|
|
53
|
+
return "read_write";
|
|
54
|
+
if (kind === "secret")
|
|
55
|
+
return "read_only";
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
export function mountModeToProto(mode) {
|
|
59
|
+
const proto = mode !== undefined && Object.prototype.hasOwnProperty.call(MOUNT_MODES, mode)
|
|
60
|
+
? MOUNT_MODES[mode]
|
|
61
|
+
: undefined;
|
|
62
|
+
if (proto === undefined)
|
|
63
|
+
throw new Error(`unknown mount mode: ${mode}`);
|
|
64
|
+
return proto;
|
|
65
|
+
}
|
|
66
|
+
/** The logical mount kind for a proto enum value, or undefined when unspecified. */
|
|
67
|
+
export function mountKindFromProto(proto) {
|
|
68
|
+
if (proto === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
return Object.prototype.hasOwnProperty.call(KIND_FROM_PROTO, proto)
|
|
71
|
+
? KIND_FROM_PROTO[proto]
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
/** The logical mount mode for a proto enum value, or undefined when unspecified. */
|
|
75
|
+
export function mountModeFromProto(proto) {
|
|
76
|
+
if (proto === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
return Object.prototype.hasOwnProperty.call(MODE_FROM_PROTO, proto)
|
|
79
|
+
? MODE_FROM_PROTO[proto]
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReasonLocale } from "./approval-reasons.js";
|
|
2
|
+
export declare function inferMountKind(args: {
|
|
3
|
+
kind?: unknown;
|
|
4
|
+
project?: unknown;
|
|
5
|
+
volume?: unknown;
|
|
6
|
+
secret?: unknown;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function projectMountMirror(projectsRoot: string, project: string): string;
|
|
9
|
+
export declare function projectMountDestinationReason(projectsRoot: string | undefined, args: {
|
|
10
|
+
kind?: unknown;
|
|
11
|
+
project?: unknown;
|
|
12
|
+
destination?: unknown;
|
|
13
|
+
}, locale?: ReasonLocale): string | undefined;
|
|
14
|
+
export declare function mountsFromInput(mounts: unknown, projectsRoot: string | undefined): Record<string, unknown>[] | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import { defaultMountMode, mountKindToProto, mountModeToProto } from "./mount-enums.js";
|
|
5
|
+
import { renderDenial } from "./approval-reasons.js";
|
|
6
|
+
// The mount kind for a call: an explicit non-empty `kind` wins, otherwise it is
|
|
7
|
+
// inferred from the source field the caller supplied (`secret`/`volume`), since
|
|
8
|
+
// `kind` is optional in the mount schemas. Falls back to a project mount.
|
|
9
|
+
export function inferMountKind(args) {
|
|
10
|
+
if (typeof args.kind === "string" && args.kind !== "")
|
|
11
|
+
return args.kind;
|
|
12
|
+
if (typeof args.secret === "string" && args.secret !== "")
|
|
13
|
+
return "secret";
|
|
14
|
+
if (typeof args.volume === "string" && args.volume !== "")
|
|
15
|
+
return "volume";
|
|
16
|
+
return "project";
|
|
17
|
+
}
|
|
18
|
+
// The container-side destination a project mount always lands at: the project
|
|
19
|
+
// path under projectsRoot.
|
|
20
|
+
export function projectMountMirror(projectsRoot, project) {
|
|
21
|
+
return [projectsRoot, project].filter((part) => part !== "").join("/");
|
|
22
|
+
}
|
|
23
|
+
// The reason to deny a mount item that carries a destination on a project (or
|
|
24
|
+
// unnamed) mount, or undefined when the item is allowed. Project mounts never
|
|
25
|
+
// take a destination; without one the directory lands at its project root.
|
|
26
|
+
export function projectMountDestinationReason(projectsRoot, args, locale = "en") {
|
|
27
|
+
if (inferMountKind(args) !== "project")
|
|
28
|
+
return undefined;
|
|
29
|
+
if (args.destination === undefined || args.destination === "")
|
|
30
|
+
return undefined;
|
|
31
|
+
const project = typeof args.project === "string" ? args.project : "";
|
|
32
|
+
const mirror = projectsRoot === undefined
|
|
33
|
+
? ""
|
|
34
|
+
: projectMountMirror(projectsRoot, project);
|
|
35
|
+
return renderDenial(locale, {
|
|
36
|
+
kind: "project_destination",
|
|
37
|
+
...(project === "" ? {} : { source: project }),
|
|
38
|
+
...(mirror === "" ? {} : { mirror }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function mountsFromInput(mounts, projectsRoot) {
|
|
42
|
+
if (!Array.isArray(mounts) || mounts.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
return mounts.map((mount) => {
|
|
45
|
+
const inferredKind = inferMountKind(mount);
|
|
46
|
+
const destinationReason = projectMountDestinationReason(projectsRoot, mount);
|
|
47
|
+
if (destinationReason !== undefined)
|
|
48
|
+
throw new Error(destinationReason);
|
|
49
|
+
if (inferredKind === "secret" && mount.mode === "read_write") {
|
|
50
|
+
throw new Error("secret mounts are read-only; omit mode or use read_only");
|
|
51
|
+
}
|
|
52
|
+
const kind = mountKindToProto(inferredKind);
|
|
53
|
+
const mode = mountModeToProto(mount.mode ?? defaultMountMode(inferredKind));
|
|
54
|
+
const result = { projectName: mount.project ?? "", kind, mode };
|
|
55
|
+
if (inferredKind !== "project" && mount.destination) {
|
|
56
|
+
result.destination = mount.destination;
|
|
57
|
+
}
|
|
58
|
+
if (mount.volume)
|
|
59
|
+
result.volume = mount.volume;
|
|
60
|
+
if (mount.secret)
|
|
61
|
+
result.secret = mount.secret;
|
|
62
|
+
return result;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|