@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.
Files changed (186) hide show
  1. package/README.md +12 -0
  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 +103 -35
  11. package/dist/card-route.test.js +60 -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 +76 -70
  15. package/dist/client/card-protocol.d.ts +3 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +13 -18
  18. package/dist/client/container-card-controller.js +86 -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 +13109 -2117
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +88 -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.js +2 -1
  76. package/dist/env-rows.d.ts +14 -0
  77. package/dist/env-rows.js +52 -0
  78. package/dist/env-rows.test.d.ts +1 -0
  79. package/dist/env-rows.test.js +43 -0
  80. package/dist/fs-provider.d.ts +1 -0
  81. package/dist/fs-provider.js +26 -12
  82. package/dist/fs-provider.test.js +51 -10
  83. package/dist/generated/version.d.ts +2 -2
  84. package/dist/generated/version.js +2 -2
  85. package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
  86. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  87. package/dist/grpc/runtime-client.d.ts +3 -1
  88. package/dist/grpc/runtime-client.js +90 -17
  89. package/dist/guest-rpc.d.ts +15 -19
  90. package/dist/guest-rpc.js +289 -70
  91. package/dist/guest-rpc.test.d.ts +1 -0
  92. package/dist/guest-rpc.test.js +241 -0
  93. package/dist/guest-terminal.d.ts +30 -0
  94. package/dist/guest-terminal.js +196 -0
  95. package/dist/images.test.js +10 -3
  96. package/dist/index.d.ts +11 -16
  97. package/dist/index.js +42 -36
  98. package/dist/locales.test.d.ts +1 -0
  99. package/dist/locales.test.js +34 -0
  100. package/dist/misc.test.js +38 -10
  101. package/dist/mount-enums.js +2 -1
  102. package/dist/mount-input.d.ts +2 -0
  103. package/dist/mount-input.js +38 -22
  104. package/dist/mounts.test.js +81 -11
  105. package/dist/naming.test.d.ts +1 -0
  106. package/dist/naming.test.js +28 -0
  107. package/dist/output-reader.js +36 -4
  108. package/dist/package-deps.test.d.ts +1 -0
  109. package/dist/package-deps.test.js +49 -0
  110. package/dist/paths.test.js +4 -2
  111. package/dist/plugin-meta.test.d.ts +1 -0
  112. package/dist/plugin-meta.test.js +29 -0
  113. package/dist/project-path.js +33 -7
  114. package/dist/project-path.test.js +14 -0
  115. package/dist/prompts.d.ts +0 -4
  116. package/dist/prompts.js +15 -84
  117. package/dist/read-only-shell.js +3 -1
  118. package/dist/read-only-shell.test.js +80 -25
  119. package/dist/runtime-client.test.d.ts +1 -0
  120. package/dist/runtime-client.test.js +53 -0
  121. package/dist/secrets.test.js +21 -6
  122. package/dist/settings-commands.test.js +61 -14
  123. package/dist/settings-create.test.js +32 -10
  124. package/dist/settings-default-env.test.d.ts +1 -0
  125. package/dist/settings-default-env.test.js +175 -0
  126. package/dist/settings-mounts.test.js +46 -7
  127. package/dist/settings-schema.d.ts +6 -11
  128. package/dist/settings-schema.js +4 -8
  129. package/dist/settings-schema.test.d.ts +1 -0
  130. package/dist/settings-schema.test.js +35 -0
  131. package/dist/settings-workspaces.test.js +36 -63
  132. package/dist/spill-store.d.ts +1 -0
  133. package/dist/spill-store.js +17 -4
  134. package/dist/spill-store.test.js +31 -8
  135. package/dist/subprocess.d.ts +4 -0
  136. package/dist/subprocess.js +148 -162
  137. package/dist/subprocess.test.js +118 -8
  138. package/dist/terminal-preference.test.d.ts +1 -0
  139. package/dist/terminal-preference.test.js +62 -0
  140. package/dist/terminal-route.d.ts +10 -0
  141. package/dist/terminal-route.js +270 -0
  142. package/dist/terminal-route.test.d.ts +1 -0
  143. package/dist/terminal-route.test.js +265 -0
  144. package/dist/terminal-sessions.d.ts +69 -0
  145. package/dist/terminal-sessions.js +252 -0
  146. package/dist/terminal-shells.d.ts +22 -0
  147. package/dist/terminal-shells.js +99 -0
  148. package/dist/terminal-tab.test.d.ts +1 -0
  149. package/dist/terminal-tab.test.js +58 -0
  150. package/dist/terminal-targets.test.d.ts +1 -0
  151. package/dist/terminal-targets.test.js +43 -0
  152. package/dist/terminal-titles.test.d.ts +1 -0
  153. package/dist/terminal-titles.test.js +36 -0
  154. package/dist/test-support.d.ts +35 -5
  155. package/dist/test-support.js +91 -29
  156. package/dist/tool-defs.js +19 -4
  157. package/dist/tool-handlers.js +80 -28
  158. package/dist/tool-params.js +19 -5
  159. package/dist/volumes.test.js +1 -1
  160. package/dist/workspace-binding.d.ts +18 -9
  161. package/dist/workspace-binding.js +244 -77
  162. package/dist/workspace-binding.test.js +189 -25
  163. package/docs/architecture.md +62 -21
  164. package/docs/architecture.zh.md +57 -13
  165. package/docs/configuration.md +61 -20
  166. package/docs/configuration.zh.md +53 -19
  167. package/docs/development-prompt.md +72 -0
  168. package/docs/development-prompt.zh.md +69 -0
  169. package/docs/development.md +141 -21
  170. package/docs/development.zh.md +125 -17
  171. package/docs/install-dsh-and-dsh-podman.md +21 -8
  172. package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
  173. package/docs/uninstall.md +83 -0
  174. package/docs/uninstall.zh.md +79 -0
  175. package/docs/update.md +120 -0
  176. package/docs/update.zh.md +114 -0
  177. package/docs/usage.md +108 -61
  178. package/docs/usage.zh.md +76 -38
  179. package/locale/en.json +6 -0
  180. package/locale/zh.json +6 -0
  181. package/package.json +58 -25
  182. package/quadlet/dsh-podman-orchestrator.container +46 -0
  183. package/quadlet/dsh.container +35 -0
  184. package/LICENSE.pkg +0 -17103
  185. package/dist/preferences.d.ts +0 -6
  186. package/dist/preferences.js +0 -27
@@ -10,10 +10,22 @@ import { dirname, resolve } from "node:path";
10
10
  import { tmpdir } from "node:os";
11
11
  import { mkdirSync } from "node:fs";
12
12
  import { controlClient, guestClient } from "./grpc/runtime-client.js";
13
- import { workspaceSlug, metadata, WorkspaceResolver, containerRowFor, containerNotFound, normalizeToolError, } from "./workspace-binding.js";
13
+ import { containerNotFound, metadata, normalizeToolError, WorkspaceResolver, workspaceSlug, } from "./workspace-binding.js";
14
14
  import { VERSION } from "./generated/version.js";
15
15
  const SLUG = "2c573001-4171-4900-904b-12a5cc02737a";
16
16
  const SLUG_SUB = "3d684112-5282-4a11-a15c-23b6dd13848b";
17
+ test("normalizeToolError strips the prefix only for a gRPC status", () => {
18
+ const statusError = Object.assign(new Error("5 NOT_FOUND: missing"), {
19
+ code: 5,
20
+ });
21
+ const normalized = normalizeToolError(statusError);
22
+ assert.equal(normalized.message, "missing");
23
+ assert.equal(normalized.code, "NOT_FOUND");
24
+ // A plain message that merely looks like a status keeps its text.
25
+ const plain = normalizeToolError(new Error("5 NOT_FOUND: missing"));
26
+ assert.equal(plain.message, "5 NOT_FOUND: missing");
27
+ assert.equal(plain.code, undefined);
28
+ });
17
29
  test("workspaceSlug accepts a UUID workspace id", () => {
18
30
  assert.equal(workspaceSlug(SLUG), SLUG);
19
31
  assert.equal(workspaceSlug(SLUG.toUpperCase()), SLUG);
@@ -59,7 +71,9 @@ test("normalizeToolError strips the grpc prefix and names the code", () => {
59
71
  const plain = normalizeToolError(new Error("boom"));
60
72
  assert.equal(plain.message, "boom");
61
73
  assert.equal(plain.code, undefined);
62
- const named = Object.assign(new Error("bad input"), { code: "INVALID_ARGUMENT" });
74
+ const named = Object.assign(new Error("bad input"), {
75
+ code: "INVALID_ARGUMENT",
76
+ });
63
77
  assert.equal(normalizeToolError(named).code, "INVALID_ARGUMENT");
64
78
  });
65
79
  test("containerNotFound reports one stable shape", () => {
@@ -67,25 +81,6 @@ test("containerNotFound reports one stable shape", () => {
67
81
  assert.equal(containerNotFound("db").code, "NOT_FOUND");
68
82
  assert.equal(containerNotFound("db").message, 'container "db" not found');
69
83
  });
70
- test("containerRowFor finds rows by workspace and name", () => {
71
- const containers = [
72
- { workspaceSlug: "team-app", containerName: "default", status: "running" },
73
- { workspaceSlug: "team-app", containerName: "db", status: "stopped" },
74
- { workspaceSlug: "other", containerName: "db", status: "running" },
75
- ];
76
- assert.deepEqual(containerRowFor(containers, "team-app", "db"), {
77
- workspaceSlug: "team-app",
78
- containerName: "db",
79
- status: "stopped",
80
- });
81
- assert.deepEqual(containerRowFor(containers, "team-app", "default"), {
82
- workspaceSlug: "team-app",
83
- containerName: "default",
84
- status: "running",
85
- });
86
- assert.equal(containerRowFor(containers, "team-app", "missing"), undefined);
87
- assert.equal(containerRowFor([], "team-app", "default"), undefined);
88
- });
89
84
  test("control and guest proto files resolve next to the runtime", () => {
90
85
  const control = controlClient("/tmp/dsh-proto-control.sock");
91
86
  const guest = guestClient("/tmp/dsh-proto-guest.sock");
@@ -100,7 +95,25 @@ async function startControlServer(containers = [], options = {}) {
100
95
  defaults: true,
101
96
  });
102
97
  const loaded = grpc.loadPackageDefinition(definition);
98
+ const guestDefinition = loader.loadSync(resolve(dirname(fileURLToPath(import.meta.url)), "grpc/proto/dshguest/v1/guest.proto"), { longs: String, enums: String, defaults: true });
99
+ const guestLoaded = grpc.loadPackageDefinition(guestDefinition);
103
100
  const server = new grpc.Server();
101
+ // With staleToken, the first container row carries a credential the agent
102
+ // rejects, so the caller must refresh before it can proceed.
103
+ let ensureCalls = 0;
104
+ server.addService(guestLoaded.dshguest.v1.WorkspaceGuestAgent.service, {
105
+ ping: (call, callback) => {
106
+ const bearer = String(call.metadata.get("authorization")[0] ?? "");
107
+ if (bearer === "bearer stale") {
108
+ callback({
109
+ code: grpc.status.UNAUTHENTICATED,
110
+ details: "invalid agent token",
111
+ });
112
+ return;
113
+ }
114
+ callback(null, { version: "test", commit: "test" });
115
+ },
116
+ });
104
117
  const received = [];
105
118
  const versions = [];
106
119
  const createRequests = [];
@@ -114,8 +127,28 @@ async function startControlServer(containers = [], options = {}) {
114
127
  describeWorkspace: (_call, callback) => {
115
128
  callback({ code: grpc.status.NOT_FOUND, details: "workspace not found" });
116
129
  },
117
- listContainers: (_call, callback) => {
118
- callback(null, { containers });
130
+ ensureContainer: (call, callback) => {
131
+ const agentToken = options.staleToken === true && ensureCalls++ === 0
132
+ ? "stale"
133
+ : "tok";
134
+ const row = containers.find((candidate) => candidate.workspaceSlug === call.request.workspaceSlug &&
135
+ candidate.containerName === call.request.container);
136
+ if (row !== undefined) {
137
+ callback(null, { ...row, agentToken });
138
+ return;
139
+ }
140
+ if (call.request.container === "default") {
141
+ // The default container createWorkspace just made.
142
+ callback(null, {
143
+ workspaceSlug: call.request.workspaceSlug,
144
+ containerName: "default",
145
+ agentSocketPath: resolve(socketsRoot, "guest.sock"),
146
+ agentToken,
147
+ mounts: [],
148
+ });
149
+ return;
150
+ }
151
+ callback({ code: grpc.status.NOT_FOUND, details: "container not found" });
119
152
  },
120
153
  createWorkspace: (call, callback) => {
121
154
  createRequests.push(call.request);
@@ -129,6 +162,12 @@ async function startControlServer(containers = [], options = {}) {
129
162
  recreateRequests.push(call.request);
130
163
  callback(null, { workspaceSlug: call.request.workspaceSlug });
131
164
  },
165
+ addContainerMount: (_call, callback) => {
166
+ callback(null, {});
167
+ },
168
+ removeWorkspace: (_call, callback) => {
169
+ callback(null, {});
170
+ },
132
171
  });
133
172
  const socketsRoot = resolve(tmpdir(), `dsh-control-test-${process.pid}-${Date.now()}-${Math.random()}`);
134
173
  mkdirSync(socketsRoot, { recursive: true });
@@ -226,6 +265,60 @@ test("resolve exposes the session directory as the default cwd", async () => {
226
265
  stop();
227
266
  }
228
267
  });
268
+ test("resolve refreshes a binding whose token the agent rejects", async () => {
269
+ // The first row carries a credential the guest agent rejects; resolving must
270
+ // re-run the orchestrator's ensure and end with the usable token.
271
+ const { socketsRoot, stop } = await startControlServer([], {
272
+ staleToken: true,
273
+ });
274
+ try {
275
+ const resolver = new WorkspaceResolver({
276
+ socketsRoot,
277
+ defaultImage: "arch",
278
+ projectsRoot: "/projects",
279
+ controlToken: "",
280
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
281
+ const binding = await resolver.resolve("/projects/team");
282
+ assert.equal(binding.token, "tok");
283
+ }
284
+ finally {
285
+ stop();
286
+ }
287
+ });
288
+ test("the auto-created workspace seeds the default environment", async () => {
289
+ const { socketsRoot, createRequests, stop } = await startControlServer();
290
+ try {
291
+ const resolver = new WorkspaceResolver({
292
+ socketsRoot,
293
+ defaultImage: "arch",
294
+ projectsRoot: "/projects",
295
+ controlToken: "",
296
+ containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
297
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
298
+ await resolver.resolve("/projects/team");
299
+ assert.equal(createRequests.length, 1);
300
+ assert.deepEqual(createRequests[0].env, { GIT_AUTHOR_NAME: "John Doe" });
301
+ }
302
+ finally {
303
+ stop();
304
+ }
305
+ });
306
+ test("the auto-created workspace omits env without defaults", async () => {
307
+ const { socketsRoot, createRequests, stop } = await startControlServer();
308
+ try {
309
+ const resolver = new WorkspaceResolver({
310
+ socketsRoot,
311
+ defaultImage: "arch",
312
+ projectsRoot: "/projects",
313
+ controlToken: "",
314
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
315
+ await resolver.resolve("/projects/team");
316
+ assert.deepEqual(createRequests[0].env, {});
317
+ }
318
+ finally {
319
+ stop();
320
+ }
321
+ });
229
322
  test("containerBinding exposes the session directory only when a project mount covers it", async () => {
230
323
  const mounted = await startControlServer([{
231
324
  workspaceSlug: SLUG,
@@ -252,7 +345,11 @@ test("containerBinding exposes the session directory only when a project mount c
252
345
  containerName: "db",
253
346
  agentSocketPath: "/run/x.sock",
254
347
  agentToken: "tok",
255
- mounts: [{ kind: "MOUNT_KIND_VOLUME", volume: "data", destination: "/data" }],
348
+ mounts: [{
349
+ kind: "MOUNT_KIND_VOLUME",
350
+ volume: "data",
351
+ destination: "/data",
352
+ }],
256
353
  }]);
257
354
  try {
258
355
  const resolver = new WorkspaceResolver({
@@ -268,6 +365,72 @@ test("containerBinding exposes the session directory only when a project mount c
268
365
  unmounted.stop();
269
366
  }
270
367
  });
368
+ test("containerBinding re-resolves the mount set after a container mutation", async () => {
369
+ const rows = [
370
+ {
371
+ workspaceSlug: SLUG,
372
+ containerName: "db",
373
+ agentSocketPath: "/run/x.sock",
374
+ agentToken: "tok",
375
+ mounts: [{ kind: "MOUNT_KIND_PROJECT", projectName: "team" }],
376
+ },
377
+ ];
378
+ const control = await startControlServer(rows);
379
+ try {
380
+ const resolver = new WorkspaceResolver({
381
+ socketsRoot: control.socketsRoot,
382
+ defaultImage: "arch",
383
+ projectsRoot: "/projects",
384
+ controlToken: "",
385
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
386
+ const first = await resolver.containerBinding("/projects/team", "db");
387
+ assert.equal(first.defaultCwd, "/projects/team");
388
+ // The container no longer mounts the session directory, but the cached
389
+ // entry still describes the row it was built from.
390
+ rows[0].mounts = [
391
+ { kind: "MOUNT_KIND_VOLUME", volume: "data", destination: "/data" },
392
+ ];
393
+ const stale = await resolver.containerBinding("/projects/team", "db");
394
+ assert.equal(stale.defaultCwd, "/projects/team", "the cache is served until it is invalidated");
395
+ await resolver.control("addContainerMount", {
396
+ workspaceSlug: SLUG,
397
+ container: "db",
398
+ });
399
+ const fresh = await resolver.containerBinding("/projects/team", "db");
400
+ assert.equal(fresh.defaultCwd, undefined, "a mutation re-resolves the mount set");
401
+ }
402
+ finally {
403
+ control.stop();
404
+ }
405
+ });
406
+ test("removing a workspace drops every cached container binding", async () => {
407
+ const rows = [
408
+ {
409
+ workspaceSlug: SLUG,
410
+ containerName: "db",
411
+ agentSocketPath: "/run/x.sock",
412
+ agentToken: "tok",
413
+ mounts: [{ kind: "MOUNT_KIND_PROJECT", projectName: "team" }],
414
+ },
415
+ ];
416
+ const control = await startControlServer(rows);
417
+ try {
418
+ const resolver = new WorkspaceResolver({
419
+ socketsRoot: control.socketsRoot,
420
+ defaultImage: "arch",
421
+ projectsRoot: "/projects",
422
+ controlToken: "",
423
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
424
+ await resolver.containerBinding("/projects/team", "db");
425
+ rows[0].mounts = [];
426
+ await resolver.control("removeWorkspace", { workspaceSlug: SLUG });
427
+ const fresh = await resolver.containerBinding("/projects/team", "db");
428
+ assert.equal(fresh.defaultCwd, undefined);
429
+ }
430
+ finally {
431
+ control.stop();
432
+ }
433
+ });
271
434
  test("containerBinding recreates a container whose agent never answers", async () => {
272
435
  const control = await startControlServer([{
273
436
  workspaceSlug: SLUG,
@@ -331,7 +494,8 @@ test("resolveForPath rejects paths it cannot map to a workspace", async () => {
331
494
  // A path outside every workspace is not in any container's filesystem: it is
332
495
  // reported with the harness's missing-path code so callers treat it as absent
333
496
  // (agent-instructions walks ancestors above the workspace this way).
334
- await assert.rejects(() => resolver.resolveForPath("/elsewhere/file", undefined), (error) => error instanceof Error && error.code === "FS_NOT_FOUND");
497
+ await assert.rejects(() => resolver.resolveForPath("/elsewhere/file", undefined), (error) => error instanceof Error &&
498
+ error.code === "FS_NOT_FOUND");
335
499
  });
336
500
  test("resolve rejects a missing cwd without stringifying it", async () => {
337
501
  const resolver = new WorkspaceResolver({
@@ -31,12 +31,15 @@ call from a plugin whose **major** version differs, naming both versions and
31
31
  which side is behind — an out-of-sync deployment fails loudly instead of being
32
32
  misread. A missing or unparseable version is refused as well. A minor or patch
33
33
  difference is compatible: the call proceeds, the orchestrator logs it once per
34
- plugin version, and the settings card shows it. `GetVersion` is exempt from the
34
+ plugin version, and the Podman page shows it. `GetVersion` is exempt from the
35
35
  check, so a plugin can always learn the orchestrator's version.
36
36
 
37
37
  The orchestrator service exposes these gRPC methods (backing both the UI and the
38
38
  tools): `ListContainers` (returns only the guest containers the orchestrator
39
39
  created — containers it does not own are never exposed),
40
+ `EnsureContainer{workspace_slug, container}` (the single attach path: returns
41
+ the named container, recreating it first when it is missing, stopped, or running
42
+ an outdated guest agent),
40
43
  `StartContainer{workspace_slug, container, image_id, mounts, env, secret_env,
41
44
  paths}`
42
45
  (creates or replaces a container in the workspace's pod; an empty `container`
@@ -45,8 +48,11 @@ targets the default container and other names are validated),
45
48
  (stops, removes, and recreates a container, optionally with a new image, project
46
49
  mounts, environment, or PATH additions; an empty `image_id` keeps the
47
50
  workspace's current image), `RemoveContainer`, `AddContainerMount`,
48
- `RemoveContainerMount`, `SetContainerPaths`, `AddContainerSecret`, and
49
- `RemoveContainerSecret`.
51
+ `UpdateContainerMount`, `RemoveContainerMount`, `SetContainerPaths`,
52
+ `AddContainerSecret`, and `RemoveContainerSecret`. The service also exposes the
53
+ workspace, volume, secret, image (build, rebuild, remove and base-image pull),
54
+ and cache (list and clean) operations that back the Podman page, plus
55
+ `GetVersion`.
50
56
 
51
57
  ## Workspaces and pods
52
58
 
@@ -65,10 +71,10 @@ model reads the artifact back with the container file tools instead of a host
65
71
  path the container cannot reach.
66
72
 
67
73
  A workspace's pod is torn down when its last container is removed, or directly
68
- through `RemoveWorkspace` (the settings card's **Remove pod** action), which
69
- stops the containers' daemons, removes the pod and its containers, cleans their
70
- socket directories, and drops the stored workspace. Volumes, secrets, and
71
- project data are left untouched.
74
+ through `RemoveWorkspace` (the Podman page's **Remove pod** action), which stops
75
+ the containers' daemons, removes the pod and its containers, cleans their socket
76
+ directories, and drops the stored workspace. Volumes, secrets, and project data
77
+ are left untouched.
72
78
 
73
79
  Beyond project mounts, a container can mount named volumes (prefixed
74
80
  `DSH_PODMAN_VOLUME_PREFIX`, default `dsh-podman-`, and auto-created by podman on
@@ -111,6 +117,13 @@ and writes files. Each guest container gets exactly one socket directory
111
117
  bind-mounted into it, so a guest never sees the orchestrator's control socket
112
118
  nor any other workspace's socket directory.
113
119
 
120
+ The guest API exposes no watcher, so the plugin's `ctx.fs` provider reports
121
+ `FS_IO_ERROR` (`Filesystem watching is not supported by this provider.`) for
122
+ `watch`. The harness's workspace-file change stream therefore answers
123
+ `workspace-file/watch-unsupported`, and the browser's file tree refreshes on
124
+ demand instead of live — the same stance the harness's SSH filesystem provider
125
+ takes for a remote path.
126
+
114
127
  Commands and daemons inherit the guest agent's environment, minus the reserved
115
128
  `DSH_PODMAN` namespace: the agent's own token stays with the agent instead of
116
129
  being copied into everything it starts. A daemon started with `inheritEnv=false`
@@ -155,20 +168,22 @@ Containerfile, so a build writes nothing to the orchestrator's state directory.
155
168
  from a public registry per `DSH_PODMAN_BASE_IMAGE_PREFIX`), then rebuilds the
156
169
  stored custom images **in dependency order** — each parent before the images
157
170
  derived from it. An image whose rebuild fails, and every image that depends on
158
- it, is reported in `skipped` while the rest continue.
171
+ it, is reported in `skipped` while the rest continue. A base image the call
172
+ could not build or pull is reported there too, by its short name; `rebuilt`
173
+ lists only the custom images, so it never names a base the call had to ensure.
159
174
 
160
175
  When a host cache is configured (`DSH_PODMAN_HOST_*_CACHE`, mounted into both
161
176
  the build container and the orchestrator), builds reuse downloaded packages. The
162
- settings card reports each cache's size and can clean it — keep the newest
163
- version of every package, or empty the cache. The builder serializes a cleanup
164
- against builds with a read/write lock, so a cleanup never deletes a package out
165
- from under a running build.
177
+ Podman page reports each cache's size and can clean it — keep the newest version
178
+ of every package, or empty the cache. The builder serializes a cleanup against
179
+ builds with a read/write lock, so a cleanup never deletes a package out from
180
+ under a running build.
166
181
 
167
- ## Settings card transport
182
+ ## Podman page transport
168
183
 
169
- The card talks to the host over one authenticated fetch route below the harness
170
- API path (`/api/podman/card`), registered on the connection service so the
171
- carrier applies its Host/Origin fence and browser authentication first:
184
+ The Podman page talks to the host over one authenticated fetch route below the
185
+ harness API path (`/api/podman/card`), registered on the connection service so
186
+ the carrier applies its Host/Origin fence and browser authentication first:
172
187
 
173
188
  - `GET` returns the live orchestrator snapshot (containers, images, workspaces,
174
189
  volumes, secrets, caches), built fresh on every request.
@@ -176,8 +191,34 @@ carrier applies its Host/Origin fence and browser authentication first:
176
191
  `image_rebuild` / `image_rebuild_all` / volume / secret / secret-env / mount
177
192
  ops) against the orchestrator and returns the notice to show.
178
193
 
179
- The settings namespace therefore holds **only real preferences**: the default
180
- image, the sockets root, and the card's active locale (`uiLocale`, so the host
181
- can render approval text in the session language — see
182
- [Approval](usage.md#approval)). Nothing derived from the orchestrator is
183
- persisted, and no command round-trips through the settings document.
194
+ The plugin's own config therefore holds **only real preferences**: the default
195
+ image, the default environment, and the page's active locale (`uiLocale`, so the
196
+ host can render approval text in the session language — see
197
+ [Approval](usage.md#approval)). They are volatile fields, so an edit applies
198
+ without reloading the plugin. Nothing derived from the orchestrator is
199
+ persisted, and no command round-trips through the config document.
200
+
201
+ ## Podman terminal transport
202
+
203
+ The Podman terminal tab talks to guest ptys through its own authenticated routes
204
+ on the same connection service (`/api/podman/terminal`,
205
+ `/api/podman/terminal/shells`, `/api/podman/terminal/retained`). The open route
206
+ streams newline-delimited JSON frames (ready, snapshot, base64 output, title,
207
+ exit, error, detached) and takes control requests (input, resize, rename, close)
208
+ over a POST; the carrier applies the same Host/Origin fence and browser
209
+ authentication as the card route.
210
+
211
+ Each terminal is keyed by `(sessionId, tabId)` and retained by the host: the
212
+ guest pty stays alive while no browser is attached, and every chunk is also fed
213
+ to a headless terminal emulator whose serialized screen is replayed on reattach,
214
+ so a reload keeps the shell and its scrollback. Shell discovery runs inside the
215
+ target container: candidate names are resolved with POSIX `command -v` on the
216
+ container's PATH (including the deployment's PATH additions) and merged with
217
+ `/etc/shells` and `$SHELL`, so only shells the container really provides are
218
+ offered, most capable first — the order mirrors the harness's own candidate
219
+ preference and ends with the minimal POSIX shells, and the first entry is the
220
+ client's default. The tab's workspace is resolved by the host from the session's
221
+ own working directory — never chosen, shown or defaulted in the browser: a
222
+ session outside every workspace is reported, and the client only uses the
223
+ project name and workspace slug it is given. The selected path is re-verified
224
+ before the shell starts.
@@ -26,17 +26,21 @@ token(`DSH_PODMAN_ORCHESTRATOR_TOKEN`)作为 `authorization: bearer` 标头
26
26
  请求还会在 `x-dsh-podman-plugin-version` 标头中携带插件版本。插件与 orchestrator
27
27
  分别部署(前者是 dsh 镜像中的 npm 包,后者是此二进制文件),因此 orchestrator
28
28
  会拒绝来自**主版本**不同的插件的控制调用,并指出两者的版本以及哪一方更旧——不同步的部署会明确失败,而不会被误读。缺失或无法解析的版本同样会被拒绝。次版本或修订版本不同是兼容的:调用照常进行,orchestrator
29
- 会按插件版本记录一次警告,设置卡片也会显示。`GetVersion`
29
+ 会按插件版本记录一次警告,Podman 页面也会显示。`GetVersion`
30
30
  不受该检查约束,因此插件始终可以获知 orchestrator 的版本。
31
31
 
32
32
  orchestrator 服务公开以下 gRPC 方法(同时支撑 UI
33
33
  和工具):`ListContainers`(仅返回 orchestrator 创建的 guest
34
- 容器——它不拥有的容器永远不会被暴露)、`StartContainer{workspace_slug, container, image_id, mounts, env, secret_env, paths}`(在工作区的
34
+ 容器——它不拥有的容器永远不会被暴露)、`EnsureContainer{workspace_slug, container}`(唯一的接入路径:返回指定容器,若其缺失、已停止或运行过期的
35
+ guest
36
+ agent,则先重建它)、`StartContainer{workspace_slug, container, image_id, mounts, env, secret_env, paths}`(在工作区的
35
37
  pod 中创建或替换容器;空的 `container`
36
38
  指向默认容器,其他名称会被校验)、`RecreateContainer{workspace_slug, container, image_id, mounts, env, paths}`(停止、删除并重新创建容器,可选择使用新镜像、项目挂载、环境或
37
39
  PATH 附加项;空的 `image_id`
38
- 保留工作区当前的镜像)、`RemoveContainer`、`AddContainerMount`、`RemoveContainerMount`、`SetContainerPaths`、`AddContainerSecret`
39
- 和 `RemoveContainerSecret`。
40
+ 保留工作区当前的镜像)、`RemoveContainer`、`AddContainerMount`、`UpdateContainerMount`、`RemoveContainerMount`、`SetContainerPaths`、`AddContainerSecret`
41
+ 和 `RemoveContainerSecret`。该服务还公开支撑 Podman
42
+ 页面的工作区、卷、机密、镜像(构建、重建、删除以及拉取基础镜像)和缓存(列出与清理)操作,以及
43
+ `GetVersion`。
40
44
 
41
45
  ## 工作区与 pod
42
46
 
@@ -47,10 +51,12 @@ UUID),因此其容器共享一个网络命名空间。每个工作区都有
47
51
  和 `/run` 上是 podman 的可写 tmpfs(`/dev` 和 `/dev/shm`
48
52
  保持可写)。所有其他可写状态都存在于项目绑定挂载、命名卷或 tmpfs 挂载中。guest
49
53
  文件 API 可以访问 `/tmp`,而 guest agent 会把超出上限的命令输出写入
50
- `/tmp/dsh-podman` 下的溢出文件。
54
+ `/tmp/dsh-podman` 下的溢出文件。过大的工具结果也会由插件的 `ctx.spillStore`
55
+ 溢出写入同一位置下的
56
+ `/tmp/dsh-podman/spill/<session>/`——因此模型会用容器文件工具读回该产物,而不是使用容器无法访问的主机路径。
51
57
 
52
58
  工作区的 Pod 会在其最后一个容器被移除时拆除,也可直接通过
53
- `RemoveWorkspace`(设置卡片中的 **移除 Pod**
59
+ `RemoveWorkspace`(Podman 页面中的 **移除 Pod**
54
60
  操作)拆除:它会停止各容器的守护进程、移除 Pod 及其容器、清理它们的 socket
55
61
  目录,并删除存储的工作区记录。卷、机密和项目数据保持不变。
56
62
 
@@ -66,6 +72,16 @@ UUID),因此其容器共享一个网络命名空间。每个工作区都有
66
72
 
67
73
  包含保留路径的目标会被拒绝,位于其内部的目标同样会被拒绝,因为它会隐藏其下的所有内容。
68
74
 
75
+ 通过 guest exec API 运行的命令,除非调用方明确要求管道,否则它们的 stdin 是
76
+ `/dev/null`,因此读取 stdin 的命令会看到 EOF 而不会阻塞(因此不带路径的裸
77
+ `rg`/`grep` 会搜索工作目录,而不是读取空管道)。`container_glob` 和
78
+ `container_grep` 还会把解析后的路径作为搜索目录传给 ripgrep,而绝不会作为
79
+ `--glob` 模式(包含 `/` 的路径永远无法匹配这种模式),并且 ripgrep 失败(退出码
80
+ 2)会被报告为工具错误,而不是返回空结果。ripgrep 会把包含 `/` 的 `--glob`
81
+ 模式锚定到进程工作目录,因此当 harness
82
+ 启动的目录列举以绝对搜索根目录运行时,它会从该根目录运行:此时 `glob` 的
83
+ `pattern` 锚定到其 `path`,而打印出的路径保持绝对。
84
+
69
85
  项目挂载会解析符号链接并被限制在 projects root
70
86
  内,因此可写项目中的符号链接不能将绑定挂载重定向到其外部的路径。带有绝对目标的符号链接永远不会被跟随;请直接命名另一个项目。
71
87
 
@@ -77,6 +93,12 @@ API,运行并监督后台**守护进程**,并读取和写入文件。每个
77
93
  容器恰好获得一个绑定挂载到其中的套接字目录,因此 guest 永远不会看到 orchestrator
78
94
  的 control socket,也不会看到任何其他工作区的套接字目录。
79
95
 
96
+ Guest API 不提供监视器,因此插件的 `ctx.fs` 提供者对 `watch` 返回
97
+ `FS_IO_ERROR`(`Filesystem watching is not supported by this provider.`)。harness
98
+ 的工作区文件变更流因此返回
99
+ `workspace-file/watch-unsupported`,浏览器的文件树改为按需刷新,而非实时刷新——与
100
+ harness 自带的 SSH 文件系统提供者对远程路径采取的做法一致。
101
+
80
102
  命令和守护进程继承 guest agent 的环境,减去保留的 `DSH_PODMAN` 命名空间:agent
81
103
  自身的 token 保留在 agent 中,而不会被复制到它所启动的每个东西中。以
82
104
  `inheritEnv=false` 启动的守护进程则只接收 `PATH`/`HOME` 基线以及自身的 `env`。
@@ -113,14 +135,17 @@ API,因此构建不会向 orchestrator 的状态目录写入任何内容。
113
135
 
114
136
  `image_rebuild_all` 首先确保每个基础镜像(根据 `DSH_PODMAN_BASE_IMAGE_PREFIX`
115
137
  在本地构建或从公共注册表拉取),然后**按依赖顺序**重建存储的自定义镜像——每个父镜像都在从它派生的镜像之前。重建失败的镜像以及依赖它的每个镜像都会被报告在
116
- `skipped` 中,其余镜像继续。
138
+ `skipped`
139
+ 中,其余镜像继续。本次调用无法构建或拉取的基础镜像也会以短名称报告在那里;`rebuilt`
140
+ 只列出自定义镜像,因此不会包含本次调用所确保的基础镜像。
117
141
 
118
142
  配置了主机缓存时(`DSH_PODMAN_HOST_*_CACHE`,同时挂载到构建容器和 orchestrator
119
- 中),构建会复用已下载的软件包。设置卡片会报告每个缓存的大小并可清理它——保留每个软件包的最新版本,或清空缓存。构建器用读写锁将清理与构建串行化,因此清理绝不会在构建进行时删除其正在使用的软件包。
143
+ 中),构建会复用已下载的软件包。Podman
144
+ 页面会报告每个缓存的大小并可清理它——保留每个软件包的最新版本,或清空缓存。构建器用读写锁将清理与构建串行化,因此清理绝不会在构建进行时删除其正在使用的软件包。
120
145
 
121
- ## 设置卡片传输
146
+ ## Podman 页面传输
122
147
 
123
- 卡片通过 harness API 路径(`/api/podman/card`)之下的一个已认证 fetch
148
+ Podman 页面通过 harness API 路径(`/api/podman/card`)之下的一个已认证 fetch
124
149
  路由与主机通信;该路由注册在 connection 服务上,因此承载层会先应用其 Host/Origin
125
150
  校验和浏览器认证:
126
151
 
@@ -130,6 +155,25 @@ API,因此构建不会向 orchestrator 的状态目录写入任何内容。
130
155
  `image_rebuild` / `image_rebuild_all` / volume / secret / secret-env / mount
131
156
  操作),并返回要显示的提示。
132
157
 
133
- 因此设置命名空间中**只保留真正的偏好**:默认镜像、sockets
134
- 根目录,以及卡片的当前语言(`uiLocale`,以便主机以会话语言呈现审批文本——参见[审批](usage.zh.md#审批))。orchestrator
135
- 派生的任何内容都不会被持久化,也不会有命令经由设置文档往返。
158
+ 因此插件自身的配置中**只保留真正的偏好**:默认镜像、默认环境变量,以及 Podman
159
+ 页面的当前语言(`uiLocale`,以便主机以会话语言呈现审批文本——参见[审批](usage.zh.md#审批))。它们是
160
+ volatile 字段,修改后无需重新加载插件即可生效。orchestrator
161
+ 派生的任何内容都不会被持久化,也不会有命令经由配置文档往返。
162
+
163
+ ## Podman 终端传输
164
+
165
+ Podman 终端标签页通过自身在同一 connection
166
+ 服务上的已认证路由(`/api/podman/terminal`、`/api/podman/terminal/shells`、`/api/podman/terminal/retained`)与
167
+ guest pty 通信。打开路由以换行分隔的 JSON 帧(ready、snapshot、base64
168
+ 输出、title、exit、error、detached)进行流式传输,并通过 POST
169
+ 接收控制请求(input、resize、rename、close);承载层会像卡片路由一样应用
170
+ Host/Origin 校验和浏览器认证。
171
+
172
+ 每个终端以 `(sessionId, tabId)` 为键并由主机保留:没有浏览器接入时 guest pty
173
+ 仍然存活,同时每个输出块都会写入一个无头终端模拟器,重新接入时回放其序列化屏幕,因此刷新页面不会丢失
174
+ shell 及其回滚缓冲。shell 探测在目标容器内进行:以 POSIX `command -v` 在该容器的
175
+ PATH(含部署的 PATH 追加项)中解析候选名称,并与 `/etc/shells` 和 `$SHELL`
176
+ 合并,因此只提供容器确实拥有的 shell;顺序按能力从强到弱(与 harness
177
+ 自身的候选顺序一致,最精简的 POSIX shell
178
+ 排在最后),第一项即客户端的默认值。标签页的工作区由主机根据会话自身的工作目录解析,浏览器既不可选择、不会显示,也不会回退:会话不在任何工作区内时会直接提示,客户端只使用主机给出的项目名与工作区
179
+ slug。所选路径在启动前会再次校验。