@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
package/dist/containers.test.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { fakeToolContext, guestExecRecorder, guestFileRecorder, secretBearingResolver, WORKSPACE_ID, } from "./test-support.js";
|
|
7
|
+
import { podmanRuntimeSection, resolveGuestCwd, resolveGuestPath, toolHandlers, TOOLS, } from "./index.js";
|
|
8
8
|
test("container start/recreate/bash accept an env map schema", () => {
|
|
9
9
|
for (const name of ["container_start", "container_recreate", "container_bash"]) {
|
|
10
10
|
const tool = TOOLS.find((entry) => entry.name === name);
|
|
@@ -96,11 +96,34 @@ test("container tools never expose internal fields in their results", async () =
|
|
|
96
96
|
const cases = [
|
|
97
97
|
[toolHandlers.container_start, { container: "default", image: "img-1" }],
|
|
98
98
|
[toolHandlers.container_recreate, { container: "default" }],
|
|
99
|
-
[toolHandlers.container_mount_add, {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
[toolHandlers.container_mount_add, {
|
|
100
|
+
container: "default",
|
|
101
|
+
kind: "volume",
|
|
102
|
+
volume: "v",
|
|
103
|
+
destination: "/data",
|
|
104
|
+
}],
|
|
105
|
+
[toolHandlers.container_mount_remove, {
|
|
106
|
+
container: "default",
|
|
107
|
+
kind: "volume",
|
|
108
|
+
volume: "v",
|
|
109
|
+
destination: "/data",
|
|
110
|
+
}],
|
|
111
|
+
[toolHandlers.container_mount_update, {
|
|
112
|
+
container: "default",
|
|
113
|
+
kind: "volume",
|
|
114
|
+
volume: "v",
|
|
115
|
+
destination: "/data",
|
|
116
|
+
mode: "read_only",
|
|
117
|
+
}],
|
|
118
|
+
[toolHandlers.container_secret_add, {
|
|
119
|
+
container: "default",
|
|
120
|
+
env: "DB_PASS",
|
|
121
|
+
secret: "db-pass",
|
|
122
|
+
}],
|
|
123
|
+
[toolHandlers.container_secret_remove, {
|
|
124
|
+
container: "default",
|
|
125
|
+
env: "DB_PASS",
|
|
126
|
+
}],
|
|
104
127
|
];
|
|
105
128
|
for (const [handler, input] of cases) {
|
|
106
129
|
const out = await handler(secretBearingResolver(), input, exec);
|
|
@@ -148,7 +171,11 @@ test("container start and recreate forward PATH additions", async () => {
|
|
|
148
171
|
getConfig: () => ({ projectsRoot: "/projects" }),
|
|
149
172
|
control: async (method, request) => {
|
|
150
173
|
calls.push([method, request]);
|
|
151
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
containerName: "default",
|
|
176
|
+
status: "running",
|
|
177
|
+
paths: ["/opt/bin"],
|
|
178
|
+
};
|
|
152
179
|
},
|
|
153
180
|
};
|
|
154
181
|
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
@@ -159,6 +186,97 @@ test("container start and recreate forward PATH additions", async () => {
|
|
|
159
186
|
assert.deepEqual(calls[1][1].paths, ["/usr/local/bin"]);
|
|
160
187
|
assert.deepEqual(recreated.paths, ["/opt/bin"]);
|
|
161
188
|
});
|
|
189
|
+
test("container_start seeds the default environment into a new container", async () => {
|
|
190
|
+
const calls = [];
|
|
191
|
+
const resolver = {
|
|
192
|
+
registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
|
|
193
|
+
getConfig: () => ({
|
|
194
|
+
projectsRoot: "/projects",
|
|
195
|
+
containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
|
|
196
|
+
}),
|
|
197
|
+
control: async (method, request) => {
|
|
198
|
+
calls.push([method, request]);
|
|
199
|
+
if (method === "listContainers")
|
|
200
|
+
return { containers: [] };
|
|
201
|
+
return { containerName: "web", status: "running" };
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
205
|
+
await toolHandlers.container_start(resolver, { container: "web", image: "img-1" }, exec);
|
|
206
|
+
assert.deepEqual(calls.map(([method]) => method), ["listContainers", "startContainer"]);
|
|
207
|
+
const start = calls.find(([method]) => method === "startContainer");
|
|
208
|
+
assert.deepEqual(start[1].env, { GIT_AUTHOR_NAME: "John Doe" });
|
|
209
|
+
});
|
|
210
|
+
test("container_start keeps an existing container's environment", async () => {
|
|
211
|
+
const calls = [];
|
|
212
|
+
const resolver = {
|
|
213
|
+
registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
|
|
214
|
+
getConfig: () => ({
|
|
215
|
+
projectsRoot: "/projects",
|
|
216
|
+
containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
|
|
217
|
+
}),
|
|
218
|
+
control: async (method, request) => {
|
|
219
|
+
calls.push([method, request]);
|
|
220
|
+
if (method === "listContainers") {
|
|
221
|
+
return {
|
|
222
|
+
containers: [
|
|
223
|
+
{
|
|
224
|
+
workspaceSlug: WORKSPACE_ID,
|
|
225
|
+
containerName: "web",
|
|
226
|
+
env: { KEEP: "1" },
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { containerName: "web", status: "running" };
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
235
|
+
await toolHandlers.container_start(resolver, { container: "web", image: "img-1" }, exec);
|
|
236
|
+
const start = calls.find(([method]) => method === "startContainer");
|
|
237
|
+
assert.equal("env" in start[1], false, "a stored environment must survive a start");
|
|
238
|
+
});
|
|
239
|
+
test("container_start lets the caller's environment win over the defaults", async () => {
|
|
240
|
+
const calls = [];
|
|
241
|
+
const resolver = {
|
|
242
|
+
registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
|
|
243
|
+
getConfig: () => ({
|
|
244
|
+
projectsRoot: "/projects",
|
|
245
|
+
containerEnv: {
|
|
246
|
+
GIT_AUTHOR_NAME: "John Doe",
|
|
247
|
+
GIT_AUTHOR_EMAIL: "john.doe@git.example",
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
control: async (method, request) => {
|
|
251
|
+
calls.push([method, request]);
|
|
252
|
+
return { containerName: "web", status: "running" };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
256
|
+
await toolHandlers.container_start(resolver, { container: "web", image: "img-1", env: { GIT_AUTHOR_NAME: "Other" } }, exec);
|
|
257
|
+
assert.deepEqual(calls.map(([method]) => method), ["startContainer"]);
|
|
258
|
+
assert.deepEqual(calls[0][1].env, {
|
|
259
|
+
GIT_AUTHOR_NAME: "Other",
|
|
260
|
+
GIT_AUTHOR_EMAIL: "john.doe@git.example",
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
test("container_recreate never injects the default environment", async () => {
|
|
264
|
+
const calls = [];
|
|
265
|
+
const resolver = {
|
|
266
|
+
registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
|
|
267
|
+
getConfig: () => ({
|
|
268
|
+
projectsRoot: "/projects",
|
|
269
|
+
containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
|
|
270
|
+
}),
|
|
271
|
+
control: async (method, request) => {
|
|
272
|
+
calls.push([method, request]);
|
|
273
|
+
return { containerName: "default", status: "running" };
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
277
|
+
await toolHandlers.container_recreate(resolver, { container: "default" }, exec);
|
|
278
|
+
assert.equal("env" in calls[0][1], false, "a recreate stores what it is given");
|
|
279
|
+
});
|
|
162
280
|
test("resolveGuestPath resolves relative paths and refuses traversal", () => {
|
|
163
281
|
assert.equal(resolveGuestPath("/abs/file", "/projects/team"), "/abs/file");
|
|
164
282
|
assert.equal(resolveGuestPath("README.md", "/projects/team"), "/projects/team/README.md");
|
|
@@ -207,10 +325,21 @@ test("container_read reports a missing file and records it as absent", async ()
|
|
|
207
325
|
test("container_edit requires a unique match unless replace_all is set", async () => {
|
|
208
326
|
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
209
327
|
const duplicate = guestFileRecorder("a a a");
|
|
210
|
-
await assert.rejects(() => toolHandlers.container_edit(duplicate.resolver, {
|
|
328
|
+
await assert.rejects(() => toolHandlers.container_edit(duplicate.resolver, {
|
|
329
|
+
container: "default",
|
|
330
|
+
file_path: "f",
|
|
331
|
+
old_string: "a",
|
|
332
|
+
new_string: "b",
|
|
333
|
+
}, exec, fakeToolContext(duplicate.resolver)), /matched 3 times/);
|
|
211
334
|
assert.deepEqual(duplicate.writes, [], "a non-unique match must not be written");
|
|
212
335
|
const all = guestFileRecorder("a a a");
|
|
213
|
-
await toolHandlers.container_edit(all.resolver, {
|
|
336
|
+
await toolHandlers.container_edit(all.resolver, {
|
|
337
|
+
container: "default",
|
|
338
|
+
file_path: "f",
|
|
339
|
+
old_string: "a",
|
|
340
|
+
new_string: "b",
|
|
341
|
+
replace_all: true,
|
|
342
|
+
}, exec, fakeToolContext(all.resolver));
|
|
214
343
|
assert.equal(all.writes[0].content, "b b b");
|
|
215
344
|
});
|
|
216
345
|
test("container command and file tools mirror the built-in arguments", () => {
|
|
@@ -249,8 +378,9 @@ test("container_bash enforces timeoutMs", async () => {
|
|
|
249
378
|
return {
|
|
250
379
|
on(event, handler) {
|
|
251
380
|
(handlers[event] ??= []).push(handler);
|
|
252
|
-
if (event === "data")
|
|
381
|
+
if (event === "data") {
|
|
253
382
|
queueMicrotask(() => handler({ processId: "42" }));
|
|
383
|
+
}
|
|
254
384
|
},
|
|
255
385
|
write() { },
|
|
256
386
|
end() { },
|
|
@@ -269,7 +399,12 @@ test("container_bash enforces timeoutMs", async () => {
|
|
|
269
399
|
defaultCwd: "/projects/team",
|
|
270
400
|
}),
|
|
271
401
|
};
|
|
272
|
-
await assert.rejects(() => toolHandlers.container_bash(resolver, {
|
|
402
|
+
await assert.rejects(() => toolHandlers.container_bash(resolver, {
|
|
403
|
+
container: "default",
|
|
404
|
+
command: "sleep",
|
|
405
|
+
description: "x",
|
|
406
|
+
timeoutMs: 10,
|
|
407
|
+
}, exec), /timed out/);
|
|
273
408
|
assert.equal(signaled.processId, "42");
|
|
274
409
|
assert.equal(signaled.signal, "SIGTERM");
|
|
275
410
|
});
|
|
@@ -314,17 +449,34 @@ test("container_grep resolves its search path like a shell would", async () => {
|
|
|
314
449
|
// With a search path, it is relative to the session directory.
|
|
315
450
|
const plain = guestExecRecorder("/projects/team");
|
|
316
451
|
await toolHandlers.container_grep(plain.resolver, { container: "default", pattern: "TODO", path: "src" }, exec);
|
|
317
|
-
assert.deepEqual(plain.starts[0].argv, [
|
|
452
|
+
assert.deepEqual(plain.starts[0].argv, [
|
|
453
|
+
"/usr/bin/rg",
|
|
454
|
+
"-n",
|
|
455
|
+
"TODO",
|
|
456
|
+
"/projects/team/src",
|
|
457
|
+
]);
|
|
318
458
|
// An include filter maps to ripgrep's --glob.
|
|
319
459
|
const filtered = guestExecRecorder("/projects/team");
|
|
320
460
|
await toolHandlers.container_grep(filtered.resolver, { container: "default", pattern: "TODO", path: "src", include: "*.ts" }, exec);
|
|
321
|
-
assert.deepEqual(filtered.starts[0].argv, [
|
|
461
|
+
assert.deepEqual(filtered.starts[0].argv, [
|
|
462
|
+
"/usr/bin/rg",
|
|
463
|
+
"-n",
|
|
464
|
+
"--glob",
|
|
465
|
+
"*.ts",
|
|
466
|
+
"TODO",
|
|
467
|
+
"/projects/team/src",
|
|
468
|
+
]);
|
|
322
469
|
assert.equal(filtered.starts[0].cwd, "/projects/team");
|
|
323
470
|
const none = guestExecRecorder("/projects/team");
|
|
324
471
|
await toolHandlers.container_grep(none.resolver, { container: "default", pattern: "TODO" }, exec);
|
|
325
472
|
// No path searches the session workspace explicitly: ripgrep would otherwise
|
|
326
473
|
// read its (non-TTY) stdin instead of the working directory.
|
|
327
|
-
assert.deepEqual(none.starts[0].argv, [
|
|
474
|
+
assert.deepEqual(none.starts[0].argv, [
|
|
475
|
+
"/usr/bin/rg",
|
|
476
|
+
"-n",
|
|
477
|
+
"TODO",
|
|
478
|
+
"/projects/team",
|
|
479
|
+
]);
|
|
328
480
|
assert.equal(none.starts[0].cwd, "/projects/team");
|
|
329
481
|
});
|
|
330
482
|
test("container_glob passes the pattern as a glob and scopes the search", async () => {
|
|
@@ -360,7 +512,8 @@ test("container_glob passes the pattern as a glob and scopes the search", async
|
|
|
360
512
|
});
|
|
361
513
|
test("container_glob caps its result at 100 files", async () => {
|
|
362
514
|
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
363
|
-
const lines = (count) => Array.from({ length: count }, (_, index) => `file-${index}.ts`).join("\n") +
|
|
515
|
+
const lines = (count) => Array.from({ length: count }, (_, index) => `file-${index}.ts`).join("\n") +
|
|
516
|
+
"\n";
|
|
364
517
|
const small = guestExecRecorder("/projects/team", lines(3));
|
|
365
518
|
const few = (await toolHandlers.container_glob(small.resolver, { container: "default", pattern: "*" }, exec));
|
|
366
519
|
assert.equal(few.files.length, 3);
|
|
@@ -401,7 +554,9 @@ test("podmanRuntimeSection names only the tools the agent has", () => {
|
|
|
401
554
|
assert.doesNotMatch(full, /plugin/i);
|
|
402
555
|
// The Podman operator preset mounts none of them, so the wording must not
|
|
403
556
|
// name a tool the agent cannot call.
|
|
404
|
-
const containerOnly = podmanRuntimeSection({
|
|
557
|
+
const containerOnly = podmanRuntimeSection({
|
|
558
|
+
tools: { get: () => undefined },
|
|
559
|
+
});
|
|
405
560
|
const short = containerOnly.text({ scope: "agent" });
|
|
406
561
|
for (const tool of ["bash", "read", "write", "edit", "glob", "grep"]) {
|
|
407
562
|
assert.doesNotMatch(short, new RegExp("`" + tool + "`"));
|
|
@@ -416,6 +571,27 @@ test("podmanRuntimeSection names only the tools the agent has", () => {
|
|
|
416
571
|
assert.match(short, /daemon_\*/);
|
|
417
572
|
assert.match(short, /share the host kernel/);
|
|
418
573
|
});
|
|
574
|
+
test("podmanRuntimeSection guides the model to custom images and named volumes", () => {
|
|
575
|
+
const variants = [
|
|
576
|
+
podmanRuntimeSection({ tools: { get: () => ({}) } }),
|
|
577
|
+
podmanRuntimeSection({ tools: { get: () => undefined } }),
|
|
578
|
+
];
|
|
579
|
+
for (const section of variants) {
|
|
580
|
+
const text = section.text({ scope: "agent" });
|
|
581
|
+
// Software installs belong in an image, not a running container.
|
|
582
|
+
assert.match(text, /custom image/);
|
|
583
|
+
assert.match(text, /`image_build`/);
|
|
584
|
+
assert.match(text, /`container_start`/);
|
|
585
|
+
assert.match(text, /`container_recreate`/);
|
|
586
|
+
// Persistence belongs in a named volume, not a tmpfs.
|
|
587
|
+
assert.match(text, /named `volume`/);
|
|
588
|
+
assert.match(text, /`tmpfs`/);
|
|
589
|
+
assert.match(text, /\/tmp/);
|
|
590
|
+
assert.match(text, /clears tmpfs contents/);
|
|
591
|
+
// The guidance stays free of plugin lore: the section reads as facts.
|
|
592
|
+
assert.doesNotMatch(text, /plugin/i);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
419
595
|
test("container_grep fails loudly when ripgrep reports an error", async () => {
|
|
420
596
|
const guest = {
|
|
421
597
|
exec: () => {
|
|
@@ -427,7 +603,9 @@ test("container_grep fails loudly when ripgrep reports an error", async () => {
|
|
|
427
603
|
write() { },
|
|
428
604
|
end() {
|
|
429
605
|
for (const handler of handlers.data ?? []) {
|
|
430
|
-
handler({
|
|
606
|
+
handler({
|
|
607
|
+
stderrChunk: Buffer.from("rg: error parsing glob '[bad'\n"),
|
|
608
|
+
});
|
|
431
609
|
handler({ exit: { exitCode: 2, signaled: false } });
|
|
432
610
|
}
|
|
433
611
|
},
|
package/dist/daemons.test.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { DAEMON_TOOLS } from "./test-support.js";
|
|
7
|
-
import {
|
|
7
|
+
import { publicDaemon, toolHandlers, TOOLS } from "./index.js";
|
|
8
8
|
test("daemon_start accepts optional uid, gid and groups", () => {
|
|
9
9
|
const tool = TOOLS.find((entry) => entry.name === "daemon_start");
|
|
10
10
|
assert.ok(tool, "daemon_start registered");
|
|
@@ -69,10 +69,20 @@ test("daemon tools require no approval and are valid object-rooted schemas", ()
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
test("publicDaemon omits the exit code while the daemon runs", () => {
|
|
72
|
-
const running = publicDaemon({
|
|
72
|
+
const running = publicDaemon({
|
|
73
|
+
name: "web",
|
|
74
|
+
argv: ["sleep", "600"],
|
|
75
|
+
running: true,
|
|
76
|
+
exitCode: 0,
|
|
77
|
+
});
|
|
73
78
|
assert.equal(running.running, true);
|
|
74
79
|
assert.equal("exitCode" in running, false);
|
|
75
|
-
const exited = publicDaemon({
|
|
80
|
+
const exited = publicDaemon({
|
|
81
|
+
name: "web",
|
|
82
|
+
argv: ["sleep", "600"],
|
|
83
|
+
running: false,
|
|
84
|
+
exitCode: 3,
|
|
85
|
+
});
|
|
76
86
|
assert.equal(exited.running, false);
|
|
77
87
|
assert.equal(exited.exitCode, 3);
|
|
78
88
|
});
|
|
@@ -111,7 +121,11 @@ test("daemon_start returns a daemon info object", async () => {
|
|
|
111
121
|
}),
|
|
112
122
|
};
|
|
113
123
|
const exec = { agent: { session: { header: { cwd: "/proj" } } } };
|
|
114
|
-
const out = await toolHandlers.daemon_start(resolver, {
|
|
124
|
+
const out = await toolHandlers.daemon_start(resolver, {
|
|
125
|
+
container: "default",
|
|
126
|
+
argv: ["python3", "-m", "http.server", "8000"],
|
|
127
|
+
name: "web",
|
|
128
|
+
}, exec);
|
|
115
129
|
assert.deepEqual(out, {
|
|
116
130
|
name: "web",
|
|
117
131
|
running: true,
|
package/dist/dsh-version.js
CHANGED
|
@@ -20,7 +20,8 @@ export function dshVersion() {
|
|
|
20
20
|
function resolveDshVersion() {
|
|
21
21
|
let searchPaths;
|
|
22
22
|
try {
|
|
23
|
-
searchPaths =
|
|
23
|
+
searchPaths =
|
|
24
|
+
createRequire(import.meta.url).resolve.paths("@deepseek-ai/dsh") ?? [];
|
|
24
25
|
}
|
|
25
26
|
catch {
|
|
26
27
|
return undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface EnvDraft {
|
|
2
|
+
key: string;
|
|
3
|
+
value: string;
|
|
4
|
+
}
|
|
5
|
+
export interface EnvRow extends EnvDraft {
|
|
6
|
+
pending: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function emptyEnvDraft(): EnvDraft;
|
|
9
|
+
export declare function envRows(env: Record<string, string>, draft: EnvDraft | undefined): EnvRow[];
|
|
10
|
+
export declare function envKeyAvailable(env: Record<string, string>, key: string): boolean;
|
|
11
|
+
export declare function commitEnvDraft(env: Record<string, string>, draft: EnvDraft): Record<string, string> | undefined;
|
|
12
|
+
export declare function renameEnvKey(env: Record<string, string>, index: number, key: string): Record<string, string>;
|
|
13
|
+
export declare function setEnvValue(env: Record<string, string>, index: number, value: string): Record<string, string>;
|
|
14
|
+
export declare function removeEnvRow(env: Record<string, string>, index: number): Record<string, string>;
|
package/dist/env-rows.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
export function emptyEnvDraft() {
|
|
5
|
+
return { key: "", value: "" };
|
|
6
|
+
}
|
|
7
|
+
export function envRows(env, draft) {
|
|
8
|
+
const rows = Object.entries(env).map(([key, value]) => ({
|
|
9
|
+
key,
|
|
10
|
+
value,
|
|
11
|
+
pending: false,
|
|
12
|
+
}));
|
|
13
|
+
if (draft !== undefined)
|
|
14
|
+
rows.push({ ...draft, pending: true });
|
|
15
|
+
return rows;
|
|
16
|
+
}
|
|
17
|
+
// Whether a draft's key can become a variable: non-empty and not already used.
|
|
18
|
+
export function envKeyAvailable(env, key) {
|
|
19
|
+
return key !== "" && !Object.prototype.hasOwnProperty.call(env, key);
|
|
20
|
+
}
|
|
21
|
+
// commitEnvDraft appends the draft to the record once its key is usable, or
|
|
22
|
+
// returns undefined so the caller keeps the row open instead of inventing a
|
|
23
|
+
// placeholder variable name.
|
|
24
|
+
export function commitEnvDraft(env, draft) {
|
|
25
|
+
if (!envKeyAvailable(env, draft.key))
|
|
26
|
+
return undefined;
|
|
27
|
+
return { ...env, [draft.key]: draft.value };
|
|
28
|
+
}
|
|
29
|
+
// Every mutation rebuilds the record in order, so a renamed key stays in place
|
|
30
|
+
// instead of jumping to the end.
|
|
31
|
+
export function renameEnvKey(env, index, key) {
|
|
32
|
+
const next = {};
|
|
33
|
+
Object.entries(env).forEach(([current, value], at) => {
|
|
34
|
+
next[at === index ? key : current] = value;
|
|
35
|
+
});
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
export function setEnvValue(env, index, value) {
|
|
39
|
+
const next = {};
|
|
40
|
+
Object.entries(env).forEach(([key, current], at) => {
|
|
41
|
+
next[key] = at === index ? value : current;
|
|
42
|
+
});
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
export function removeEnvRow(env, index) {
|
|
46
|
+
const next = {};
|
|
47
|
+
Object.entries(env).forEach(([key, value], at) => {
|
|
48
|
+
if (at !== index)
|
|
49
|
+
next[key] = value;
|
|
50
|
+
});
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
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 { commitEnvDraft, emptyEnvDraft, envKeyAvailable, envRows, removeEnvRow, renameEnvKey, setEnvValue, } from "./env-rows.js";
|
|
7
|
+
test("envRows lists the committed entries and then the pending draft", () => {
|
|
8
|
+
assert.deepEqual(envRows({}, undefined), []);
|
|
9
|
+
assert.deepEqual(envRows({ A: "1" }, undefined), [
|
|
10
|
+
{ key: "A", value: "1", pending: false },
|
|
11
|
+
]);
|
|
12
|
+
assert.deepEqual(envRows({ A: "1", B: "2" }, emptyEnvDraft()), [
|
|
13
|
+
{ key: "A", value: "1", pending: false },
|
|
14
|
+
{ key: "B", value: "2", pending: false },
|
|
15
|
+
{ key: "", value: "", pending: true },
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
test("envKeyAvailable accepts only a new, non-empty key", () => {
|
|
19
|
+
assert.equal(envKeyAvailable({}, ""), false);
|
|
20
|
+
assert.equal(envKeyAvailable({ A: "1" }, "A"), false);
|
|
21
|
+
assert.equal(envKeyAvailable({ A: "1" }, "B"), true);
|
|
22
|
+
// A key inherited from the prototype is not an existing variable.
|
|
23
|
+
assert.equal(envKeyAvailable({}, "toString"), true);
|
|
24
|
+
});
|
|
25
|
+
test("commitEnvDraft refuses an empty or duplicate key", () => {
|
|
26
|
+
assert.equal(commitEnvDraft({}, { key: "", value: "x" }), undefined);
|
|
27
|
+
assert.equal(commitEnvDraft({ A: "1" }, { key: "A", value: "x" }), undefined);
|
|
28
|
+
});
|
|
29
|
+
test("commitEnvDraft appends an accepted key with its value", () => {
|
|
30
|
+
assert.deepEqual(commitEnvDraft({ A: "1" }, { key: "B", value: "2" }), {
|
|
31
|
+
A: "1",
|
|
32
|
+
B: "2",
|
|
33
|
+
});
|
|
34
|
+
assert.deepEqual(commitEnvDraft({}, { key: "B", value: "" }), { B: "" });
|
|
35
|
+
});
|
|
36
|
+
test("env transformations keep row order", () => {
|
|
37
|
+
const env = { A: "1", B: "2", C: "3" };
|
|
38
|
+
assert.deepEqual(renameEnvKey(env, 1, "BB"), { A: "1", BB: "2", C: "3" });
|
|
39
|
+
assert.deepEqual(setEnvValue(env, 2, "33"), { A: "1", B: "2", C: "33" });
|
|
40
|
+
assert.deepEqual(removeEnvRow(env, 0), { B: "2", C: "3" });
|
|
41
|
+
// The input record is never mutated.
|
|
42
|
+
assert.deepEqual(env, { A: "1", B: "2", C: "3" });
|
|
43
|
+
});
|
package/dist/fs-provider.d.ts
CHANGED
|
@@ -19,5 +19,6 @@ export interface FilesystemProvider {
|
|
|
19
19
|
mkdir(target: any, parents?: boolean): Promise<any>;
|
|
20
20
|
remove(target: any, recursive?: boolean): Promise<any>;
|
|
21
21
|
editText(target: any, edit: any, expected?: any, signal?: AbortSignal): Promise<any>;
|
|
22
|
+
watch(target: any, changed: (error?: Error) => void, signal?: AbortSignal): Promise<() => Promise<void>>;
|
|
22
23
|
}
|
|
23
24
|
export declare function createFilesystemProvider(resolver: WorkspaceResolver): FilesystemProvider;
|
package/dist/fs-provider.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
|
-
import {
|
|
4
|
+
import { detectLineEndings, DIFF_BASIS_MAX_BYTES, fsError, guestChunks, guestStat, guestStatResponse, guestTextChunks, guestVersion, normalizeLineEndings, readDiffBasis, readGuestText, resolveGuestPath, restoreLineEndings, throwIfAborted, unaryGuest, writeGuestFile, } from "./guest-rpc.js";
|
|
5
5
|
import { isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
6
6
|
export function createFilesystemProvider(resolver) {
|
|
7
7
|
return {
|
|
@@ -10,10 +10,12 @@ export function createFilesystemProvider(resolver) {
|
|
|
10
10
|
const resolved = resolveGuestPath(path, opts?.cwd);
|
|
11
11
|
// An explicit container targets that container's guest; the harness's own
|
|
12
12
|
// tools never pass one and keep the session workspace's default.
|
|
13
|
-
const container = typeof opts?.container === "string"
|
|
13
|
+
const container = typeof opts?.container === "string"
|
|
14
|
+
? opts.container
|
|
15
|
+
: "";
|
|
14
16
|
const binding = container !== "" && container !== "default"
|
|
15
|
-
? await resolver.containerBinding(opts?.cwd, container)
|
|
16
|
-
: await resolver.resolveForPath(resolved, opts?.cwd);
|
|
17
|
+
? await resolver.containerBinding(opts?.cwd, container, opts?.signal)
|
|
18
|
+
: await resolver.resolveForPath(resolved, opts?.cwd, opts?.signal);
|
|
17
19
|
return {
|
|
18
20
|
targetKey: resolved,
|
|
19
21
|
displayPath: resolved,
|
|
@@ -55,7 +57,7 @@ export function createFilesystemProvider(resolver) {
|
|
|
55
57
|
lstat: async (path, opts, signal) => {
|
|
56
58
|
throwIfAborted(signal, "lstat");
|
|
57
59
|
const resolved = resolveGuestPath(path, opts?.cwd);
|
|
58
|
-
const binding = await resolver.resolveForPath(resolved, opts?.cwd);
|
|
60
|
+
const binding = await resolver.resolveForPath(resolved, opts?.cwd, signal);
|
|
59
61
|
const result = await unaryGuest({ binding }, "stat", { path: resolved, noFollow: true }, signal);
|
|
60
62
|
if (!result?.exists)
|
|
61
63
|
return undefined;
|
|
@@ -90,16 +92,18 @@ export function createFilesystemProvider(resolver) {
|
|
|
90
92
|
else if (expected?.kind === "createIfAbsent" && current.exists) {
|
|
91
93
|
throw fsError("FS_NOT_OBSERVED", `cannot overwrite existing "${target.displayPath}" without reading it first`);
|
|
92
94
|
}
|
|
93
|
-
// The basis is gated on
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
95
|
+
// The basis is gated on both sides: an incoming or existing file at or
|
|
96
|
+
// above the limit yields `before: null` and the consumer falls back to a
|
|
97
|
+
// whole-file diff, so writing one byte into a huge file does not read the
|
|
98
|
+
// whole file back just for presentation. Both sides are LF-normalized so
|
|
99
|
+
// a CRLF overwrite does not read as every line changed.
|
|
97
100
|
const rawBasis = current.exists &&
|
|
98
|
-
Buffer.byteLength(content, "utf8") < DIFF_BASIS_MAX_BYTES
|
|
101
|
+
Buffer.byteLength(content, "utf8") < DIFF_BASIS_MAX_BYTES &&
|
|
102
|
+
Number(current.size ?? 0) < DIFF_BASIS_MAX_BYTES
|
|
99
103
|
? await readDiffBasis(target, signal)
|
|
100
104
|
: null;
|
|
101
105
|
const before = rawBasis === null ? null : normalizeLineEndings(rawBasis);
|
|
102
|
-
await writeGuestFile(
|
|
106
|
+
await writeGuestFile(target.binding, target.targetKey, content, { create: true, truncate: true }, signal);
|
|
103
107
|
const after = await guestStatResponse(target, signal);
|
|
104
108
|
return {
|
|
105
109
|
operation: current.exists ? "update" : "create",
|
|
@@ -172,7 +176,7 @@ export function createFilesystemProvider(resolver) {
|
|
|
172
176
|
throw fsError("FS_AMBIGUOUS_EDIT", `old_string matched ${occurrences} times in "${target.displayPath}"; provide a more specific old_string or set replace_all to true`);
|
|
173
177
|
}
|
|
174
178
|
const after = before.split(oldNorm).join(newNorm);
|
|
175
|
-
await writeGuestFile(
|
|
179
|
+
await writeGuestFile(target.binding, target.targetKey, restoreLineEndings(after, lineEndings), { create: true, truncate: true }, signal);
|
|
176
180
|
const afterInfo = await guestStatResponse(target, signal);
|
|
177
181
|
return {
|
|
178
182
|
version: guestVersion(afterInfo),
|
|
@@ -180,5 +184,15 @@ export function createFilesystemProvider(resolver) {
|
|
|
180
184
|
after,
|
|
181
185
|
};
|
|
182
186
|
},
|
|
187
|
+
// Watching would need a watcher inside each workspace's container (inotify
|
|
188
|
+
// in the guest, or host-side polling of every open tree node), and every
|
|
189
|
+
// container here is remote and disposable. Report the seam's typed
|
|
190
|
+
// "unsupported" failure instead, as the harness's SSH provider does: the
|
|
191
|
+
// workspace file tree then refreshes on demand rather than live. `changed`
|
|
192
|
+
// is never called — the promise rejects before a watcher becomes active.
|
|
193
|
+
watch: (_target, _changed, signal) => {
|
|
194
|
+
throwIfAborted(signal, "watch");
|
|
195
|
+
return Promise.reject(fsError("FS_IO_ERROR", "Filesystem watching is not supported by this provider."));
|
|
196
|
+
},
|
|
183
197
|
};
|
|
184
198
|
}
|