@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,443 @@
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 { WORKSPACE_ID, fakeToolContext, guestExecRecorder, guestFileRecorder, secretBearingResolver } from "./test-support.js";
7
+ import { TOOLS, podmanRuntimeSection, resolveGuestCwd, resolveGuestPath, toolHandlers, } from "./index.js";
8
+ test("container start/recreate/bash accept an env map schema", () => {
9
+ for (const name of ["container_start", "container_recreate", "container_bash"]) {
10
+ const tool = TOOLS.find((entry) => entry.name === name);
11
+ assert.ok(tool, `${name} registered`);
12
+ const env = tool.parameters.properties.env;
13
+ assert.equal(env.type, "object", `${name}.env type`);
14
+ assert.equal(env.additionalProperties.type, "string", `${name}.env values type`);
15
+ assert.ok(!Array.isArray(tool.parameters.required.includes("env")), `${name}.env must stay optional`);
16
+ }
17
+ });
18
+ test("container_list returns sanitized container objects with env values", async () => {
19
+ const resolver = {
20
+ registry: {
21
+ resolveByPath: async () => ({ id: WORKSPACE_ID }),
22
+ },
23
+ control: async (method) => {
24
+ if (method === "listContainers") {
25
+ return {
26
+ containers: [
27
+ {
28
+ workspaceSlug: WORKSPACE_ID,
29
+ containerName: "default",
30
+ status: "running",
31
+ imageId: "img-1",
32
+ env: { PATH: "/bin", HOME: "/root" },
33
+ },
34
+ {
35
+ workspaceSlug: WORKSPACE_ID,
36
+ containerName: "db",
37
+ status: "running",
38
+ env: { PORT: "5432", DB: "main", X: "1", Y: "2" },
39
+ },
40
+ {
41
+ workspaceSlug: WORKSPACE_ID,
42
+ containerName: "worker",
43
+ status: "stopped",
44
+ },
45
+ ],
46
+ };
47
+ }
48
+ return {};
49
+ },
50
+ };
51
+ const exec = { agent: { session: { header: { cwd: "/proj" } } } };
52
+ const out = (await toolHandlers.container_list(resolver, {}, exec));
53
+ assert.deepEqual(out, [
54
+ {
55
+ containerName: "default",
56
+ status: "running",
57
+ imageId: "img-1",
58
+ mounts: [],
59
+ paths: [],
60
+ env: { PATH: "/bin", HOME: "/root" },
61
+ secretEnv: {},
62
+ },
63
+ {
64
+ containerName: "db",
65
+ status: "running",
66
+ mounts: [],
67
+ paths: [],
68
+ env: { PORT: "5432", DB: "main", X: "1", Y: "2" },
69
+ secretEnv: {},
70
+ },
71
+ {
72
+ containerName: "worker",
73
+ status: "stopped",
74
+ mounts: [],
75
+ paths: [],
76
+ env: {},
77
+ secretEnv: {},
78
+ },
79
+ ]);
80
+ });
81
+ test("container_list reports an uncreated default container as not started", async () => {
82
+ const resolver = {
83
+ registry: {
84
+ resolveByPath: async () => ({ id: WORKSPACE_ID }),
85
+ },
86
+ control: async () => ({ containers: [] }),
87
+ };
88
+ const exec = { agent: { session: { header: { cwd: "/proj" } } } };
89
+ const out = (await toolHandlers.container_list(resolver, {}, exec));
90
+ assert.deepEqual(out, [
91
+ { containerName: "default", status: "not started" },
92
+ ]);
93
+ });
94
+ test("container tools never expose internal fields in their results", async () => {
95
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
96
+ const cases = [
97
+ [toolHandlers.container_start, { container: "default", image: "img-1" }],
98
+ [toolHandlers.container_recreate, { container: "default" }],
99
+ [toolHandlers.container_mount_add, { container: "default", kind: "volume", volume: "v", destination: "/data" }],
100
+ [toolHandlers.container_mount_remove, { container: "default", kind: "volume", volume: "v", destination: "/data" }],
101
+ [toolHandlers.container_mount_update, { container: "default", kind: "volume", volume: "v", destination: "/data", mode: "read_only" }],
102
+ [toolHandlers.container_secret_add, { container: "default", env: "DB_PASS", secret: "db-pass" }],
103
+ [toolHandlers.container_secret_remove, { container: "default", env: "DB_PASS" }],
104
+ ];
105
+ for (const [handler, input] of cases) {
106
+ const out = await handler(secretBearingResolver(), input, exec);
107
+ const json = JSON.stringify(out);
108
+ assert.ok(!json.includes("agentSocketPath"), "must not expose agentSocketPath");
109
+ assert.ok(!json.includes("agentToken"), "must not expose agentToken");
110
+ assert.ok(!json.includes("podmanName"), "must not expose podmanName");
111
+ assert.ok(!json.includes("workspaceSlug"), "must not expose workspaceSlug");
112
+ assert.ok(!json.includes("createdAt"), "must not expose createdAt");
113
+ assert.ok(json.includes("hunter2"), "env values are returned to the model");
114
+ assert.equal(out.containerName, "default");
115
+ assert.equal(out.status, "running");
116
+ assert.equal(out.imageId, "img-1");
117
+ assert.equal(out.secretEnv.DB_PASS, "db-pass");
118
+ }
119
+ });
120
+ test("container_recreate reports the recreated container", async () => {
121
+ const resolver = {
122
+ registry: {
123
+ resolveByPath: async () => ({ id: WORKSPACE_ID }),
124
+ },
125
+ getConfig: () => ({ projectsRoot: "/projects" }),
126
+ // The control plane answers with the container it recreated: its logical
127
+ // name, and its own mounts and env rather than the default container's.
128
+ control: async () => ({
129
+ workspaceSlug: WORKSPACE_ID,
130
+ containerName: "db",
131
+ imageId: "img-1",
132
+ status: "running",
133
+ mounts: [],
134
+ env: { APP_ENV: "probe" },
135
+ }),
136
+ };
137
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
138
+ const named = (await toolHandlers.container_recreate(resolver, { container: "db", image: "img-1" }, exec));
139
+ assert.equal(named.containerName, "db");
140
+ assert.deepEqual(named.env, { APP_ENV: "probe" });
141
+ });
142
+ test("container start and recreate forward PATH additions", async () => {
143
+ const calls = [];
144
+ const resolver = {
145
+ registry: {
146
+ resolveByPath: async () => ({ id: WORKSPACE_ID }),
147
+ },
148
+ getConfig: () => ({ projectsRoot: "/projects" }),
149
+ control: async (method, request) => {
150
+ calls.push([method, request]);
151
+ return { containerName: "default", status: "running", paths: ["/opt/bin"] };
152
+ },
153
+ };
154
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
155
+ const started = (await toolHandlers.container_start(resolver, { container: "default", paths: ["/opt/bin"] }, exec));
156
+ assert.deepEqual(calls[0][1].paths, ["/opt/bin"]);
157
+ assert.deepEqual(started.paths, ["/opt/bin"]);
158
+ const recreated = (await toolHandlers.container_recreate(resolver, { container: "default", paths: ["/usr/local/bin"] }, exec));
159
+ assert.deepEqual(calls[1][1].paths, ["/usr/local/bin"]);
160
+ assert.deepEqual(recreated.paths, ["/opt/bin"]);
161
+ });
162
+ test("resolveGuestPath resolves relative paths and refuses traversal", () => {
163
+ assert.equal(resolveGuestPath("/abs/file", "/projects/team"), "/abs/file");
164
+ assert.equal(resolveGuestPath("README.md", "/projects/team"), "/projects/team/README.md");
165
+ assert.equal(resolveGuestPath("src/main.go", "/projects/team"), "/projects/team/src/main.go");
166
+ assert.equal(resolveGuestPath("./a", "/projects/team"), "/projects/team/a");
167
+ // ".." is refused before resolution, where it would be normalised away.
168
+ assert.throws(() => resolveGuestPath("../escape", "/projects/team"), /must not escape/);
169
+ assert.throws(() => resolveGuestPath("a/../b", "/projects/team"), /must not escape/);
170
+ assert.throws(() => resolveGuestPath("/a/../b", "/projects/team"), /must not escape/);
171
+ // A relative path is meaningless without a session working directory.
172
+ assert.throws(() => resolveGuestPath("rel", undefined), /session working directory/);
173
+ assert.throws(() => resolveGuestPath("rel", ""), /session working directory/);
174
+ assert.equal(resolveGuestPath("/abs", undefined), "/abs");
175
+ });
176
+ test("resolveGuestCwd resolves working directories but keeps traversal", () => {
177
+ assert.equal(resolveGuestCwd(undefined, "/projects/team"), undefined);
178
+ assert.equal(resolveGuestCwd("", "/projects/team"), undefined);
179
+ assert.equal(resolveGuestCwd("/abs", "/projects/team"), "/abs");
180
+ assert.equal(resolveGuestCwd("sub", "/projects/team"), "/projects/team/sub");
181
+ // Commands are not confined to the projects root, so ".." is allowed here.
182
+ assert.equal(resolveGuestCwd("../sibling", "/projects/team"), "/projects/sibling");
183
+ });
184
+ test("container_read applies offset and limit like the built-in read tool", async () => {
185
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
186
+ const { resolver } = guestFileRecorder("l1\nl2\nl3\nl4\n");
187
+ assert.equal(await toolHandlers.container_read(resolver, { container: "default", file_path: "f" }, exec, fakeToolContext(resolver)), "l1\nl2\nl3\nl4\n", "no offset/limit returns the whole content");
188
+ assert.equal(await toolHandlers.container_read(resolver, { container: "default", file_path: "f", offset: 2, limit: 2 }, exec, fakeToolContext(resolver)), "l2\nl3");
189
+ });
190
+ test("container_read records the read so a later write needs no re-read", async () => {
191
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
192
+ const { resolver } = guestFileRecorder("hello\n");
193
+ const ctx = fakeToolContext(resolver);
194
+ assert.equal(await toolHandlers.container_read(resolver, { container: "default", file_path: "f" }, exec, ctx), "hello\n");
195
+ assert.equal(ctx.observed.length, 1, "a successful read reports one observation");
196
+ assert.equal(ctx.observed[0].target.displayPath, "/projects/team/f");
197
+ assert.equal(ctx.observed[0].observation.kind, "present");
198
+ assert.match(ctx.observed[0].observation.version, /^agent:/);
199
+ });
200
+ test("container_read reports a missing file and records it as absent", async () => {
201
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
202
+ const { resolver } = guestFileRecorder("", false);
203
+ const ctx = fakeToolContext(resolver);
204
+ await assert.rejects(() => toolHandlers.container_read(resolver, { container: "default", file_path: "gone" }, exec, ctx), (error) => error.code === "FS_NOT_FOUND" && /not found/.test(error.message));
205
+ assert.deepEqual(ctx.observed[0].observation, { kind: "absent" });
206
+ });
207
+ test("container_edit requires a unique match unless replace_all is set", async () => {
208
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
209
+ const duplicate = guestFileRecorder("a a a");
210
+ await assert.rejects(() => toolHandlers.container_edit(duplicate.resolver, { container: "default", file_path: "f", old_string: "a", new_string: "b" }, exec, fakeToolContext(duplicate.resolver)), /matched 3 times/);
211
+ assert.deepEqual(duplicate.writes, [], "a non-unique match must not be written");
212
+ const all = guestFileRecorder("a a a");
213
+ await toolHandlers.container_edit(all.resolver, { container: "default", file_path: "f", old_string: "a", new_string: "b", replace_all: true }, exec, fakeToolContext(all.resolver));
214
+ assert.equal(all.writes[0].content, "b b b");
215
+ });
216
+ test("container command and file tools mirror the built-in arguments", () => {
217
+ const parameters = (name) => TOOLS.find((entry) => entry.name === name).parameters;
218
+ const bash = parameters("container_bash");
219
+ assert.ok(bash.required.includes("command"));
220
+ assert.ok(bash.required.includes("description"));
221
+ assert.equal(bash.properties.timeoutMs.type, "number");
222
+ const exec = parameters("container_exec");
223
+ assert.ok(exec.required.includes("description"));
224
+ assert.equal(exec.properties.workdir.type, "string");
225
+ assert.equal(exec.properties.cwd, undefined);
226
+ const read = parameters("container_read");
227
+ assert.equal(read.properties.file_path.type, "string");
228
+ assert.equal(read.properties.offset.type, "integer");
229
+ assert.equal(read.properties.limit.type, "integer");
230
+ const write = parameters("container_write");
231
+ assert.equal(write.properties.file_path.type, "string");
232
+ const edit = parameters("container_edit");
233
+ assert.equal(edit.properties.old_string.type, "string");
234
+ assert.equal(edit.properties.new_string.type, "string");
235
+ assert.equal(edit.properties.replace_all.type, "boolean");
236
+ const glob = parameters("container_glob");
237
+ assert.equal(glob.properties.path.type, "string");
238
+ assert.equal(glob.properties.cwd, undefined);
239
+ const grep = parameters("container_grep");
240
+ assert.equal(grep.properties.include.type, "string");
241
+ assert.equal(grep.properties.cwd, undefined);
242
+ });
243
+ test("container_bash enforces timeoutMs", async () => {
244
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
245
+ let signaled;
246
+ const guest = {
247
+ exec: () => {
248
+ const handlers = {};
249
+ return {
250
+ on(event, handler) {
251
+ (handlers[event] ??= []).push(handler);
252
+ if (event === "data")
253
+ queueMicrotask(() => handler({ processId: "42" }));
254
+ },
255
+ write() { },
256
+ end() { },
257
+ };
258
+ },
259
+ signal: (request, _metadata, callback) => {
260
+ signaled = request;
261
+ callback(null, {});
262
+ },
263
+ };
264
+ const resolver = {
265
+ resolve: async () => ({
266
+ guest,
267
+ token: "t",
268
+ socket: "/run/x.sock",
269
+ defaultCwd: "/projects/team",
270
+ }),
271
+ };
272
+ await assert.rejects(() => toolHandlers.container_bash(resolver, { container: "default", command: "sleep", description: "x", timeoutMs: 10 }, exec), /timed out/);
273
+ assert.equal(signaled.processId, "42");
274
+ assert.equal(signaled.signal, "SIGTERM");
275
+ });
276
+ test("container commands inherit the managed shell environment", async () => {
277
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
278
+ const ctx = {
279
+ get: (name) => name === "shellEnv"
280
+ ? {
281
+ collect: () => ({
282
+ DSH_HOME: "/dsh",
283
+ DSH_SHELL: "1",
284
+ DSH_SESSION_ID: "s1",
285
+ }),
286
+ }
287
+ : undefined,
288
+ };
289
+ const bash = guestExecRecorder();
290
+ await toolHandlers.container_bash(bash.resolver, {
291
+ container: "default",
292
+ command: "env",
293
+ description: "x",
294
+ env: { TERM: "xterm", DSH_SESSION_ID: "spoofed" },
295
+ }, exec, ctx);
296
+ assert.deepEqual(bash.starts[0].env, {
297
+ // The terminal overrides come first, the caller's entry beats them, and
298
+ // the managed DSH_* snapshot displaces the caller's spoofed session id.
299
+ NO_COLOR: "1",
300
+ TERM: "xterm",
301
+ PAGER: "cat",
302
+ GIT_PAGER: "cat",
303
+ DSH_HOME: "/dsh",
304
+ DSH_SHELL: "1",
305
+ DSH_SESSION_ID: "s1",
306
+ });
307
+ const run = guestExecRecorder();
308
+ await toolHandlers.container_exec(run.resolver, { container: "default", argv: ["env"], description: "x" }, exec, ctx);
309
+ assert.equal(run.starts[0].env?.DSH_SESSION_ID, "s1");
310
+ assert.equal(run.starts[0].env?.TERM, "dumb");
311
+ });
312
+ test("container_grep resolves its search path like a shell would", async () => {
313
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
314
+ // With a search path, it is relative to the session directory.
315
+ const plain = guestExecRecorder("/projects/team");
316
+ await toolHandlers.container_grep(plain.resolver, { container: "default", pattern: "TODO", path: "src" }, exec);
317
+ assert.deepEqual(plain.starts[0].argv, ["/usr/bin/rg", "-n", "TODO", "/projects/team/src"]);
318
+ // An include filter maps to ripgrep's --glob.
319
+ const filtered = guestExecRecorder("/projects/team");
320
+ await toolHandlers.container_grep(filtered.resolver, { container: "default", pattern: "TODO", path: "src", include: "*.ts" }, exec);
321
+ assert.deepEqual(filtered.starts[0].argv, ["/usr/bin/rg", "-n", "--glob", "*.ts", "TODO", "/projects/team/src"]);
322
+ assert.equal(filtered.starts[0].cwd, "/projects/team");
323
+ const none = guestExecRecorder("/projects/team");
324
+ await toolHandlers.container_grep(none.resolver, { container: "default", pattern: "TODO" }, exec);
325
+ // No path searches the session workspace explicitly: ripgrep would otherwise
326
+ // read its (non-TTY) stdin instead of the working directory.
327
+ assert.deepEqual(none.starts[0].argv, ["/usr/bin/rg", "-n", "TODO", "/projects/team"]);
328
+ assert.equal(none.starts[0].cwd, "/projects/team");
329
+ });
330
+ test("container_glob passes the pattern as a glob and scopes the search", async () => {
331
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
332
+ const plain = guestExecRecorder("/projects/team");
333
+ await toolHandlers.container_glob(plain.resolver, { container: "default", pattern: "*.ts" }, exec);
334
+ assert.deepEqual(plain.starts[0].argv, [
335
+ "/usr/bin/rg",
336
+ "--files",
337
+ "--glob=*.ts",
338
+ "--sort=modified",
339
+ "--no-ignore",
340
+ "--hidden",
341
+ "--glob=!**/.git",
342
+ "--glob=!**/.git/**",
343
+ "--glob=!**/.svn",
344
+ "--glob=!**/.svn/**",
345
+ "--glob=!**/.hg",
346
+ "--glob=!**/.hg/**",
347
+ "--glob=!**/.bzr",
348
+ "--glob=!**/.bzr/**",
349
+ "--glob=!**/.jj",
350
+ "--glob=!**/.jj/**",
351
+ "--glob=!**/.sl",
352
+ "--glob=!**/.sl/**",
353
+ ]);
354
+ assert.equal(plain.starts[0].cwd, "/projects/team");
355
+ // An explicit search path becomes the working directory.
356
+ const scoped = guestExecRecorder("/projects/team");
357
+ await toolHandlers.container_glob(scoped.resolver, { container: "default", pattern: "*", path: "/volumes/data" }, exec);
358
+ assert.equal(scoped.starts[0].cwd, "/volumes/data");
359
+ assert.ok(scoped.starts[0].argv.includes("--glob=*"));
360
+ });
361
+ test("container_glob caps its result at 100 files", async () => {
362
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
363
+ const lines = (count) => Array.from({ length: count }, (_, index) => `file-${index}.ts`).join("\n") + "\n";
364
+ const small = guestExecRecorder("/projects/team", lines(3));
365
+ const few = (await toolHandlers.container_glob(small.resolver, { container: "default", pattern: "*" }, exec));
366
+ assert.equal(few.files.length, 3);
367
+ assert.equal(few.note, undefined);
368
+ const big = guestExecRecorder("/projects/team", lines(101));
369
+ const many = (await toolHandlers.container_glob(big.resolver, { container: "default", pattern: "*" }, exec));
370
+ assert.equal(many.files.length, 100);
371
+ assert.match(many.note ?? "", /showing 100 of 101 files/);
372
+ });
373
+ test("podmanRuntimeSection names only the tools the agent has", () => {
374
+ // The harness's shell/filesystem tools are mounted for this agent.
375
+ const withBuiltins = podmanRuntimeSection({ tools: { get: () => ({}) } });
376
+ assert.equal(withBuiltins.name, "podman:runtime");
377
+ assert.equal(withBuiltins.order, 950);
378
+ const full = withBuiltins.text({ scope: "agent" });
379
+ for (const tool of ["bash", "read", "write", "edit", "glob", "grep"]) {
380
+ assert.match(full, new RegExp("`" + tool + "`"));
381
+ }
382
+ assert.match(full, /container-backed/);
383
+ assert.match(full, /run in a Podman container/);
384
+ assert.match(full, /There is no host shell/);
385
+ // The facts that pre-empt the "bash runs on the host" hallucination: the
386
+ // built-in shell and container_bash run in one container, and identical uname
387
+ // output is the shared kernel, not a host shell.
388
+ assert.match(full, /container: "default"/);
389
+ assert.match(full, /sees the same environment and filesystem/);
390
+ assert.match(full, /share the host kernel/);
391
+ assert.match(full, /hostname/);
392
+ assert.match(full, /\/etc\/os-release/);
393
+ // Containers belong to a dsh workspace, and the daemon tools are
394
+ // container-scoped too (they do not start with `container_`).
395
+ assert.match(full, /scoped to a dsh workspace/);
396
+ assert.match(full, /container_\*/);
397
+ assert.match(full, /daemon_\*/);
398
+ // Neither the old vocabulary nor plugin lore belongs in the section.
399
+ assert.doesNotMatch(full, /Podman workspace/);
400
+ assert.doesNotMatch(full, /those same operations/);
401
+ assert.doesNotMatch(full, /plugin/i);
402
+ // The Podman operator preset mounts none of them, so the wording must not
403
+ // name a tool the agent cannot call.
404
+ const containerOnly = podmanRuntimeSection({ tools: { get: () => undefined } });
405
+ const short = containerOnly.text({ scope: "agent" });
406
+ for (const tool of ["bash", "read", "write", "edit", "glob", "grep"]) {
407
+ assert.doesNotMatch(short, new RegExp("`" + tool + "`"));
408
+ }
409
+ assert.doesNotMatch(short, /container-backed/);
410
+ assert.doesNotMatch(short, /sees the same/);
411
+ assert.match(short, /run in a Podman container/);
412
+ assert.match(short, /There is no host shell/);
413
+ assert.match(short, /container: "default"/);
414
+ assert.match(short, /scoped to a dsh workspace/);
415
+ assert.match(short, /container_\*/);
416
+ assert.match(short, /daemon_\*/);
417
+ assert.match(short, /share the host kernel/);
418
+ });
419
+ test("container_grep fails loudly when ripgrep reports an error", async () => {
420
+ const guest = {
421
+ exec: () => {
422
+ const handlers = {};
423
+ return {
424
+ on(event, handler) {
425
+ (handlers[event] ??= []).push(handler);
426
+ },
427
+ write() { },
428
+ end() {
429
+ for (const handler of handlers.data ?? []) {
430
+ handler({ stderrChunk: Buffer.from("rg: error parsing glob '[bad'\n") });
431
+ handler({ exit: { exitCode: 2, signaled: false } });
432
+ }
433
+ },
434
+ };
435
+ },
436
+ };
437
+ const resolver = {
438
+ resolve: async () => ({ guest, token: "t" }),
439
+ containerBinding: async () => ({ guest, token: "t" }),
440
+ };
441
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
442
+ await assert.rejects(toolHandlers.container_grep(resolver, { container: "default", pattern: "TODO", include: "[bad" }, exec), /error parsing glob/);
443
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,153 @@
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 { DAEMON_TOOLS } from "./test-support.js";
7
+ import { TOOLS, publicDaemon, toolHandlers } from "./index.js";
8
+ test("daemon_start accepts optional uid, gid and groups", () => {
9
+ const tool = TOOLS.find((entry) => entry.name === "daemon_start");
10
+ assert.ok(tool, "daemon_start registered");
11
+ const properties = tool.parameters.properties;
12
+ assert.equal(properties.name.type, "string");
13
+ assert.ok(tool.parameters.required.includes("name"), "daemon_start must require a name");
14
+ assert.equal(properties.uid.type, "integer");
15
+ assert.equal(properties.uid.minimum, 0);
16
+ assert.equal(properties.gid.type, "integer");
17
+ assert.equal(properties.gid.minimum, 0);
18
+ assert.equal(properties.groups.type, "array");
19
+ assert.equal(properties.groups.items.type, "integer");
20
+ assert.equal(properties.groups.items.minimum, 0);
21
+ assert.equal(properties.inheritEnv.type, "boolean");
22
+ });
23
+ test("daemon_start serializes uid/gid as protobuf wrapper objects", async () => {
24
+ let captured;
25
+ const resolver = {
26
+ containerBinding: async () => ({
27
+ guest: {
28
+ startDaemon: (request, _metadata, callback) => {
29
+ captured = request;
30
+ callback(null, {
31
+ name: "valkey-1001",
32
+ running: true,
33
+ argv: ["valkey-server"],
34
+ });
35
+ },
36
+ },
37
+ token: "t",
38
+ socket: "/run/x.sock",
39
+ }),
40
+ };
41
+ const exec = { agent: { session: { header: { cwd: "/proj" } } } };
42
+ await toolHandlers.daemon_start(resolver, {
43
+ container: "valkey-ctr",
44
+ argv: ["valkey-server"],
45
+ uid: 1001,
46
+ gid: 1001,
47
+ }, exec);
48
+ assert.deepEqual(captured.uid, { value: 1001 });
49
+ assert.deepEqual(captured.gid, { value: 1001 });
50
+ assert.deepEqual(captured.inheritEnv, { value: true });
51
+ captured = undefined;
52
+ await toolHandlers.daemon_start(resolver, { container: "valkey-ctr", argv: ["valkey-server"] }, exec);
53
+ assert.equal(captured.uid, undefined);
54
+ assert.equal(captured.gid, undefined);
55
+ assert.deepEqual(captured.inheritEnv, { value: true });
56
+ captured = undefined;
57
+ await toolHandlers.daemon_start(resolver, { container: "valkey-ctr", argv: ["valkey-server"], inheritEnv: false }, exec);
58
+ assert.deepEqual(captured.inheritEnv, { value: false });
59
+ });
60
+ test("daemon tools require no approval and are valid object-rooted schemas", () => {
61
+ for (const name of DAEMON_TOOLS) {
62
+ const tool = TOOLS.find((entry) => entry.name === name);
63
+ assert.ok(tool, `${name} registered`);
64
+ assert.notEqual(tool.approval, true, `${name} must not require approval`);
65
+ assert.equal(tool.parameters.type, "object", `${name} type`);
66
+ assert.equal(typeof tool.parameters.properties, "object");
67
+ assert.ok(Array.isArray(tool.parameters.required));
68
+ assert.ok(tool.parameters.required.includes("container"));
69
+ }
70
+ });
71
+ test("publicDaemon omits the exit code while the daemon runs", () => {
72
+ const running = publicDaemon({ name: "web", argv: ["sleep", "600"], running: true, exitCode: 0 });
73
+ assert.equal(running.running, true);
74
+ assert.equal("exitCode" in running, false);
75
+ const exited = publicDaemon({ name: "web", argv: ["sleep", "600"], running: false, exitCode: 3 });
76
+ assert.equal(exited.running, false);
77
+ assert.equal(exited.exitCode, 3);
78
+ });
79
+ test("publicDaemon reports supplementary groups", () => {
80
+ const withGroups = publicDaemon({
81
+ name: "web",
82
+ argv: ["sleep", "600"],
83
+ running: true,
84
+ uid: 1000,
85
+ gid: 1000,
86
+ groups: [3000, 4000],
87
+ });
88
+ assert.deepEqual(withGroups.groups, [3000, 4000]);
89
+ const without = publicDaemon({
90
+ name: "web",
91
+ argv: ["sleep", "600"],
92
+ running: true,
93
+ groups: [],
94
+ });
95
+ assert.equal("groups" in without, false);
96
+ });
97
+ test("daemon_start returns a daemon info object", async () => {
98
+ const resolver = {
99
+ resolve: async () => ({
100
+ guest: {
101
+ startDaemon: (_metadata, _request, callback) => callback(null, {
102
+ name: "web",
103
+ running: true,
104
+ argv: ["python3", "-m", "http.server", "8000"],
105
+ uid: 1000,
106
+ gid: 1000,
107
+ }),
108
+ },
109
+ token: "t",
110
+ socket: "/run/x.sock",
111
+ }),
112
+ };
113
+ const exec = { agent: { session: { header: { cwd: "/proj" } } } };
114
+ const out = await toolHandlers.daemon_start(resolver, { container: "default", argv: ["python3", "-m", "http.server", "8000"], name: "web" }, exec);
115
+ assert.deepEqual(out, {
116
+ name: "web",
117
+ running: true,
118
+ argv: ["python3", "-m", "http.server", "8000"],
119
+ uid: 1000,
120
+ gid: 1000,
121
+ });
122
+ });
123
+ test("daemon_start resolves a relative working directory", async () => {
124
+ const makeResolver = (defaultCwd) => {
125
+ let captured;
126
+ const resolver = {
127
+ containerBinding: async () => ({
128
+ guest: {
129
+ startDaemon: (request, _metadata, callback) => {
130
+ captured = request;
131
+ callback(null, { name: "d", running: true, argv: ["x"] });
132
+ },
133
+ },
134
+ token: "t",
135
+ socket: "/run/x.sock",
136
+ ...(defaultCwd === undefined ? {} : { defaultCwd }),
137
+ }),
138
+ };
139
+ return { resolver, captured: () => captured };
140
+ };
141
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
142
+ const relative = makeResolver("/projects/team");
143
+ await toolHandlers.daemon_start(relative.resolver, { container: "c", argv: ["x"], cwd: "sub" }, exec);
144
+ assert.equal(relative.captured().cwd, "/projects/team/sub");
145
+ // No explicit cwd: the mounted session directory is used.
146
+ const defaulted = makeResolver("/projects/team");
147
+ await toolHandlers.daemon_start(defaulted.resolver, { container: "c", argv: ["x"] }, exec);
148
+ assert.equal(defaulted.captured().cwd, "/projects/team");
149
+ // Not mounted: the guest agent's own working directory stays in place.
150
+ const unmounted = makeResolver();
151
+ await toolHandlers.daemon_start(unmounted.resolver, { container: "c", argv: ["x"] }, exec);
152
+ assert.equal(unmounted.captured().cwd, undefined);
153
+ });
@@ -0,0 +1,23 @@
1
+ import { WorkspaceResolver } from "./workspace-binding.js";
2
+ export interface FilesystemProvider {
3
+ resolve(path: string, opts?: any): Promise<any>;
4
+ processPath(target: any): string;
5
+ processPathFromHostPath(hostPath: string): string | undefined;
6
+ fileUrl(target: any): string;
7
+ contains(parent: any, child: any): boolean;
8
+ readText(target: any, signal?: AbortSignal): Promise<string>;
9
+ streamText(target: any, signal?: AbortSignal): Promise<AsyncIterable<string>>;
10
+ readBytes(target: any, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>;
11
+ readByteRange(target: any, range: {
12
+ offset: number;
13
+ length: number;
14
+ }, signal?: AbortSignal): Promise<Uint8Array>;
15
+ lstat(path: string, opts?: any, signal?: AbortSignal): Promise<any | undefined>;
16
+ writeText(target: any, content: string, expected?: any, signal?: AbortSignal): Promise<any>;
17
+ stat(target: any): Promise<any>;
18
+ listDir(target: any): Promise<any>;
19
+ mkdir(target: any, parents?: boolean): Promise<any>;
20
+ remove(target: any, recursive?: boolean): Promise<any>;
21
+ editText(target: any, edit: any, expected?: any, signal?: AbortSignal): Promise<any>;
22
+ }
23
+ export declare function createFilesystemProvider(resolver: WorkspaceResolver): FilesystemProvider;