@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.
Files changed (165) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/README.zh.md +41 -0
  4. package/cordis.patch.yml +17 -0
  5. package/dist/approval-reasons.d.ts +129 -0
  6. package/dist/approval-reasons.js +253 -0
  7. package/dist/approval-reasons.test.d.ts +1 -0
  8. package/dist/approval-reasons.test.js +115 -0
  9. package/dist/approval.d.ts +29 -0
  10. package/dist/approval.js +412 -0
  11. package/dist/approval.test.d.ts +1 -0
  12. package/dist/approval.test.js +462 -0
  13. package/dist/card-route.d.ts +15 -0
  14. package/dist/card-route.js +478 -0
  15. package/dist/card-route.test.d.ts +1 -0
  16. package/dist/card-route.test.js +181 -0
  17. package/dist/card-test-support.d.ts +15 -0
  18. package/dist/card-test-support.js +160 -0
  19. package/dist/client/ContainerCard.d.ts +6 -0
  20. package/dist/client/ContainerCard.js +79 -0
  21. package/dist/client/card-client.d.ts +6 -0
  22. package/dist/client/card-client.js +47 -0
  23. package/dist/client/card-protocol.d.ts +101 -0
  24. package/dist/client/card-protocol.js +10 -0
  25. package/dist/client/container-card-caches.d.ts +10 -0
  26. package/dist/client/container-card-caches.js +31 -0
  27. package/dist/client/container-card-controller.d.ts +107 -0
  28. package/dist/client/container-card-controller.js +207 -0
  29. package/dist/client/container-card-create-modal.d.ts +24 -0
  30. package/dist/client/container-card-create-modal.js +109 -0
  31. package/dist/client/container-card-directory-styles.d.ts +5 -0
  32. package/dist/client/container-card-directory-styles.js +246 -0
  33. package/dist/client/container-card-directory.d.ts +12 -0
  34. package/dist/client/container-card-directory.js +144 -0
  35. package/dist/client/container-card-editors.d.ts +30 -0
  36. package/dist/client/container-card-editors.js +116 -0
  37. package/dist/client/container-card-images.d.ts +37 -0
  38. package/dist/client/container-card-images.js +35 -0
  39. package/dist/client/container-card-paths.d.ts +20 -0
  40. package/dist/client/container-card-paths.js +88 -0
  41. package/dist/client/container-card-row.d.ts +30 -0
  42. package/dist/client/container-card-row.js +54 -0
  43. package/dist/client/container-card-secrets.d.ts +21 -0
  44. package/dist/client/container-card-secrets.js +71 -0
  45. package/dist/client/container-card-shared.d.ts +55 -0
  46. package/dist/client/container-card-shared.js +152 -0
  47. package/dist/client/container-card-styles.d.ts +21 -0
  48. package/dist/client/container-card-styles.js +143 -0
  49. package/dist/client/container-card-volumes.d.ts +12 -0
  50. package/dist/client/container-card-volumes.js +37 -0
  51. package/dist/client/container-card-workspace.d.ts +31 -0
  52. package/dist/client/container-card-workspace.js +24 -0
  53. package/dist/client/directory-picker.d.ts +12 -0
  54. package/dist/client/directory-picker.js +30 -0
  55. package/dist/client/index.d.ts +5 -0
  56. package/dist/client/index.js +4419 -0
  57. package/dist/client/locales.d.ts +9 -0
  58. package/dist/client/locales.js +370 -0
  59. package/dist/client/read-only-approval.d.ts +8 -0
  60. package/dist/client/read-only-approval.js +63 -0
  61. package/dist/client/slot-contract.d.ts +12 -0
  62. package/dist/client/slot-contract.js +4 -0
  63. package/dist/client/terminal-styles.d.ts +5 -0
  64. package/dist/client/terminal-styles.js +31 -0
  65. package/dist/client/tool-views.d.ts +8 -0
  66. package/dist/client/tool-views.js +241 -0
  67. package/dist/containers.test.d.ts +1 -0
  68. package/dist/containers.test.js +443 -0
  69. package/dist/daemons.test.d.ts +1 -0
  70. package/dist/daemons.test.js +153 -0
  71. package/dist/fs-provider.d.ts +23 -0
  72. package/dist/fs-provider.js +184 -0
  73. package/dist/fs-provider.test.d.ts +1 -0
  74. package/dist/fs-provider.test.js +260 -0
  75. package/dist/generated/version.d.ts +2 -0
  76. package/dist/generated/version.js +6 -0
  77. package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
  78. package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
  79. package/dist/grpc/runtime-client.d.ts +5 -0
  80. package/dist/grpc/runtime-client.js +43 -0
  81. package/dist/guest-rpc.d.ts +62 -0
  82. package/dist/guest-rpc.js +391 -0
  83. package/dist/images.test.d.ts +1 -0
  84. package/dist/images.test.js +79 -0
  85. package/dist/index.d.ts +22 -0
  86. package/dist/index.js +140 -0
  87. package/dist/misc.test.d.ts +1 -0
  88. package/dist/misc.test.js +141 -0
  89. package/dist/mount-enums.d.ts +22 -0
  90. package/dist/mount-enums.js +81 -0
  91. package/dist/mount-input.d.ts +14 -0
  92. package/dist/mount-input.js +64 -0
  93. package/dist/mounts.test.d.ts +1 -0
  94. package/dist/mounts.test.js +408 -0
  95. package/dist/output-reader.d.ts +34 -0
  96. package/dist/output-reader.js +87 -0
  97. package/dist/paths.test.d.ts +1 -0
  98. package/dist/paths.test.js +74 -0
  99. package/dist/preferences.d.ts +6 -0
  100. package/dist/preferences.js +27 -0
  101. package/dist/project-path.d.ts +6 -0
  102. package/dist/project-path.js +73 -0
  103. package/dist/project-path.test.d.ts +1 -0
  104. package/dist/project-path.test.js +52 -0
  105. package/dist/prompts.d.ts +13 -0
  106. package/dist/prompts.js +149 -0
  107. package/dist/public.d.ts +4 -0
  108. package/dist/public.js +63 -0
  109. package/dist/read-only-shell.d.ts +14 -0
  110. package/dist/read-only-shell.js +150 -0
  111. package/dist/read-only-shell.test.d.ts +1 -0
  112. package/dist/read-only-shell.test.js +127 -0
  113. package/dist/secrets.test.d.ts +1 -0
  114. package/dist/secrets.test.js +181 -0
  115. package/dist/settings-commands.test.d.ts +1 -0
  116. package/dist/settings-commands.test.js +549 -0
  117. package/dist/settings-create.test.d.ts +1 -0
  118. package/dist/settings-create.test.js +394 -0
  119. package/dist/settings-mounts.test.d.ts +1 -0
  120. package/dist/settings-mounts.test.js +467 -0
  121. package/dist/settings-paths.test.d.ts +1 -0
  122. package/dist/settings-paths.test.js +65 -0
  123. package/dist/settings-schema.d.ts +14 -0
  124. package/dist/settings-schema.js +14 -0
  125. package/dist/settings-workspaces.test.d.ts +1 -0
  126. package/dist/settings-workspaces.test.js +265 -0
  127. package/dist/spill-store.d.ts +17 -0
  128. package/dist/spill-store.js +54 -0
  129. package/dist/spill-store.test.d.ts +1 -0
  130. package/dist/spill-store.test.js +59 -0
  131. package/dist/subprocess.d.ts +9 -0
  132. package/dist/subprocess.js +369 -0
  133. package/dist/subprocess.test.d.ts +1 -0
  134. package/dist/subprocess.test.js +234 -0
  135. package/dist/test-support.d.ts +328 -0
  136. package/dist/test-support.js +437 -0
  137. package/dist/tool-defs.d.ts +26 -0
  138. package/dist/tool-defs.js +188 -0
  139. package/dist/tool-handlers.d.ts +2 -0
  140. package/dist/tool-handlers.js +546 -0
  141. package/dist/tool-params.d.ts +803 -0
  142. package/dist/tool-params.js +494 -0
  143. package/dist/tool-schemas.d.ts +2 -0
  144. package/dist/tool-schemas.js +5 -0
  145. package/dist/tool-views.d.ts +2 -0
  146. package/dist/tool-views.js +56 -0
  147. package/dist/views.test.d.ts +1 -0
  148. package/dist/views.test.js +23 -0
  149. package/dist/volumes.test.d.ts +1 -0
  150. package/dist/volumes.test.js +40 -0
  151. package/dist/workspace-binding.d.ts +40 -0
  152. package/dist/workspace-binding.js +275 -0
  153. package/dist/workspace-binding.test.d.ts +1 -0
  154. package/dist/workspace-binding.test.js +337 -0
  155. package/docs/architecture.md +173 -0
  156. package/docs/architecture.zh.md +129 -0
  157. package/docs/configuration.md +160 -0
  158. package/docs/configuration.zh.md +147 -0
  159. package/docs/development.md +142 -0
  160. package/docs/development.zh.md +135 -0
  161. package/docs/install-dsh-and-dsh-podman.md +165 -0
  162. package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
  163. package/docs/usage.md +393 -0
  164. package/docs/usage.zh.md +315 -0
  165. package/package.json +119 -0
@@ -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 {};