@exagone313/dsh-podman 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +17 -0
- package/dist/approval-reasons.d.ts +129 -0
- package/dist/approval-reasons.js +253 -0
- package/dist/approval-reasons.test.d.ts +1 -0
- package/dist/approval-reasons.test.js +115 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.js +412 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +462 -0
- package/dist/card-route.d.ts +15 -0
- package/dist/card-route.js +478 -0
- package/dist/card-route.test.d.ts +1 -0
- package/dist/card-route.test.js +181 -0
- package/dist/card-test-support.d.ts +15 -0
- package/dist/card-test-support.js +160 -0
- package/dist/client/ContainerCard.d.ts +6 -0
- package/dist/client/ContainerCard.js +79 -0
- package/dist/client/card-client.d.ts +6 -0
- package/dist/client/card-client.js +47 -0
- package/dist/client/card-protocol.d.ts +101 -0
- package/dist/client/card-protocol.js +10 -0
- package/dist/client/container-card-caches.d.ts +10 -0
- package/dist/client/container-card-caches.js +31 -0
- package/dist/client/container-card-controller.d.ts +107 -0
- package/dist/client/container-card-controller.js +207 -0
- package/dist/client/container-card-create-modal.d.ts +24 -0
- package/dist/client/container-card-create-modal.js +109 -0
- package/dist/client/container-card-directory-styles.d.ts +5 -0
- package/dist/client/container-card-directory-styles.js +246 -0
- package/dist/client/container-card-directory.d.ts +12 -0
- package/dist/client/container-card-directory.js +144 -0
- package/dist/client/container-card-editors.d.ts +30 -0
- package/dist/client/container-card-editors.js +116 -0
- package/dist/client/container-card-images.d.ts +37 -0
- package/dist/client/container-card-images.js +35 -0
- package/dist/client/container-card-paths.d.ts +20 -0
- package/dist/client/container-card-paths.js +88 -0
- package/dist/client/container-card-row.d.ts +30 -0
- package/dist/client/container-card-row.js +54 -0
- package/dist/client/container-card-secrets.d.ts +21 -0
- package/dist/client/container-card-secrets.js +71 -0
- package/dist/client/container-card-shared.d.ts +55 -0
- package/dist/client/container-card-shared.js +152 -0
- package/dist/client/container-card-styles.d.ts +21 -0
- package/dist/client/container-card-styles.js +143 -0
- package/dist/client/container-card-volumes.d.ts +12 -0
- package/dist/client/container-card-volumes.js +37 -0
- package/dist/client/container-card-workspace.d.ts +31 -0
- package/dist/client/container-card-workspace.js +24 -0
- package/dist/client/directory-picker.d.ts +12 -0
- package/dist/client/directory-picker.js +30 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +4419 -0
- package/dist/client/locales.d.ts +9 -0
- package/dist/client/locales.js +370 -0
- package/dist/client/read-only-approval.d.ts +8 -0
- package/dist/client/read-only-approval.js +63 -0
- package/dist/client/slot-contract.d.ts +12 -0
- package/dist/client/slot-contract.js +4 -0
- package/dist/client/terminal-styles.d.ts +5 -0
- package/dist/client/terminal-styles.js +31 -0
- package/dist/client/tool-views.d.ts +8 -0
- package/dist/client/tool-views.js +241 -0
- package/dist/containers.test.d.ts +1 -0
- package/dist/containers.test.js +443 -0
- package/dist/daemons.test.d.ts +1 -0
- package/dist/daemons.test.js +153 -0
- package/dist/fs-provider.d.ts +23 -0
- package/dist/fs-provider.js +184 -0
- package/dist/fs-provider.test.d.ts +1 -0
- package/dist/fs-provider.test.js +260 -0
- package/dist/generated/version.d.ts +2 -0
- package/dist/generated/version.js +6 -0
- package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
- package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
- package/dist/grpc/runtime-client.d.ts +5 -0
- package/dist/grpc/runtime-client.js +43 -0
- package/dist/guest-rpc.d.ts +62 -0
- package/dist/guest-rpc.js +391 -0
- package/dist/images.test.d.ts +1 -0
- package/dist/images.test.js +79 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +140 -0
- package/dist/misc.test.d.ts +1 -0
- package/dist/misc.test.js +141 -0
- package/dist/mount-enums.d.ts +22 -0
- package/dist/mount-enums.js +81 -0
- package/dist/mount-input.d.ts +14 -0
- package/dist/mount-input.js +64 -0
- package/dist/mounts.test.d.ts +1 -0
- package/dist/mounts.test.js +408 -0
- package/dist/output-reader.d.ts +34 -0
- package/dist/output-reader.js +87 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +74 -0
- package/dist/preferences.d.ts +6 -0
- package/dist/preferences.js +27 -0
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.js +73 -0
- package/dist/project-path.test.d.ts +1 -0
- package/dist/project-path.test.js +52 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +149 -0
- package/dist/public.d.ts +4 -0
- package/dist/public.js +63 -0
- package/dist/read-only-shell.d.ts +14 -0
- package/dist/read-only-shell.js +150 -0
- package/dist/read-only-shell.test.d.ts +1 -0
- package/dist/read-only-shell.test.js +127 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +181 -0
- package/dist/settings-commands.test.d.ts +1 -0
- package/dist/settings-commands.test.js +549 -0
- package/dist/settings-create.test.d.ts +1 -0
- package/dist/settings-create.test.js +394 -0
- package/dist/settings-mounts.test.d.ts +1 -0
- package/dist/settings-mounts.test.js +467 -0
- package/dist/settings-paths.test.d.ts +1 -0
- package/dist/settings-paths.test.js +65 -0
- package/dist/settings-schema.d.ts +14 -0
- package/dist/settings-schema.js +14 -0
- package/dist/settings-workspaces.test.d.ts +1 -0
- package/dist/settings-workspaces.test.js +265 -0
- package/dist/spill-store.d.ts +17 -0
- package/dist/spill-store.js +54 -0
- package/dist/spill-store.test.d.ts +1 -0
- package/dist/spill-store.test.js +59 -0
- package/dist/subprocess.d.ts +9 -0
- package/dist/subprocess.js +369 -0
- package/dist/subprocess.test.d.ts +1 -0
- package/dist/subprocess.test.js +234 -0
- package/dist/test-support.d.ts +328 -0
- package/dist/test-support.js +437 -0
- package/dist/tool-defs.d.ts +26 -0
- package/dist/tool-defs.js +188 -0
- package/dist/tool-handlers.d.ts +2 -0
- package/dist/tool-handlers.js +546 -0
- package/dist/tool-params.d.ts +803 -0
- package/dist/tool-params.js +494 -0
- package/dist/tool-schemas.d.ts +2 -0
- package/dist/tool-schemas.js +5 -0
- package/dist/tool-views.d.ts +2 -0
- package/dist/tool-views.js +56 -0
- package/dist/views.test.d.ts +1 -0
- package/dist/views.test.js +23 -0
- package/dist/volumes.test.d.ts +1 -0
- package/dist/volumes.test.js +40 -0
- package/dist/workspace-binding.d.ts +40 -0
- package/dist/workspace-binding.js +275 -0
- package/dist/workspace-binding.test.d.ts +1 -0
- package/dist/workspace-binding.test.js +337 -0
- package/docs/architecture.md +173 -0
- package/docs/architecture.zh.md +129 -0
- package/docs/configuration.md +160 -0
- package/docs/configuration.zh.md +147 -0
- package/docs/development.md +142 -0
- package/docs/development.zh.md +135 -0
- package/docs/install-dsh-and-dsh-podman.md +165 -0
- package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
- package/docs/usage.md +393 -0
- package/docs/usage.zh.md +315 -0
- package/package.json +119 -0
|
@@ -0,0 +1,408 @@
|
|
|
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 { MOUNT_EXEC, MOUNT_TOOLS, guestExecRecorder, mountRequestRecorder, testReadSession, WORKSPACE_ID, } from "./test-support.js";
|
|
7
|
+
import { TOOLS, approvalDecision, preExecutePolicy, summarizeArgs, toolHandlers } from "./index.js";
|
|
8
|
+
import { forcedMountMode } from "./mount-enums.js";
|
|
9
|
+
test("container_start approval depends on mounts being passed", () => {
|
|
10
|
+
const tool = TOOLS.find((entry) => entry.name === "container_start");
|
|
11
|
+
assert.ok(tool, "container_start registered");
|
|
12
|
+
assert.notEqual(tool.approval, true, "container_start must not always ask");
|
|
13
|
+
assert.ok(typeof tool.approvalWhen === "function", "container_start approvalWhen");
|
|
14
|
+
assert.equal(approvalDecision("container_start", { container: "web" }), undefined, "no mounts: must not ask");
|
|
15
|
+
assert.equal(approvalDecision("container_start", { container: "web", mounts: [] }), undefined, "empty mounts: must not ask");
|
|
16
|
+
const decision = approvalDecision("container_start", {
|
|
17
|
+
container: "web",
|
|
18
|
+
image: "localhost/dsh-podman/nginx:latest",
|
|
19
|
+
mounts: [
|
|
20
|
+
{ project: "team/src", mode: "read_only" },
|
|
21
|
+
{ project: "team", destination: "/workspace/team", mode: "read_write" },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
assert.ok(decision, "mounts passed: must ask");
|
|
25
|
+
assert.equal(decision.kind, "ask");
|
|
26
|
+
assert.equal(decision.reason, 'Start container "web" from image "localhost/dsh-podman/nginx:latest" with mounts: project "team/src" (read-only), project "team" at "/workspace/team" (read-write).');
|
|
27
|
+
});
|
|
28
|
+
test("summarizeArgs infers the mount target when kind is omitted", () => {
|
|
29
|
+
assert.equal(summarizeArgs("container_mount_remove", {
|
|
30
|
+
container: "valkey-ctr",
|
|
31
|
+
secret: "valkey-tls",
|
|
32
|
+
}), 'Remove mount from container "valkey-ctr": secret "valkey-tls".');
|
|
33
|
+
assert.equal(summarizeArgs("container_mount_add", {
|
|
34
|
+
container: "valkey-ctr",
|
|
35
|
+
volume: "valkey-data",
|
|
36
|
+
}), 'Add mount to container "valkey-ctr": volume "valkey-data" (read-only).');
|
|
37
|
+
});
|
|
38
|
+
test("preExecutePolicy denies project mounts that carry a destination", async () => {
|
|
39
|
+
const deny = (await preExecutePolicy({
|
|
40
|
+
name: "container_mount_add",
|
|
41
|
+
arguments: { kind: "project", project: "team/src", destination: "/custom" },
|
|
42
|
+
}, () => Promise.resolve({ kind: "allow" }), () => "/projects"));
|
|
43
|
+
assert.equal(deny.kind, "deny", "must deny instead of asking");
|
|
44
|
+
assert.equal(deny.reason, 'Project mounts cannot set a destination; "team/src" always mounts at "/projects/team/src".');
|
|
45
|
+
const startDeny = (await preExecutePolicy({
|
|
46
|
+
name: "container_start",
|
|
47
|
+
arguments: {
|
|
48
|
+
container: "web",
|
|
49
|
+
mounts: [
|
|
50
|
+
{ project: "team", mode: "read_only" },
|
|
51
|
+
{ kind: "project", project: "team", destination: "/x" },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
}, () => Promise.resolve({ kind: "allow" }), () => "/projects"));
|
|
55
|
+
assert.equal(startDeny.kind, "deny", "a mounts array with a project destination must deny");
|
|
56
|
+
const volumeAllowed = (await preExecutePolicy({
|
|
57
|
+
name: "container_mount_add",
|
|
58
|
+
arguments: { kind: "volume", volume: "data", destination: "/data" },
|
|
59
|
+
}, () => Promise.resolve({ kind: "ask", reason: "x" })));
|
|
60
|
+
assert.equal(volumeAllowed.kind, "ask", "non-project mounts with a destination must still ask");
|
|
61
|
+
const underNever = (await preExecutePolicy({
|
|
62
|
+
name: "container_mount_add",
|
|
63
|
+
arguments: { kind: "project", project: "team", destination: "/x" },
|
|
64
|
+
agent: { session: { facts: { policy: "never" } } },
|
|
65
|
+
}, () => Promise.resolve({ kind: "allow" }), () => "/projects", undefined, testReadSession));
|
|
66
|
+
assert.equal(underNever.kind, "deny", "full access must not accept an invalid project mount");
|
|
67
|
+
});
|
|
68
|
+
test("mount tools are registered with the expected schemas", () => {
|
|
69
|
+
const listTool = TOOLS.find((entry) => entry.name === "container_mount_list");
|
|
70
|
+
assert.ok(listTool, "container_mount_list registered");
|
|
71
|
+
assert.notEqual(listTool.approval, true, "container_mount_list must not require approval");
|
|
72
|
+
assert.deepEqual(listTool.parameters.required, ["container"]);
|
|
73
|
+
const addTool = TOOLS.find((entry) => entry.name === "container_mount_add");
|
|
74
|
+
assert.ok(addTool, "container_mount_add registered");
|
|
75
|
+
assert.equal(addTool.approval, true, "container_mount_add must require approval");
|
|
76
|
+
assert.deepEqual(addTool.parameters.required, ["container"]);
|
|
77
|
+
assert.deepEqual(addTool.parameters.properties.mode.enum, [
|
|
78
|
+
"read_only",
|
|
79
|
+
"read_write",
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(addTool.parameters.properties.kind.enum, [
|
|
82
|
+
"project",
|
|
83
|
+
"tmpfs",
|
|
84
|
+
"volume",
|
|
85
|
+
"secret",
|
|
86
|
+
]);
|
|
87
|
+
assert.equal(typeof addTool.parameters.properties.volume, "object", "container_mount_add accepts a volume");
|
|
88
|
+
assert.equal(typeof addTool.parameters.properties.secret, "object", "container_mount_add accepts a secret");
|
|
89
|
+
const removeTool = TOOLS.find((entry) => entry.name === "container_mount_remove");
|
|
90
|
+
assert.ok(removeTool, "container_mount_remove registered");
|
|
91
|
+
assert.equal(removeTool.approval, true, "container_mount_remove must require approval");
|
|
92
|
+
assert.deepEqual(removeTool.parameters.required, ["container"]);
|
|
93
|
+
assert.deepEqual(removeTool.parameters.properties.kind.enum, [
|
|
94
|
+
"project",
|
|
95
|
+
"tmpfs",
|
|
96
|
+
"volume",
|
|
97
|
+
"secret",
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(typeof removeTool.parameters.properties.volume, "object", "container_mount_remove accepts a volume");
|
|
100
|
+
assert.equal(typeof removeTool.parameters.properties.secret, "object", "container_mount_remove accepts a secret");
|
|
101
|
+
assert.equal(typeof removeTool.parameters.properties.destination, "object", "container_mount_remove accepts a destination");
|
|
102
|
+
const updateTool = TOOLS.find((entry) => entry.name === "container_mount_update");
|
|
103
|
+
assert.ok(updateTool, "container_mount_update registered");
|
|
104
|
+
assert.equal(updateTool.approval, true, "container_mount_update must require approval");
|
|
105
|
+
assert.deepEqual(updateTool.parameters.required, ["container", "mode"]);
|
|
106
|
+
assert.deepEqual(updateTool.parameters.properties.kind.enum, [
|
|
107
|
+
"project",
|
|
108
|
+
"volume",
|
|
109
|
+
]);
|
|
110
|
+
assert.deepEqual(updateTool.parameters.properties.mode.enum, [
|
|
111
|
+
"read_only",
|
|
112
|
+
"read_write",
|
|
113
|
+
]);
|
|
114
|
+
assert.equal(typeof updateTool.parameters.properties.destination, "object", "container_mount_update accepts a destination");
|
|
115
|
+
});
|
|
116
|
+
test("an omitted mount kind is inferred from the source field", async () => {
|
|
117
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
118
|
+
await toolHandlers.container_mount_remove(resolver, { container: "valkey-ctr", secret: "valkey-tls" }, MOUNT_EXEC);
|
|
119
|
+
await toolHandlers.container_mount_add(resolver, { container: "valkey-ctr", volume: "valkey-data", destination: "/data" }, MOUNT_EXEC);
|
|
120
|
+
assert.deepEqual(requests[0], [
|
|
121
|
+
"removeContainerMount",
|
|
122
|
+
{
|
|
123
|
+
workspaceSlug: WORKSPACE_ID,
|
|
124
|
+
container: "valkey-ctr",
|
|
125
|
+
kind: "MOUNT_KIND_SECRET",
|
|
126
|
+
secret: "valkey-tls",
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
assert.deepEqual(requests[1], [
|
|
130
|
+
"addContainerMount",
|
|
131
|
+
{
|
|
132
|
+
workspaceSlug: WORKSPACE_ID,
|
|
133
|
+
container: "valkey-ctr",
|
|
134
|
+
kind: "MOUNT_KIND_VOLUME",
|
|
135
|
+
volume: "valkey-data",
|
|
136
|
+
destination: "/data",
|
|
137
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
test("container_mount_update maps the selector and mode", async () => {
|
|
142
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
143
|
+
await toolHandlers.container_mount_update(resolver, { container: "valkey-ctr", kind: "project", project: "team/src", mode: "read_only" }, MOUNT_EXEC);
|
|
144
|
+
await toolHandlers.container_mount_update(resolver, { container: "valkey-ctr", volume: "valkey-data", destination: "/data", mode: "read_write" }, MOUNT_EXEC);
|
|
145
|
+
assert.deepEqual(requests[0], [
|
|
146
|
+
"updateContainerMount",
|
|
147
|
+
{
|
|
148
|
+
workspaceSlug: WORKSPACE_ID,
|
|
149
|
+
container: "valkey-ctr",
|
|
150
|
+
kind: "MOUNT_KIND_PROJECT",
|
|
151
|
+
project: "team/src",
|
|
152
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
assert.deepEqual(requests[1], [
|
|
156
|
+
"updateContainerMount",
|
|
157
|
+
{
|
|
158
|
+
workspaceSlug: WORKSPACE_ID,
|
|
159
|
+
container: "valkey-ctr",
|
|
160
|
+
kind: "MOUNT_KIND_VOLUME",
|
|
161
|
+
volume: "valkey-data",
|
|
162
|
+
destination: "/data",
|
|
163
|
+
mode: "MOUNT_MODE_READ_WRITE",
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
test("container_mount_update rejects kinds without a mode", async () => {
|
|
168
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
169
|
+
await assert.rejects(() => toolHandlers.container_mount_update(resolver, { container: "web", kind: "tmpfs", destination: "/mnt", mode: "read_only" }, MOUNT_EXEC), /only project and volume mounts carry a mode; tmpfs mounts cannot be remounted/);
|
|
170
|
+
await assert.rejects(() => toolHandlers.container_mount_update(resolver, { container: "web", project: "team", mode: "rw" }, MOUNT_EXEC), /unknown mount mode: rw/);
|
|
171
|
+
assert.deepEqual(requests, [], "a rejected update must not reach the orchestrator");
|
|
172
|
+
});
|
|
173
|
+
test("summarizeArgs renders the remount reason", () => {
|
|
174
|
+
assert.equal(summarizeArgs("container_mount_update", {
|
|
175
|
+
container: "valkey-ctr",
|
|
176
|
+
kind: "project",
|
|
177
|
+
project: "team",
|
|
178
|
+
mode: "read_only",
|
|
179
|
+
}), 'Update mount in container "valkey-ctr": remount project "team" to read-only.');
|
|
180
|
+
assert.equal(summarizeArgs("container_mount_update", {
|
|
181
|
+
container: "valkey-ctr",
|
|
182
|
+
volume: "data",
|
|
183
|
+
destination: "/data",
|
|
184
|
+
mode: "read_write",
|
|
185
|
+
}), 'Update mount in container "valkey-ctr": remount volume "data" at "/data" to read-write.');
|
|
186
|
+
});
|
|
187
|
+
test("container_mount_add rejects unknown mount kinds and modes", async () => {
|
|
188
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
189
|
+
await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", kind: "bind", project: "team" }, MOUNT_EXEC), /unknown mount kind: bind/);
|
|
190
|
+
await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", project: "team", mode: "rw" }, MOUNT_EXEC), /unknown mount mode: rw/);
|
|
191
|
+
assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
|
|
192
|
+
});
|
|
193
|
+
test("container_mount_add rejects a destination on a project mount", async () => {
|
|
194
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
195
|
+
await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", kind: "project", project: "team/src", destination: "/custom" }, MOUNT_EXEC), /Project mounts cannot set a destination; "team\/src" always mounts at "\/projects\/team\/src"/);
|
|
196
|
+
await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", project: "team", destination: "/custom" }, MOUNT_EXEC), /Project mounts cannot set a destination/);
|
|
197
|
+
assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
|
|
198
|
+
await toolHandlers.container_mount_add(resolver, { container: "web", kind: "volume", volume: "data", destination: "/data" }, MOUNT_EXEC);
|
|
199
|
+
assert.equal(requests.length, 1, "a volume mount with a destination is still accepted");
|
|
200
|
+
});
|
|
201
|
+
test("container_start rejects a destination on a project mount", async () => {
|
|
202
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
203
|
+
await assert.rejects(() => toolHandlers.container_start(resolver, {
|
|
204
|
+
container: "web",
|
|
205
|
+
image: "img1",
|
|
206
|
+
mounts: [{ project: "team", destination: "/custom" }],
|
|
207
|
+
}, MOUNT_EXEC), /Project mounts cannot set a destination/);
|
|
208
|
+
assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
|
|
209
|
+
});
|
|
210
|
+
test("container_mount_remove rejects unknown mount kinds", async () => {
|
|
211
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
212
|
+
await assert.rejects(() => toolHandlers.container_mount_remove(resolver, { container: "web", kind: "bind", project: "team" }, MOUNT_EXEC), /unknown mount kind: bind/);
|
|
213
|
+
assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
|
|
214
|
+
});
|
|
215
|
+
test("adding a tmpfs mount without a mode stays read_write", async () => {
|
|
216
|
+
// The orchestrator rejects a read-only tmpfs, so the read-only default must
|
|
217
|
+
// not apply to it.
|
|
218
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
219
|
+
await toolHandlers.container_mount_add(resolver, { container: "web", kind: "tmpfs", destination: "/scratch" }, MOUNT_EXEC);
|
|
220
|
+
assert.deepEqual(requests, [
|
|
221
|
+
["addContainerMount", {
|
|
222
|
+
workspaceSlug: WORKSPACE_ID,
|
|
223
|
+
container: "web",
|
|
224
|
+
kind: "MOUNT_KIND_TMPFS",
|
|
225
|
+
destination: "/scratch",
|
|
226
|
+
mode: "MOUNT_MODE_READ_WRITE",
|
|
227
|
+
}],
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
test("mount tools default a missing kind to project and a missing mode to read_only", async () => {
|
|
231
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
232
|
+
await toolHandlers.container_mount_add(resolver, { container: "web", project: "team" }, MOUNT_EXEC);
|
|
233
|
+
await toolHandlers.container_mount_add(resolver, {
|
|
234
|
+
container: "web",
|
|
235
|
+
kind: "volume",
|
|
236
|
+
volume: "valkey-data",
|
|
237
|
+
destination: "/data",
|
|
238
|
+
mode: "read_only",
|
|
239
|
+
}, MOUNT_EXEC);
|
|
240
|
+
await toolHandlers.container_mount_remove(resolver, { container: "web", project: "team" }, MOUNT_EXEC);
|
|
241
|
+
assert.deepEqual(requests, [
|
|
242
|
+
["addContainerMount", {
|
|
243
|
+
workspaceSlug: WORKSPACE_ID,
|
|
244
|
+
container: "web",
|
|
245
|
+
kind: "MOUNT_KIND_PROJECT",
|
|
246
|
+
project: "team",
|
|
247
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
248
|
+
}],
|
|
249
|
+
["addContainerMount", {
|
|
250
|
+
workspaceSlug: WORKSPACE_ID,
|
|
251
|
+
container: "web",
|
|
252
|
+
kind: "MOUNT_KIND_VOLUME",
|
|
253
|
+
volume: "valkey-data",
|
|
254
|
+
destination: "/data",
|
|
255
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
256
|
+
}],
|
|
257
|
+
["removeContainerMount", {
|
|
258
|
+
workspaceSlug: WORKSPACE_ID,
|
|
259
|
+
container: "web",
|
|
260
|
+
kind: "MOUNT_KIND_PROJECT",
|
|
261
|
+
project: "team",
|
|
262
|
+
}],
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
test("container start and recreate reject unknown mount kinds and modes", async () => {
|
|
266
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
267
|
+
await assert.rejects(() => toolHandlers.container_start(resolver, { container: "web", mounts: [{ project: "team", kind: "bind" }] }, MOUNT_EXEC), /unknown mount kind: bind/);
|
|
268
|
+
await assert.rejects(() => toolHandlers.container_recreate(resolver, { container: "web", mounts: [{ project: "team", mode: "rw" }] }, MOUNT_EXEC), /unknown mount mode: rw/);
|
|
269
|
+
assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
|
|
270
|
+
});
|
|
271
|
+
test("container start maps valid mounts and defaults the mode to read_only", async () => {
|
|
272
|
+
const { requests, resolver } = mountRequestRecorder();
|
|
273
|
+
await toolHandlers.container_start(resolver, {
|
|
274
|
+
container: "web",
|
|
275
|
+
image: "img1",
|
|
276
|
+
mounts: [
|
|
277
|
+
{ project: "team/src", mode: "read_only" },
|
|
278
|
+
{ project: "team", mode: "read_write" },
|
|
279
|
+
{ kind: "volume", volume: "valkey-data", destination: "/data" },
|
|
280
|
+
{ kind: "secret", secret: "valkey-tls", destination: "/run/secrets/tls" },
|
|
281
|
+
{ kind: "tmpfs", destination: "/scratch" },
|
|
282
|
+
],
|
|
283
|
+
}, MOUNT_EXEC);
|
|
284
|
+
assert.deepEqual(requests, [
|
|
285
|
+
["startContainer", {
|
|
286
|
+
workspaceSlug: WORKSPACE_ID,
|
|
287
|
+
container: "web",
|
|
288
|
+
imageId: "img1",
|
|
289
|
+
mounts: [
|
|
290
|
+
{
|
|
291
|
+
projectName: "team/src",
|
|
292
|
+
kind: "MOUNT_KIND_PROJECT",
|
|
293
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
projectName: "team",
|
|
297
|
+
kind: "MOUNT_KIND_PROJECT",
|
|
298
|
+
mode: "MOUNT_MODE_READ_WRITE",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
projectName: "",
|
|
302
|
+
kind: "MOUNT_KIND_VOLUME",
|
|
303
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
304
|
+
destination: "/data",
|
|
305
|
+
volume: "valkey-data",
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
projectName: "",
|
|
309
|
+
kind: "MOUNT_KIND_SECRET",
|
|
310
|
+
mode: "MOUNT_MODE_READ_ONLY",
|
|
311
|
+
destination: "/run/secrets/tls",
|
|
312
|
+
secret: "valkey-tls",
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
projectName: "",
|
|
316
|
+
kind: "MOUNT_KIND_TMPFS",
|
|
317
|
+
mode: "MOUNT_MODE_READ_WRITE",
|
|
318
|
+
destination: "/scratch",
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
}],
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
test("mount schemas are object-rooted without per-property required", () => {
|
|
325
|
+
for (const name of MOUNT_TOOLS) {
|
|
326
|
+
const tool = TOOLS.find((entry) => entry.name === name);
|
|
327
|
+
assert.ok(tool, `${name} registered`);
|
|
328
|
+
assert.equal(tool.parameters.type, "object", `${name} type`);
|
|
329
|
+
assert.equal(typeof tool.parameters.properties, "object");
|
|
330
|
+
assert.ok(Array.isArray(tool.parameters.required));
|
|
331
|
+
for (const key of Object.keys(tool.parameters.properties)) {
|
|
332
|
+
const property = tool.parameters.properties[key];
|
|
333
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(property, "required"), `${name}.${key} must not carry per-property required`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const name of ["container_start", "container_recreate"]) {
|
|
337
|
+
const tool = TOOLS.find((entry) => entry.name === name);
|
|
338
|
+
assert.ok(tool, `${name} registered`);
|
|
339
|
+
const mounts = tool.parameters.properties.mounts;
|
|
340
|
+
assert.equal(mounts.type, "array", `${name}.mounts type`);
|
|
341
|
+
assert.equal(mounts.items.type, "object", `${name}.mounts items type`);
|
|
342
|
+
assert.equal(mounts.items.additionalProperties, false);
|
|
343
|
+
assert.deepEqual(mounts.items.required, []);
|
|
344
|
+
for (const key of Object.keys(mounts.items.properties)) {
|
|
345
|
+
const property = mounts.items.properties[key];
|
|
346
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(property, "required"), `${name}.mounts.items.${key} must not carry per-property required`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
test("container_mount_list returns mount objects", async () => {
|
|
351
|
+
const resolver = {
|
|
352
|
+
registry: {
|
|
353
|
+
resolveByPath: async () => ({ id: WORKSPACE_ID }),
|
|
354
|
+
},
|
|
355
|
+
control: async () => ({
|
|
356
|
+
containers: [
|
|
357
|
+
{
|
|
358
|
+
workspaceSlug: WORKSPACE_ID,
|
|
359
|
+
containerName: "default",
|
|
360
|
+
mounts: [
|
|
361
|
+
{ projectName: "team", mode: "MOUNT_MODE_READ_WRITE", kind: "MOUNT_KIND_PROJECT" },
|
|
362
|
+
{ volume: "myvol", destination: "/data", mode: "MOUNT_MODE_READ_ONLY", kind: "MOUNT_KIND_VOLUME" },
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
}),
|
|
367
|
+
};
|
|
368
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
369
|
+
const out = await toolHandlers.container_mount_list(resolver, { container: "default" }, exec);
|
|
370
|
+
assert.deepEqual(out, [
|
|
371
|
+
{ projectName: "team", mode: "read_write", kind: "project" },
|
|
372
|
+
{ volume: "myvol", destination: "/data", mode: "read_only", kind: "volume" },
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
test("command tools default to the session directory when mounted", async () => {
|
|
376
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
377
|
+
const bash = guestExecRecorder("/projects/team");
|
|
378
|
+
await toolHandlers.container_bash(bash.resolver, { container: "default", command: "ls" }, exec);
|
|
379
|
+
assert.equal(bash.starts[0].cwd, "/projects/team");
|
|
380
|
+
const run = guestExecRecorder("/projects/team");
|
|
381
|
+
await toolHandlers.container_exec(run.resolver, { container: "default", argv: ["ls"] }, exec);
|
|
382
|
+
assert.equal(run.starts[0].cwd, "/projects/team");
|
|
383
|
+
const glob = guestExecRecorder("/projects/team");
|
|
384
|
+
await toolHandlers.container_glob(glob.resolver, { container: "default", pattern: "*.ts" }, exec);
|
|
385
|
+
assert.equal(glob.starts[0].cwd, "/projects/team");
|
|
386
|
+
const grep = guestExecRecorder("/projects/team");
|
|
387
|
+
await toolHandlers.container_grep(grep.resolver, { container: "default", pattern: "TODO" }, exec);
|
|
388
|
+
assert.equal(grep.starts[0].cwd, "/projects/team");
|
|
389
|
+
});
|
|
390
|
+
test("command tools fall back to the guest cwd when the session directory is not mounted", async () => {
|
|
391
|
+
const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
|
|
392
|
+
const bare = guestExecRecorder();
|
|
393
|
+
await toolHandlers.container_exec(bare.resolver, { container: "db", argv: ["ls"] }, exec);
|
|
394
|
+
assert.equal(bare.starts[0].cwd, undefined);
|
|
395
|
+
// An explicit working directory still resolves without a mounted default.
|
|
396
|
+
const explicit = guestExecRecorder();
|
|
397
|
+
await toolHandlers.container_exec(explicit.resolver, { container: "db", argv: ["ls"], workdir: "/data" }, exec);
|
|
398
|
+
assert.equal(explicit.starts[0].cwd, "/data");
|
|
399
|
+
});
|
|
400
|
+
test("forcedMountMode fixes tmpfs and secret and leaves project and volume editable", () => {
|
|
401
|
+
// The orchestrator rejects a read-only tmpfs and exposes secrets read-only,
|
|
402
|
+
// so the card shows those two as a fixed mode.
|
|
403
|
+
assert.equal(forcedMountMode("tmpfs"), "read_write");
|
|
404
|
+
assert.equal(forcedMountMode("secret"), "read_only");
|
|
405
|
+
assert.equal(forcedMountMode("project"), undefined);
|
|
406
|
+
assert.equal(forcedMountMode("volume"), undefined);
|
|
407
|
+
assert.equal(forcedMountMode(undefined), undefined);
|
|
408
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const SPILL_ROOT = "/tmp/dsh-podman";
|
|
2
|
+
interface SpillSpec {
|
|
3
|
+
path: string;
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
}
|
|
6
|
+
export interface OutputReader {
|
|
7
|
+
append(data: Buffer): void;
|
|
8
|
+
readFrom(offset: number): {
|
|
9
|
+
text: string;
|
|
10
|
+
nextOffset: number;
|
|
11
|
+
lossy: boolean;
|
|
12
|
+
spillPath?: string;
|
|
13
|
+
};
|
|
14
|
+
setSpillValid(valid: boolean): void;
|
|
15
|
+
readonly spillPath: string | undefined;
|
|
16
|
+
readonly spillNeeded: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function outputReader(mode: unknown, spill?: SpillSpec): OutputReader | undefined;
|
|
19
|
+
export declare function spillTargetFor(mode: unknown, label: string): {
|
|
20
|
+
target?: {
|
|
21
|
+
path: string;
|
|
22
|
+
maxBytes: number;
|
|
23
|
+
};
|
|
24
|
+
spec?: SpillSpec;
|
|
25
|
+
};
|
|
26
|
+
export declare function splitEnv(env: unknown): {
|
|
27
|
+
env: Record<string, string>;
|
|
28
|
+
unsetEnv: string[];
|
|
29
|
+
};
|
|
30
|
+
export declare function discardUnneededSpill(binding: {
|
|
31
|
+
guest: any;
|
|
32
|
+
token: string;
|
|
33
|
+
}, reader: OutputReader | undefined): void;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import { unaryGuest } from "./guest-rpc.js";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
// The container-local directory the orchestrator mounts read-write for
|
|
7
|
+
// command-output spill files. Must match the orchestrator's `spillRoot`.
|
|
8
|
+
export const SPILL_ROOT = "/tmp/dsh-podman";
|
|
9
|
+
export function outputReader(mode, spill) {
|
|
10
|
+
if (typeof mode !== "object" || mode === null)
|
|
11
|
+
return undefined;
|
|
12
|
+
const maxBytes = Number(mode.maxBytes);
|
|
13
|
+
if (!Number.isFinite(maxBytes) || maxBytes < 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
let total = 0;
|
|
16
|
+
let retained = Buffer.alloc(0);
|
|
17
|
+
let spillValid = spill !== undefined;
|
|
18
|
+
return {
|
|
19
|
+
append(data) {
|
|
20
|
+
total += data.length;
|
|
21
|
+
retained = Buffer.concat([retained, data]).subarray(-maxBytes);
|
|
22
|
+
},
|
|
23
|
+
readFrom(offset) {
|
|
24
|
+
const start = Math.max(0, total - retained.length);
|
|
25
|
+
const requested = Math.max(0, Number.isFinite(offset) ? offset : 0);
|
|
26
|
+
const local = Math.max(0, requested - start);
|
|
27
|
+
const lossy = requested < start;
|
|
28
|
+
return {
|
|
29
|
+
text: retained.subarray(local).toString("utf8"),
|
|
30
|
+
nextOffset: total,
|
|
31
|
+
lossy,
|
|
32
|
+
// Advertise the full-stream file only while it can still hold the
|
|
33
|
+
// complete stream, matching the local backend's reader.
|
|
34
|
+
...(lossy &&
|
|
35
|
+
spill !== undefined &&
|
|
36
|
+
spillValid &&
|
|
37
|
+
total <= spill.maxBytes
|
|
38
|
+
? { spillPath: spill.path }
|
|
39
|
+
: {}),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
setSpillValid(valid) {
|
|
43
|
+
spillValid = valid;
|
|
44
|
+
},
|
|
45
|
+
get spillPath() {
|
|
46
|
+
return spill?.path;
|
|
47
|
+
},
|
|
48
|
+
get spillNeeded() {
|
|
49
|
+
return total > maxBytes;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// spillTargetFor builds the guest spill request for one collect-mode stream, or
|
|
54
|
+
// an empty object when the mode requests no spill.
|
|
55
|
+
export function spillTargetFor(mode, label) {
|
|
56
|
+
const spill = mode?.spill;
|
|
57
|
+
const maxBytes = Number(spill?.maxBytes);
|
|
58
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0)
|
|
59
|
+
return {};
|
|
60
|
+
const path = `${SPILL_ROOT}/${randomUUID()}.${label}`;
|
|
61
|
+
return { target: { path, maxBytes }, spec: { path, maxBytes } };
|
|
62
|
+
}
|
|
63
|
+
// splitEnv separates the harness's explicit environment entries from its
|
|
64
|
+
// `undefined` tombstones, which a proto string map cannot carry.
|
|
65
|
+
export function splitEnv(env) {
|
|
66
|
+
const set = {};
|
|
67
|
+
const unsetEnv = [];
|
|
68
|
+
if (typeof env === "object" && env !== null) {
|
|
69
|
+
for (const [key, value] of Object.entries(env)) {
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
unsetEnv.push(key);
|
|
72
|
+
else
|
|
73
|
+
set[key] = String(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { env: set, unsetEnv };
|
|
77
|
+
}
|
|
78
|
+
// discardUnneededSpill removes a spill the in-memory tail already covered. A
|
|
79
|
+
// valid spill that holds output the tail dropped stays for the caller to read,
|
|
80
|
+
// and an incomplete one was already removed by the guest.
|
|
81
|
+
export function discardUnneededSpill(binding, reader) {
|
|
82
|
+
if (reader === undefined || reader.spillPath === undefined)
|
|
83
|
+
return;
|
|
84
|
+
if (reader.spillNeeded)
|
|
85
|
+
return;
|
|
86
|
+
void unaryGuest({ binding }, "delete", { path: reader.spillPath, recursive: false }).catch(() => { });
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { pathRequestRecorder, WORKSPACE_ID } from "./test-support.js";
|
|
7
|
+
import { TOOLS, summarizeArgs, toolHandlers } from "./index.js";
|
|
8
|
+
const EXEC = { agent: { session: { header: { cwd: "/proj" } } } };
|
|
9
|
+
test("the path tools are registered and approval-gated", () => {
|
|
10
|
+
for (const name of [
|
|
11
|
+
"container_path_set",
|
|
12
|
+
"container_path_add",
|
|
13
|
+
"container_path_remove",
|
|
14
|
+
]) {
|
|
15
|
+
const tool = TOOLS.find((entry) => entry.name === name);
|
|
16
|
+
assert.ok(tool, `${name} registered`);
|
|
17
|
+
assert.equal(tool.approval, true, `${name} must require approval`);
|
|
18
|
+
assert.ok(tool.parameters.required.includes("container"), `${name} requires container`);
|
|
19
|
+
}
|
|
20
|
+
assert.deepEqual(TOOLS.find((entry) => entry.name === "container_path_set").parameters
|
|
21
|
+
.required, ["container", "paths"]);
|
|
22
|
+
assert.deepEqual(TOOLS.find((entry) => entry.name === "container_path_add").parameters
|
|
23
|
+
.required, ["container", "path"]);
|
|
24
|
+
});
|
|
25
|
+
test("container_path_set persists and applies the whole list", async () => {
|
|
26
|
+
const { controlCalls, setCalls, resolver } = pathRequestRecorder();
|
|
27
|
+
const out = await toolHandlers.container_path_set(resolver, { container: "default", paths: ["/opt/bin", "/usr/local/bin"] }, EXEC);
|
|
28
|
+
assert.deepEqual(controlCalls, [
|
|
29
|
+
[
|
|
30
|
+
"setContainerPaths",
|
|
31
|
+
{
|
|
32
|
+
workspaceSlug: WORKSPACE_ID,
|
|
33
|
+
container: "default",
|
|
34
|
+
paths: ["/opt/bin", "/usr/local/bin"],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
]);
|
|
38
|
+
assert.deepEqual(setCalls, [["/opt/bin", "/usr/local/bin"]]);
|
|
39
|
+
assert.deepEqual(out, { paths: ["/opt/bin", "/usr/local/bin"] });
|
|
40
|
+
});
|
|
41
|
+
test("container_path_add prepends and moves an existing entry to the front", async () => {
|
|
42
|
+
const { controlCalls, resolver } = pathRequestRecorder({ paths: ["/a", "/b"] });
|
|
43
|
+
const added = await toolHandlers.container_path_add(resolver, { container: "default", path: "/c" }, EXEC);
|
|
44
|
+
assert.deepEqual(added, { paths: ["/c", "/a", "/b"] });
|
|
45
|
+
const moved = await toolHandlers.container_path_add(resolver, { container: "default", path: "/b" }, EXEC);
|
|
46
|
+
assert.deepEqual(moved, { paths: ["/b", "/c", "/a"] });
|
|
47
|
+
assert.equal(controlCalls.length, 2);
|
|
48
|
+
});
|
|
49
|
+
test("container_path_remove removes an added path", async () => {
|
|
50
|
+
const { resolver } = pathRequestRecorder({ paths: ["/a", "/b"] });
|
|
51
|
+
const out = await toolHandlers.container_path_remove(resolver, { container: "default", path: "/a" }, EXEC);
|
|
52
|
+
assert.deepEqual(out, { paths: ["/b"] });
|
|
53
|
+
});
|
|
54
|
+
test("container_path_remove distinguishes a default path from an unknown one", async () => {
|
|
55
|
+
const { controlCalls, resolver } = pathRequestRecorder({
|
|
56
|
+
paths: ["/a"],
|
|
57
|
+
defaultPath: "/usr/bin:/bin",
|
|
58
|
+
});
|
|
59
|
+
await assert.rejects(() => toolHandlers.container_path_remove(resolver, { container: "default", path: "/usr/bin" }, EXEC), /is part of the container's default PATH and cannot be removed/);
|
|
60
|
+
await assert.rejects(() => toolHandlers.container_path_remove(resolver, { container: "default", path: "/nope" }, EXEC), /is not an added path/);
|
|
61
|
+
assert.deepEqual(controlCalls, [], "a rejected removal must not reach the orchestrator");
|
|
62
|
+
});
|
|
63
|
+
test("summarizeArgs renders the PATH reasons", () => {
|
|
64
|
+
assert.equal(summarizeArgs("container_path_set", {
|
|
65
|
+
container: "web",
|
|
66
|
+
paths: ["/opt/bin", "/usr/local/bin"],
|
|
67
|
+
}), 'Set the PATH additions of container "web": "/opt/bin", "/usr/local/bin".');
|
|
68
|
+
assert.equal(summarizeArgs("container_path_set", { container: "web", paths: [] }), 'Set the PATH additions of container "web": (none).');
|
|
69
|
+
assert.equal(summarizeArgs("container_path_add", { container: "web", path: "/opt/bin" }), 'Add "/opt/bin" to the PATH of container "web".');
|
|
70
|
+
assert.equal(summarizeArgs("container_path_remove", {
|
|
71
|
+
container: "web",
|
|
72
|
+
path: "/opt/bin",
|
|
73
|
+
}), 'Remove "/opt/bin" from the PATH of container "web".');
|
|
74
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type WorkspaceResolver } from "./workspace-binding.js";
|
|
2
|
+
/**
|
|
3
|
+
* Register the plugin's preferences namespace and keep the resolver's config in
|
|
4
|
+
* step with the user's choices.
|
|
5
|
+
*/
|
|
6
|
+
export declare function installContainerPreferences(ctx: any, resolver: WorkspaceResolver): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// The plugin's real preferences (default image, sockets root, and the browser
|
|
5
|
+
// locale) live in the settings namespace; everything derived from the
|
|
6
|
+
// orchestrator travels over the card route (see card-route.ts).
|
|
7
|
+
import { CONTAINER_NS, settingsSchema, } from "./settings-schema.js";
|
|
8
|
+
/**
|
|
9
|
+
* Register the plugin's preferences namespace and keep the resolver's config in
|
|
10
|
+
* step with the user's choices.
|
|
11
|
+
*/
|
|
12
|
+
export function installContainerPreferences(ctx, resolver) {
|
|
13
|
+
ctx.inject(["settings"], (sctx) => {
|
|
14
|
+
const scope = sctx.settings.register(CONTAINER_NS, settingsSchema, {
|
|
15
|
+
base: {
|
|
16
|
+
defaultImage: resolver.getConfig().defaultImage,
|
|
17
|
+
socketsRoot: resolver.getConfig().socketsRoot,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
scope.watch((next) => {
|
|
21
|
+
resolver.setConfig({
|
|
22
|
+
defaultImage: next.defaultImage,
|
|
23
|
+
socketsRoot: next.socketsRoot,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function projectNameFromHostPath(projectsRoot: string, path: string): string | undefined;
|
|
2
|
+
export declare function hostPathForProjectName(projectsRoot: string, project: string): string;
|
|
3
|
+
export declare function confineToRoot(root: string, path: string): string;
|
|
4
|
+
export declare function parentDirectory(root: string, path: string): string;
|
|
5
|
+
export declare function rootCrumbs(root: string, path: string): string[];
|
|
6
|
+
export declare function crumbLabel(path: string): string;
|