@exagone313/dsh-podman 0.2.0-rc.3 → 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.
Files changed (191) hide show
  1. package/README.md +14 -2
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +154 -42
  11. package/dist/card-route.test.js +85 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +92 -50
  15. package/dist/client/card-protocol.d.ts +6 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +16 -18
  18. package/dist/client/container-card-controller.js +92 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13141 -2036
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +106 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.d.ts +1 -0
  76. package/dist/dsh-version.js +46 -0
  77. package/dist/env-rows.d.ts +14 -0
  78. package/dist/env-rows.js +52 -0
  79. package/dist/env-rows.test.d.ts +1 -0
  80. package/dist/env-rows.test.js +43 -0
  81. package/dist/fs-provider.d.ts +1 -0
  82. package/dist/fs-provider.js +26 -12
  83. package/dist/fs-provider.test.js +51 -10
  84. package/dist/generated/version.d.ts +2 -2
  85. package/dist/generated/version.js +2 -2
  86. package/dist/grpc/proto/dshctl/v1/control.proto +11 -1
  87. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  88. package/dist/grpc/runtime-client.d.ts +3 -1
  89. package/dist/grpc/runtime-client.js +90 -17
  90. package/dist/guest-rpc.d.ts +15 -19
  91. package/dist/guest-rpc.js +289 -70
  92. package/dist/guest-rpc.test.d.ts +1 -0
  93. package/dist/guest-rpc.test.js +241 -0
  94. package/dist/guest-terminal.d.ts +30 -0
  95. package/dist/guest-terminal.js +196 -0
  96. package/dist/images.test.js +10 -3
  97. package/dist/index.d.ts +11 -16
  98. package/dist/index.js +42 -36
  99. package/dist/locales.test.d.ts +1 -0
  100. package/dist/locales.test.js +34 -0
  101. package/dist/misc.test.js +38 -10
  102. package/dist/mount-enums.js +2 -1
  103. package/dist/mount-input.d.ts +2 -0
  104. package/dist/mount-input.js +38 -22
  105. package/dist/mounts.test.js +81 -11
  106. package/dist/naming.test.d.ts +1 -0
  107. package/dist/naming.test.js +28 -0
  108. package/dist/output-reader.js +36 -4
  109. package/dist/package-deps.test.d.ts +1 -0
  110. package/dist/package-deps.test.js +49 -0
  111. package/dist/paths.test.js +4 -2
  112. package/dist/plugin-meta.test.d.ts +1 -0
  113. package/dist/plugin-meta.test.js +29 -0
  114. package/dist/project-path.js +33 -7
  115. package/dist/project-path.test.js +14 -0
  116. package/dist/prompts.d.ts +0 -4
  117. package/dist/prompts.js +15 -84
  118. package/dist/read-only-shell.js +3 -1
  119. package/dist/read-only-shell.test.js +80 -25
  120. package/dist/runtime-client.test.d.ts +1 -0
  121. package/dist/runtime-client.test.js +53 -0
  122. package/dist/secrets.test.js +21 -6
  123. package/dist/settings-commands.test.js +61 -14
  124. package/dist/settings-create.test.js +32 -10
  125. package/dist/settings-default-env.test.d.ts +1 -0
  126. package/dist/settings-default-env.test.js +175 -0
  127. package/dist/settings-mounts.test.js +46 -7
  128. package/dist/settings-schema.d.ts +6 -11
  129. package/dist/settings-schema.js +4 -8
  130. package/dist/settings-schema.test.d.ts +1 -0
  131. package/dist/settings-schema.test.js +35 -0
  132. package/dist/settings-workspaces.test.js +38 -63
  133. package/dist/spill-store.d.ts +1 -0
  134. package/dist/spill-store.js +17 -4
  135. package/dist/spill-store.test.js +31 -8
  136. package/dist/subprocess.d.ts +4 -0
  137. package/dist/subprocess.js +148 -162
  138. package/dist/subprocess.test.js +118 -8
  139. package/dist/terminal-preference.test.d.ts +1 -0
  140. package/dist/terminal-preference.test.js +62 -0
  141. package/dist/terminal-route.d.ts +10 -0
  142. package/dist/terminal-route.js +270 -0
  143. package/dist/terminal-route.test.d.ts +1 -0
  144. package/dist/terminal-route.test.js +265 -0
  145. package/dist/terminal-sessions.d.ts +69 -0
  146. package/dist/terminal-sessions.js +252 -0
  147. package/dist/terminal-shells.d.ts +22 -0
  148. package/dist/terminal-shells.js +99 -0
  149. package/dist/terminal-tab.test.d.ts +1 -0
  150. package/dist/terminal-tab.test.js +58 -0
  151. package/dist/terminal-targets.test.d.ts +1 -0
  152. package/dist/terminal-targets.test.js +43 -0
  153. package/dist/terminal-titles.test.d.ts +1 -0
  154. package/dist/terminal-titles.test.js +36 -0
  155. package/dist/test-support.d.ts +35 -5
  156. package/dist/test-support.js +91 -29
  157. package/dist/tool-defs.js +19 -4
  158. package/dist/tool-handlers.js +80 -28
  159. package/dist/tool-params.js +19 -5
  160. package/dist/version-compat.d.ts +4 -0
  161. package/dist/version-compat.js +29 -0
  162. package/dist/version-compat.test.d.ts +1 -0
  163. package/dist/version-compat.test.js +27 -0
  164. package/dist/volumes.test.js +1 -1
  165. package/dist/workspace-binding.d.ts +19 -10
  166. package/dist/workspace-binding.js +252 -78
  167. package/dist/workspace-binding.test.js +202 -27
  168. package/docs/architecture.md +71 -20
  169. package/docs/architecture.zh.md +62 -12
  170. package/docs/configuration.md +61 -20
  171. package/docs/configuration.zh.md +53 -19
  172. package/docs/development-prompt.md +72 -0
  173. package/docs/development-prompt.zh.md +69 -0
  174. package/docs/development.md +184 -29
  175. package/docs/development.zh.md +165 -26
  176. package/docs/install-dsh-and-dsh-podman.md +24 -21
  177. package/docs/install-dsh-and-dsh-podman.zh.md +20 -21
  178. package/docs/uninstall.md +83 -0
  179. package/docs/uninstall.zh.md +79 -0
  180. package/docs/update.md +120 -0
  181. package/docs/update.zh.md +114 -0
  182. package/docs/usage.md +108 -61
  183. package/docs/usage.zh.md +76 -38
  184. package/locale/en.json +6 -0
  185. package/locale/zh.json +6 -0
  186. package/package.json +58 -25
  187. package/quadlet/dsh-podman-orchestrator.container +46 -0
  188. package/quadlet/dsh.container +35 -0
  189. package/LICENSE.pkg +0 -17103
  190. package/dist/preferences.d.ts +0 -6
  191. package/dist/preferences.js +0 -27
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import { test } from "node:test";
5
5
  import { strict as assert } from "node:assert";
6
- import { fakeScope, fakeContext, baseValue } from "./card-test-support.js";
6
+ import { baseValue, fakeContext, fakeScope } from "./card-test-support.js";
7
7
  import { installCardCommandDriver } from "./card-test-support.js";
8
8
  test("create command sends PATH additions", async () => {
9
9
  const scope = fakeScope(baseValue());
@@ -34,7 +34,7 @@ test("create command sends PATH additions", async () => {
34
34
  paths: ["/opt/bin"],
35
35
  env: {},
36
36
  container: "",
37
- secretEnvMap: {},
37
+ secretEnv: {},
38
38
  mount: null,
39
39
  },
40
40
  });
@@ -73,7 +73,7 @@ test("create command sends PATH additions", async () => {
73
73
  mounts: [],
74
74
  paths: ["/opt/bin", "/usr/local/bin"],
75
75
  env: {},
76
- secretEnvMap: {},
76
+ secretEnv: {},
77
77
  mount: null,
78
78
  },
79
79
  });
@@ -137,7 +137,14 @@ test("secret_create command drives createSecret with the name", async () => {
137
137
  };
138
138
  await installCardCommandDriver(fakeContext(scope), resolver);
139
139
  await scope.update({
140
- command: { op: "secret_create", workspace: "db-pass", image: "", at: 6, mounts: [], value: "" },
140
+ command: {
141
+ op: "secret_create",
142
+ workspace: "db-pass",
143
+ image: "",
144
+ at: 6,
145
+ mounts: [],
146
+ value: "",
147
+ },
141
148
  });
142
149
  await new Promise((resolve) => setImmediate(resolve));
143
150
  const createCall = calls.find(([method]) => method === "createSecret");
@@ -167,7 +174,13 @@ test("secret_create command drives createSecret with length when provided", asyn
167
174
  };
168
175
  await installCardCommandDriver(fakeContext(scope), resolver);
169
176
  await scope.update({
170
- command: { op: "secret_create", workspace: "s", image: "", at: 11, length: 48 },
177
+ command: {
178
+ op: "secret_create",
179
+ workspace: "s",
180
+ image: "",
181
+ at: 11,
182
+ length: 48,
183
+ },
171
184
  });
172
185
  await new Promise((resolve) => setImmediate(resolve));
173
186
  const createCall = calls.find(([method]) => method === "createSecret");
@@ -227,11 +240,20 @@ test("secret_create command drives createSecret with charset when provided", asy
227
240
  };
228
241
  await installCardCommandDriver(fakeContext(scope), resolver);
229
242
  await scope.update({
230
- command: { op: "secret_create", workspace: "tok", length: 48, charset: "hex" },
243
+ command: {
244
+ op: "secret_create",
245
+ workspace: "tok",
246
+ length: 48,
247
+ charset: "hex",
248
+ },
231
249
  });
232
250
  await new Promise((resolve) => setImmediate(resolve));
233
251
  const createCall = calls.find(([method]) => method === "createSecret");
234
- assert.deepEqual(createCall?.[1], { name: "tok", length: 48, charset: "hex" });
252
+ assert.deepEqual(createCall?.[1], {
253
+ name: "tok",
254
+ length: 48,
255
+ charset: "hex",
256
+ });
235
257
  assert.equal(scope.value.command, null);
236
258
  });
237
259
  test("recreate command drives recreateContainer with env", async () => {
@@ -297,7 +319,7 @@ test("create command with a container name routes to startContainer", async () =
297
319
  at: 1,
298
320
  mounts: [],
299
321
  env: {},
300
- secretEnvMap: {},
322
+ secretEnv: {},
301
323
  mount: null,
302
324
  },
303
325
  });
@@ -339,7 +361,7 @@ test("create command sends secret env", async () => {
339
361
  mounts: [],
340
362
  env: {},
341
363
  container: "",
342
- secretEnvMap: { VALKEY_PASSWORD: "valkey-tls" },
364
+ secretEnv: { VALKEY_PASSWORD: "valkey-tls" },
343
365
  mount: null,
344
366
  },
345
367
  });
@@ -379,7 +401,7 @@ test("create command sends secret env", async () => {
379
401
  mounts: [],
380
402
  env: {},
381
403
  container: "",
382
- secretEnvMap: {},
404
+ secretEnv: {},
383
405
  mount: null,
384
406
  },
385
407
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ // The default container environment through the settings card: seeding on
5
+ // creation, authoritative recreates, and the synchronisation into existing
6
+ // containers.
7
+ import { test } from "node:test";
8
+ import { strict as assert } from "node:assert";
9
+ import { baseValue, fakeContext, fakeScope, installCardCommandDriver, } from "./card-test-support.js";
10
+ const DEFAULTS = {
11
+ GIT_AUTHOR_NAME: "John Doe",
12
+ GIT_AUTHOR_EMAIL: "john.doe@git.example",
13
+ };
14
+ function driver(containerEnv, containers = []) {
15
+ const scope = fakeScope(baseValue());
16
+ const calls = [];
17
+ const resolver = {
18
+ getConfig: () => (containerEnv === undefined ? {} : { containerEnv }),
19
+ setConfig: () => { },
20
+ async control(method, request) {
21
+ calls.push([method, request]);
22
+ if (method === "listContainers")
23
+ return { containers };
24
+ if (method === "listImages")
25
+ return { images: [] };
26
+ if (method === "listWorkspaces")
27
+ return { workspaces: [] };
28
+ return {};
29
+ },
30
+ };
31
+ return { scope, calls, resolver };
32
+ }
33
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
34
+ test("create seeds the default environment into a new workspace", async () => {
35
+ const { scope, calls, resolver } = driver(DEFAULTS);
36
+ await installCardCommandDriver(fakeContext(scope), resolver);
37
+ await scope.update({
38
+ command: {
39
+ op: "create",
40
+ workspace: "w1",
41
+ projectName: "team",
42
+ image: "img1",
43
+ mounts: [],
44
+ env: { EXTRA: "1" },
45
+ container: "",
46
+ },
47
+ });
48
+ await settle();
49
+ const create = calls.find(([method]) => method === "createWorkspace");
50
+ assert.deepEqual(create?.[1].env, { ...DEFAULTS, EXTRA: "1" });
51
+ });
52
+ test("create seeds the default environment into a new named container", async () => {
53
+ const { scope, calls, resolver } = driver(DEFAULTS);
54
+ await installCardCommandDriver(fakeContext(scope), resolver);
55
+ await scope.update({
56
+ command: {
57
+ op: "create",
58
+ workspace: "w1",
59
+ container: "web",
60
+ image: "img1",
61
+ mounts: [],
62
+ env: {},
63
+ },
64
+ });
65
+ await settle();
66
+ const start = calls.find(([method]) => method === "startContainer");
67
+ assert.deepEqual(start?.[1].env, DEFAULTS);
68
+ assert.equal(start?.[1].container, "web");
69
+ });
70
+ test("recreate is authoritative and does not re-seed the defaults", async () => {
71
+ const { scope, calls, resolver } = driver(DEFAULTS);
72
+ await installCardCommandDriver(fakeContext(scope), resolver);
73
+ await scope.update({
74
+ command: {
75
+ op: "recreate",
76
+ workspace: "w1",
77
+ image: "",
78
+ env: { EXTRA: "1" },
79
+ },
80
+ });
81
+ await settle();
82
+ const recreate = calls.find(([method]) => method === "recreateContainer");
83
+ assert.deepEqual(recreate?.[1].env, { EXTRA: "1" });
84
+ });
85
+ test("default_env_sync adds only missing keys to running containers", async () => {
86
+ const containers = [
87
+ {
88
+ workspaceSlug: "w1",
89
+ containerName: "default",
90
+ status: "running",
91
+ env: { EXTRA: "1" },
92
+ },
93
+ {
94
+ workspaceSlug: "w1",
95
+ containerName: "web",
96
+ status: "running",
97
+ env: { ...DEFAULTS },
98
+ },
99
+ {
100
+ workspaceSlug: "w2",
101
+ containerName: "default",
102
+ status: "stopped",
103
+ env: {},
104
+ },
105
+ ];
106
+ const { scope, calls, resolver } = driver(DEFAULTS, containers);
107
+ await installCardCommandDriver(fakeContext(scope), resolver);
108
+ await scope.update({ command: { op: "default_env_sync", workspace: "" } });
109
+ await settle();
110
+ const recreates = calls.filter(([method]) => method === "recreateContainer");
111
+ assert.deepEqual(recreates.map(([, request]) => request), [
112
+ {
113
+ workspaceSlug: "w1",
114
+ container: "default",
115
+ env: { ...DEFAULTS, EXTRA: "1" },
116
+ },
117
+ ], "only the incomplete running container is recreated, with existing values kept");
118
+ assert.equal(scope.value.notice, "applied the default environment to 1 container; 1 container stopped, to pick up on their next start");
119
+ });
120
+ test("default_env_sync honors a workspace filter", async () => {
121
+ const containers = [
122
+ {
123
+ workspaceSlug: "w1",
124
+ containerName: "default",
125
+ status: "running",
126
+ env: {},
127
+ },
128
+ {
129
+ workspaceSlug: "w2",
130
+ containerName: "default",
131
+ status: "running",
132
+ env: {},
133
+ },
134
+ ];
135
+ const { scope, calls, resolver } = driver(DEFAULTS, containers);
136
+ await installCardCommandDriver(fakeContext(scope), resolver);
137
+ await scope.update({ command: { op: "default_env_sync", workspace: "w2" } });
138
+ await settle();
139
+ const recreates = calls.filter(([method]) => method === "recreateContainer");
140
+ assert.deepEqual(recreates.map(([, request]) => request), [
141
+ { workspaceSlug: "w2", container: "default", env: DEFAULTS },
142
+ ]);
143
+ });
144
+ test("default_env_sync reports nothing to do when the defaults are absent", async () => {
145
+ const { scope, calls, resolver } = driver(undefined, [
146
+ {
147
+ workspaceSlug: "w1",
148
+ containerName: "default",
149
+ status: "running",
150
+ env: {},
151
+ },
152
+ ]);
153
+ await installCardCommandDriver(fakeContext(scope), resolver);
154
+ await calls.splice(0);
155
+ await scope.update({ command: { op: "default_env_sync", workspace: "" } });
156
+ await settle();
157
+ assert.equal(calls.some(([method]) => method === "recreateContainer"), false);
158
+ assert.equal(scope.value.notice, "");
159
+ });
160
+ test("default_env_sync reports an already-complete state", async () => {
161
+ const containers = [
162
+ {
163
+ workspaceSlug: "w1",
164
+ containerName: "default",
165
+ status: "running",
166
+ env: { ...DEFAULTS },
167
+ },
168
+ ];
169
+ const { scope, calls, resolver } = driver(DEFAULTS, containers);
170
+ await installCardCommandDriver(fakeContext(scope), resolver);
171
+ await scope.update({ command: { op: "default_env_sync", workspace: "" } });
172
+ await settle();
173
+ assert.equal(calls.some(([method]) => method === "recreateContainer"), false);
174
+ assert.equal(scope.value.notice, "the default environment is already applied to every running container");
175
+ });
@@ -30,10 +30,17 @@ test("create command drives createWorkspace with mounts and image", async () =>
30
30
  projectName: "team",
31
31
  image: "img1",
32
32
  at: 1,
33
- mounts: [{ kind: "project", project: "team", mode: "read_write", destination: "", volume: "", secret: "" }],
33
+ mounts: [{
34
+ kind: "project",
35
+ project: "team",
36
+ mode: "read_write",
37
+ destination: "",
38
+ volume: "",
39
+ secret: "",
40
+ }],
34
41
  env: {},
35
42
  container: "",
36
- secretEnvMap: {},
43
+ secretEnv: {},
37
44
  mount: null,
38
45
  },
39
46
  });
@@ -43,7 +50,11 @@ test("create command drives createWorkspace with mounts and image", async () =>
43
50
  workspaceSlug: "w1",
44
51
  projectName: "team",
45
52
  imageId: "img1",
46
- mounts: [{ projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_WRITE" }],
53
+ mounts: [{
54
+ projectName: "team",
55
+ kind: "MOUNT_KIND_PROJECT",
56
+ mode: "MOUNT_MODE_READ_WRITE",
57
+ }],
47
58
  });
48
59
  assert.equal(scope.value.command, null);
49
60
  });
@@ -75,7 +86,7 @@ test("container_mount_add command drives addContainerMount for each kind", async
75
86
  mount: {
76
87
  kind: "project",
77
88
  project: "team/src",
78
- destination: "/workspace/team",
89
+ destination: "",
79
90
  mode: "read_only",
80
91
  volume: "",
81
92
  secret: "",
@@ -130,7 +141,7 @@ test("container_mount_add command drives addContainerMount for each kind", async
130
141
  kind: "secret",
131
142
  project: "",
132
143
  destination: "/run/secrets/tls",
133
- mode: "read_write",
144
+ mode: "read_only",
134
145
  volume: "",
135
146
  secret: "valkey-tls",
136
147
  },
@@ -330,6 +341,34 @@ test("container_mount_update command drives updateContainerMount", async () => {
330
341
  ]);
331
342
  assert.equal(scope.value.command, null);
332
343
  });
344
+ test("container_mount_add command rejects a read-write secret mount", async () => {
345
+ const { scope, calls } = await installedMountScope();
346
+ await scope.update({
347
+ command: mountCommand("container_mount_add", {
348
+ kind: "secret",
349
+ secret: "valkey-tls",
350
+ destination: "/run/secrets/tls",
351
+ mode: "read_write",
352
+ }),
353
+ });
354
+ await new Promise((resolve) => setImmediate(resolve));
355
+ assert.equal(scope.value.notice, "secret mounts are read-only; omit mode or use read_only");
356
+ assert.equal(calls.some(([method]) => method === "addContainerMount"), false, "a rejected secret mount must not reach the orchestrator");
357
+ });
358
+ test("container_mount_update command rejects a secret mount", async () => {
359
+ const { scope, calls } = await installedMountScope();
360
+ await scope.update({
361
+ command: mountCommand("container_mount_update", {
362
+ kind: "secret",
363
+ secret: "valkey-tls",
364
+ destination: "/run/secrets/tls",
365
+ mode: "read_write",
366
+ }),
367
+ });
368
+ await new Promise((resolve) => setImmediate(resolve));
369
+ assert.equal(scope.value.notice, "secret mounts are read-only; omit mode or use read_only");
370
+ assert.equal(calls.some(([method]) => method === "updateContainerMount"), false, "a rejected secret mount must not reach the orchestrator");
371
+ });
333
372
  test("container_mount_add command reports an unknown kind as a notice", async () => {
334
373
  const { scope, calls } = await installedMountScope();
335
374
  await scope.update({
@@ -389,7 +428,7 @@ test("create command reports an unknown mount kind as a notice", async () => {
389
428
  }],
390
429
  env: {},
391
430
  container: "",
392
- secretEnvMap: {},
431
+ secretEnv: {},
393
432
  mount: null,
394
433
  },
395
434
  });
@@ -416,7 +455,7 @@ test("create command reports an unknown mount mode as a notice", async () => {
416
455
  }],
417
456
  env: {},
418
457
  container: "",
419
- secretEnvMap: {},
458
+ secretEnv: {},
420
459
  mount: null,
421
460
  },
422
461
  });
@@ -1,14 +1,9 @@
1
+ import { type Volatile } from "@deepseek-ai/cordis";
1
2
  import z from "@deepseek-ai/schemastery";
2
3
  export declare const CONTAINER_NS = "podman";
3
- export declare const settingsSchema: z<ContainerSettings>;
4
- export interface ContainerSettings {
5
- defaultImage: string;
6
- socketsRoot: string;
7
- uiLocale: string;
8
- }
9
- export interface ContainerSettingsScope {
10
- get(): ContainerSettings;
11
- watch(callback: (next: ContainerSettings, prev: ContainerSettings) => void | Promise<void>): () => void;
12
- update(patch: object): Promise<void>;
13
- replace(section: object): Promise<void>;
4
+ export interface Config {
5
+ defaultImage: Volatile<string>;
6
+ uiLocale: Volatile<string>;
7
+ containerEnv: Volatile<Record<string, string>>;
14
8
  }
9
+ export declare const Config: z<Config>;
@@ -3,12 +3,8 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  export const CONTAINER_NS = "podman";
6
- // Only real preferences live in the settings namespace, so the settings
7
- // document holds user choices and nothing derived. The live orchestrator state
8
- // and the card's commands travel over the card route instead (see
9
- // card-protocol.ts).
10
- export const settingsSchema = z.object({
11
- defaultImage: z.string().default(""),
12
- socketsRoot: z.string().default(""),
13
- uiLocale: z.string().default(""),
6
+ export const Config = z.object({
7
+ defaultImage: z.string().default("").volatile(),
8
+ uiLocale: z.string().default("").volatile(),
9
+ containerEnv: z.dict(z.string()).default({}).volatile(),
14
10
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
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 { Config } from "./settings-schema.js";
7
+ import { WorkspaceResolver } from "./workspace-binding.js";
8
+ test("every preference is a volatile field with an empty default", () => {
9
+ const config = Config({});
10
+ const expected = [
11
+ ["defaultImage", ""],
12
+ ["uiLocale", ""],
13
+ ["containerEnv", {}],
14
+ ];
15
+ for (const [field, value] of expected) {
16
+ assert.equal(typeof config[field].get, "function", `${field} must be volatile`);
17
+ assert.deepEqual(config[field].get(), value, `${field} must resolve its default`);
18
+ }
19
+ // The host serves the schema to the client config form: volatility must be
20
+ // declared on the schema, not only in the parsed value.
21
+ assert.match(JSON.stringify(Config.toJSON()), /"volatile":true/);
22
+ });
23
+ test("the resolver reads its config through the live reader", () => {
24
+ let defaultImage = "archlinux";
25
+ const resolver = new WorkspaceResolver(() => ({
26
+ socketsRoot: "/run/dsh-podman",
27
+ defaultImage,
28
+ projectsRoot: "/projects",
29
+ controlToken: "token",
30
+ containerEnv: {},
31
+ }), undefined);
32
+ assert.equal(resolver.getConfig().defaultImage, "archlinux");
33
+ defaultImage = "alpine";
34
+ assert.equal(resolver.getConfig().defaultImage, "alpine", "a volatile change must be visible without rebuilding the resolver");
35
+ });
@@ -18,6 +18,8 @@ test("refresh on install publishes containers, images and workspaces", async ()
18
18
  setConfig: () => { },
19
19
  async control(method, request) {
20
20
  calls.push([method, request]);
21
+ if (method === "getVersion")
22
+ return { version: "0.2.0", commit: "abc" };
21
23
  if (method === "listContainers") {
22
24
  return { containers: [{ containerName: "c1", workspaceSlug: "w1" }] };
23
25
  }
@@ -25,7 +27,12 @@ test("refresh on install publishes containers, images and workspaces", async ()
25
27
  return { images: [{ imageId: "img1" }] };
26
28
  }
27
29
  if (method === "listWorkspaces") {
28
- return { workspaces: [{ workspaceSlug: "w1", mounts: [{ projectName: "team/app" }] }] };
30
+ return {
31
+ workspaces: [{
32
+ workspaceSlug: "w1",
33
+ mounts: [{ projectName: "team/app" }],
34
+ }],
35
+ };
29
36
  }
30
37
  if (method === "listVolumes") {
31
38
  return { volumes: [{ name: "data" }, { name: "cache" }] };
@@ -38,7 +45,15 @@ test("refresh on install publishes containers, images and workspaces", async ()
38
45
  };
39
46
  await installCardCommandDriver(fakeContext(scope), resolver);
40
47
  await scope.update({}); // settle the queued async refresh
41
- assert.deepEqual(calls.map(([method]) => method), ["listContainers", "listImages", "listWorkspaces", "listVolumes", "listSecrets", "listCaches"]);
48
+ assert.deepEqual(calls.map(([method]) => method), [
49
+ "getVersion",
50
+ "listContainers",
51
+ "listImages",
52
+ "listWorkspaces",
53
+ "listVolumes",
54
+ "listSecrets",
55
+ "listCaches",
56
+ ]);
42
57
  assert.equal(scope.value.containers.length, 1);
43
58
  assert.equal(scope.value.images.length, 1);
44
59
  const workspaces = scope.value.workspaces;
@@ -46,62 +61,9 @@ test("refresh on install publishes containers, images and workspaces", async ()
46
61
  assert.equal(workspaces[0].workspaceSlug, "w1");
47
62
  assert.equal(workspaces[0].projectName, "team/app");
48
63
  assert.deepEqual(scope.value.volumes, [{ name: "data" }, { name: "cache" }]);
49
- assert.deepEqual(scope.value.secrets, [{ name: "db-pass" }, { name: "api-key" }]);
50
- });
51
- test("handle never writes projectsRoot into setConfig", async () => {
52
- const scope = fakeScope(baseValue());
53
- const setConfigCalls = [];
54
- const resolver = {
55
- getConfig: () => ({}),
56
- setConfig: (patch) => setConfigCalls.push(patch),
57
- async control(method) {
58
- if (method === "listContainers")
59
- return { containers: [] };
60
- if (method === "listImages")
61
- return { images: [] };
62
- if (method === "listWorkspaces")
63
- return { workspaces: [] };
64
- return {};
65
- },
66
- };
67
- await installCardCommandDriver(fakeContext(scope), resolver);
68
- await scope.update({});
69
- await new Promise((resolve) => setImmediate(resolve));
70
- assert.ok(setConfigCalls.length > 0, "handle must write resolver config");
71
- for (const patch of setConfigCalls) {
72
- assert.ok(!Object.prototype.hasOwnProperty.call(patch, "projectsRoot"), "projectsRoot must not be written via setConfig");
73
- }
74
- });
75
- test("settings seed the image and sockets root from the resolver config", async () => {
76
- let registeredBase;
77
- const scope = fakeScope(baseValue());
78
- const context = {
79
- inject(deps, callback) {
80
- assert.deepEqual(deps, ["settings"]);
81
- callback({
82
- settings: {
83
- register(_namespace, _schema, options) {
84
- registeredBase = options?.base ?? {};
85
- return scope;
86
- },
87
- },
88
- });
89
- },
90
- };
91
- const resolver = {
92
- getConfig: () => ({
93
- defaultImage: "archlinux",
94
- socketsRoot: "/run/dsh-podman",
95
- projectsRoot: "/projects",
96
- }),
97
- setConfig: () => { },
98
- async control() {
99
- return {};
100
- },
101
- };
102
- await installCardCommandDriver(context, resolver);
103
- assert.equal(registeredBase?.defaultImage, "archlinux");
104
- assert.equal(registeredBase?.socketsRoot, "/run/dsh-podman");
64
+ assert.deepEqual(scope.value.secrets, [{ name: "db-pass" }, {
65
+ name: "api-key",
66
+ }]);
105
67
  });
106
68
  test("create command drives createWorkspace with env", async () => {
107
69
  const scope = fakeScope(baseValue());
@@ -131,7 +93,7 @@ test("create command drives createWorkspace with env", async () => {
131
93
  mounts: [],
132
94
  env: { A: "1", B: "2" },
133
95
  container: "",
134
- secretEnvMap: {},
96
+ secretEnv: {},
135
97
  mount: null,
136
98
  },
137
99
  });
@@ -173,7 +135,7 @@ test("create command drives createWorkspace without env when empty", async () =>
173
135
  mounts: [],
174
136
  env: {},
175
137
  container: "",
176
- secretEnvMap: {},
138
+ secretEnv: {},
177
139
  mount: null,
178
140
  },
179
141
  });
@@ -190,7 +152,12 @@ test("workspace list comes from the dsh registry even without orchestrator state
190
152
  const scope = fakeScope(baseValue());
191
153
  const registry = {
192
154
  list: () => [
193
- { id: WORKSPACE_ID, path: "/projects/team/app", title: "app", createdAt: "2026-01-01T00:00:00Z" },
155
+ {
156
+ id: WORKSPACE_ID,
157
+ path: "/projects/team/app",
158
+ title: "app",
159
+ createdAt: "2026-01-01T00:00:00Z",
160
+ },
194
161
  ],
195
162
  };
196
163
  const resolver = {
@@ -225,7 +192,12 @@ test("dsh workspace layers orchestrator container info", async () => {
225
192
  const scope = fakeScope(baseValue());
226
193
  const registry = {
227
194
  list: () => [
228
- { id: WORKSPACE_ID, path: "/projects/team/app", title: "app", createdAt: "2026-01-01T00:00:00Z" },
195
+ {
196
+ id: WORKSPACE_ID,
197
+ path: "/projects/team/app",
198
+ title: "app",
199
+ createdAt: "2026-01-01T00:00:00Z",
200
+ },
229
201
  ],
230
202
  };
231
203
  const resolver = {
@@ -247,7 +219,10 @@ test("dsh workspace layers orchestrator container info", async () => {
247
219
  containerName: `dsh-podman-${WORKSPACE_ID}-default`,
248
220
  imageId: "arch",
249
221
  status: "running",
250
- mounts: [{ projectName: "team/app", mode: "MOUNT_MODE_READ_WRITE" }],
222
+ mounts: [{
223
+ projectName: "team/app",
224
+ mode: "MOUNT_MODE_READ_WRITE",
225
+ }],
251
226
  }],
252
227
  };
253
228
  }
@@ -12,6 +12,7 @@ export interface SpillRef {
12
12
  bytes: number;
13
13
  retrievalHint: string;
14
14
  }
15
+ export declare function sessionSegment(sessionId: unknown): string;
15
16
  export declare function createSpillStore(ctx: any, resolver: WorkspaceResolver): {
16
17
  saveText(input: SpillRequest): Promise<SpillRef>;
17
18
  };
@@ -17,10 +17,23 @@ function spillName(suggestedName) {
17
17
  .slice(0, 64);
18
18
  return `${randomUUID().replace(/-/g, "")}-${safe === "" ? "spill.txt" : safe}`;
19
19
  }
20
- // sessionSegment is the per-session directory name: session ids are UUIDs, but
21
- // a backend must never trust a caller-supplied segment in a path.
22
- function sessionSegment(sessionId) {
23
- return sessionId.replace(/[^A-Za-z0-9._-]/g, "_");
20
+ // sessionSegment is the per-session directory name. Session ids are not
21
+ // guaranteed to be UUIDs (dsh mints `session-<n>`, `<id>-session-<uuid>`, and
22
+ // `webhook-<uuid>`, and accepts a caller-supplied id), so the id is encoded
23
+ // rather than trusted: a short readable prefix from a safe alphabet plus the
24
+ // full id in hex — deterministic, collision-free, and never "." or "..".
25
+ export function sessionSegment(sessionId) {
26
+ const id = String(sessionId ?? "");
27
+ const encoded = Buffer.from(id, "utf8").toString("hex");
28
+ const readable = id
29
+ .replace(/[^A-Za-z0-9_-]/g, "_")
30
+ .replace(/_{2,}/g, "_")
31
+ .replace(/^[_-]+/, "")
32
+ .slice(0, 24)
33
+ .replace(/[_-]+$/, "");
34
+ if (readable !== "")
35
+ return `${readable}-${encoded}`;
36
+ return encoded === "" ? "session" : encoded;
24
37
  }
25
38
  // createSpillStore backs `ctx.spillStore` with the session's container:
26
39
  // oversized tool results are written inside the guest, and the locator is the