@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
@@ -1,32 +1,75 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { guestClient, controlClient, grpc, unary, } from "./grpc/runtime-client.js";
4
+ import { closeClients, controlClient, grpc, guestClient, unary, } from "./grpc/runtime-client.js";
5
+ import { mergeDefaultEnv } from "./container-env.js";
5
6
  import { isAbsolute, join } from "node:path";
6
7
  import { VERSION } from "./generated/version.js";
8
+ // The control calls that change a container's mount set or recreate it. A
9
+ // successful call invalidates the cached binding for that container (or for
10
+ // every container of a removed workspace), because the cached mounts decide
11
+ // whether the session directory is exposed as the container's default cwd.
12
+ const CONTAINER_MUTATIONS = {
13
+ startContainer: "container",
14
+ recreateContainer: "container",
15
+ removeContainer: "container",
16
+ addContainerMount: "container",
17
+ removeContainerMount: "container",
18
+ updateContainerMount: "container",
19
+ addContainerSecret: "container",
20
+ removeContainerSecret: "container",
21
+ removeWorkspace: "workspace",
22
+ };
7
23
  export class WorkspaceResolver {
8
24
  config;
9
25
  registry;
10
26
  bindings = new Map();
11
- constructor(config, registry) {
27
+ // Named containers get their own cache, keyed by "<slug>:<logical name>", so
28
+ // resolving one does not re-run ensureContainer, rebuild a gRPC channel, and
29
+ // wait for readiness on every tool call.
30
+ containerBindings = new Map();
31
+ constructor(
32
+ // A reader rather than a snapshot: the plugin's preferences are volatile
33
+ // config fields, so every call must observe their current values.
34
+ config, registry) {
12
35
  this.config = config;
13
36
  this.registry = registry;
14
37
  }
15
- setConfig(patch) {
16
- Object.assign(this.config, patch);
17
- }
18
38
  getConfig() {
19
- return this.config;
39
+ return typeof this.config === "function" ? this.config() : this.config;
40
+ }
41
+ // dispose closes every cached gRPC channel and drops the cached bindings.
42
+ // Called when the plugin is disposed so a reload does not leak unix sockets.
43
+ dispose() {
44
+ this.bindings.clear();
45
+ this.containerBindings.clear();
46
+ closeClients();
47
+ }
48
+ // forgetContainer drops the cached binding for one named container, so the
49
+ // next call re-resolves its row and picks up a changed mount set.
50
+ forgetContainer(slug, container) {
51
+ this.containerBindings.delete(`${slug}:${container}`);
52
+ }
53
+ // forgetWorkspace drops every cached container binding of one workspace, so
54
+ // a removed (or recreated) workspace cannot serve stale rows.
55
+ forgetWorkspace(slug) {
56
+ const prefix = `${slug}:`;
57
+ for (const key of [...this.containerBindings.keys()]) {
58
+ if (key.startsWith(prefix))
59
+ this.containerBindings.delete(key);
60
+ }
20
61
  }
21
- async resolve(cwd) {
62
+ async resolve(cwd, signal) {
22
63
  const workspace = await this.workspaceForCwd(cwd);
23
- const relativePath = String(workspace.path).replace(`${this.config.projectsRoot}/`, "");
24
64
  const key = workspaceSlug(workspace.id);
25
- const binding = await this.ready(key, relativePath);
65
+ const projectName = projectNameForPath(this.getConfig().projectsRoot, workspace.path);
66
+ const binding = await this.ready(key, projectName, signal);
26
67
  // The default container always keeps its project mount, so the session
27
68
  // directory is always mounted in it.
28
69
  const session = defaultCwdOf(cwd);
29
- return session === undefined ? binding : { ...binding, defaultCwd: session };
70
+ return session === undefined
71
+ ? binding
72
+ : { ...binding, defaultCwd: session };
30
73
  }
31
74
  // Resolve the workspace binding for a filesystem path when the caller did not
32
75
  // supply a session working directory. The 0.1.5 `fs.resolve` contract allows
@@ -34,13 +77,14 @@ export class WorkspaceResolver {
34
77
  // sensible owner, so match it by longest canonical-path prefix. A path
35
78
  // outside every workspace is not in any container's filesystem, so it is
36
79
  // reported as missing rather than failing the caller.
37
- async resolveForPath(path, cwd) {
80
+ async resolveForPath(path, cwd, signal) {
38
81
  if (typeof cwd === "string" && cwd !== "")
39
- return this.resolve(cwd);
82
+ return this.resolve(cwd, signal);
40
83
  if (isAbsolute(path)) {
41
84
  const workspace = this.containingWorkspace(path);
42
- if (workspace !== undefined)
43
- return this.resolve(String(workspace.path));
85
+ if (workspace !== undefined) {
86
+ return this.resolve(String(workspace.path), signal);
87
+ }
44
88
  throw notFoundError(`no DH workspace contains path ${JSON.stringify(path)}`);
45
89
  }
46
90
  throw new Error(`cannot resolve a DH workspace for path ${JSON.stringify(path)}; pass a session working directory`);
@@ -70,89 +114,141 @@ export class WorkspaceResolver {
70
114
  }
71
115
  return workspace;
72
116
  }
73
- resolveSlug(key) {
74
- return this.ready(key, key);
117
+ resolveSlug(key, signal) {
118
+ return this.ready(key, key, signal);
75
119
  }
76
- async containerBinding(cwd, container) {
120
+ async containerBinding(cwd, container, signal) {
77
121
  const workspace = await this.workspaceForCwd(cwd);
78
122
  const slug = workspaceSlug(workspace.id);
79
- const result = await this.control("listContainers", {});
80
- const row = containerRowFor(result.containers ?? [], slug, container);
81
- if (row === undefined) {
82
- throw containerNotFound(container, slug);
123
+ const key = `${slug}:${container}`;
124
+ let entry = await this.resolveContainerBinding(slug, container, key, signal);
125
+ try {
126
+ entry = {
127
+ binding: await this.ensureAgentReady(entry.binding),
128
+ mounts: entry.mounts,
129
+ };
130
+ }
131
+ catch {
132
+ this.containerBindings.delete(key);
133
+ await this.recoverContainer(slug, container, signal);
134
+ entry = await this.resolveContainerBinding(slug, container, key, signal);
135
+ entry = {
136
+ binding: await this.ensureAgentReady(entry.binding),
137
+ mounts: entry.mounts,
138
+ };
83
139
  }
84
- const socket = row.agentSocketPath;
85
- const binding = {
86
- guest: guestClient(socket),
87
- token: row.agentToken,
88
- socket,
89
- };
90
140
  const session = defaultCwdOf(cwd);
91
141
  if (session !== undefined &&
92
- projectMountCovers(this.config.projectsRoot, row.mounts ?? [], session)) {
93
- binding.defaultCwd = session;
142
+ projectMountCovers(this.getConfig().projectsRoot, entry.mounts, session)) {
143
+ return { ...entry.binding, defaultCwd: session };
94
144
  }
95
- await this.ensureReady(binding, slug, container);
96
- return binding;
145
+ return entry.binding;
146
+ }
147
+ // resolveContainerBinding returns the cached promise for a named container,
148
+ // creating it once. It deliberately stops at an "ensureContainer + build a
149
+ // binding" step: readiness is per-call, so a credential refresh can never
150
+ // recurse through this path.
151
+ resolveContainerBinding(slug, container, key, signal) {
152
+ let entry = this.containerBindings.get(key);
153
+ if (entry === undefined) {
154
+ entry = this.buildContainerBinding(slug, container, key).catch((error) => {
155
+ if (this.containerBindings.get(key) === entry) {
156
+ this.containerBindings.delete(key);
157
+ }
158
+ throw error;
159
+ });
160
+ this.containerBindings.set(key, entry);
161
+ }
162
+ return entry;
163
+ }
164
+ async buildContainerBinding(slug, container, key) {
165
+ const row = await this.ensureContainer(slug, container);
166
+ // refresh re-resolves the container row and swaps the cache entry, so a
167
+ // binding whose token rotated is not re-served from the stale cache.
168
+ const refresh = () => {
169
+ const next = this.buildContainerBinding(slug, container, key);
170
+ this.containerBindings.set(key, next);
171
+ return next.then((fresh) => fresh.binding);
172
+ };
173
+ return { binding: this.bindingFor(row, refresh), mounts: row.mounts ?? [] };
97
174
  }
98
175
  // ready resolves a workspace's default container and waits for its guest
99
176
  // agent, recreating the container once when it is reported running but its
100
177
  // agent never answers (a crashed agent or a lost socket).
101
- async ready(key, projectName) {
102
- let binding = await this.resolveBinding(key, projectName);
178
+ async ready(key, projectName, signal) {
179
+ let binding = await this.resolveBinding(key, projectName, signal);
103
180
  try {
104
- await waitForReady(binding.guest, this.readyTimeoutMs());
181
+ return await this.ensureAgentReady(binding);
105
182
  }
106
183
  catch {
107
184
  this.bindings.delete(key);
108
- binding = await this.resolveBinding(key, projectName);
185
+ binding = await this.resolveBinding(key, projectName, signal);
109
186
  try {
110
- await waitForReady(binding.guest, this.readyTimeoutMs());
187
+ return await this.ensureAgentReady(binding);
111
188
  }
112
189
  catch {
113
- await this.recoverContainer(key, "default");
190
+ await this.recoverContainer(key, "default", signal);
114
191
  this.bindings.delete(key);
115
- binding = await this.resolveBinding(key, projectName);
116
- await waitForReady(binding.guest, this.readyTimeoutMs());
192
+ binding = await this.resolveBinding(key, projectName, signal);
193
+ return await this.ensureAgentReady(binding);
117
194
  }
118
195
  }
119
- return binding;
120
196
  }
121
197
  readyTimeoutMs() {
122
- const configured = Number(this.config.readyTimeoutMs);
198
+ const configured = Number(this.getConfig().readyTimeoutMs);
123
199
  return Number.isFinite(configured) && configured > 0 ? configured : 15000;
124
200
  }
125
- // ensureReady waits for one binding's guest agent and recovers the container
126
- // once when the wait fails.
127
- async ensureReady(binding, slug, container) {
201
+ // bindingFor builds a binding from an orchestrator container row. refresh
202
+ // re-resolves it, so a caller can retry once after the agent rejects a token
203
+ // that went stale (the plugin caches bindings for its lifetime).
204
+ bindingFor(row, refresh) {
205
+ const socket = row.agentSocketPath;
206
+ return {
207
+ guest: guestClient(socket),
208
+ token: row.agentToken,
209
+ socket,
210
+ refresh,
211
+ };
212
+ }
213
+ // ensureAgentReady waits for the agent and verifies the credential with a
214
+ // Ping. A rejected credential is refreshed once (re-resolving the binding,
215
+ // which re-runs the orchestrator's ensure) and re-verified, so a token that
216
+ // rotated after the binding was cached does not fail the caller.
217
+ async ensureAgentReady(binding) {
218
+ await waitForReady(binding.guest, this.readyTimeoutMs());
128
219
  try {
129
- await waitForReady(binding.guest, this.readyTimeoutMs());
130
- return;
220
+ await pingAgent(binding);
221
+ return binding;
131
222
  }
132
- catch {
133
- // Recover below.
223
+ catch (error) {
224
+ if (error?.code !== grpc.status.UNAUTHENTICATED ||
225
+ binding.refresh === undefined) {
226
+ throw error;
227
+ }
228
+ const refreshed = await binding.refresh();
229
+ await waitForReady(refreshed.guest, this.readyTimeoutMs());
230
+ await pingAgent(refreshed);
231
+ return refreshed;
134
232
  }
135
- await this.recoverContainer(slug, container);
136
- await waitForReady(binding.guest, this.readyTimeoutMs());
137
233
  }
138
234
  // recoverContainer best-effort recreates a container whose agent is
139
235
  // unreachable. A failure here is not fatal: the caller's retry surfaces the
140
236
  // real readiness failure.
141
- async recoverContainer(slug, container) {
237
+ async recoverContainer(slug, container, signal) {
142
238
  try {
143
239
  await this.control("recreateContainer", {
144
240
  workspaceSlug: slug,
145
241
  container,
146
- });
242
+ }, signal);
147
243
  }
148
244
  catch {
149
245
  // Best-effort.
150
246
  }
151
247
  }
152
- resolveBinding(key, projectName) {
248
+ resolveBinding(key, projectName, signal) {
153
249
  let binding = this.bindings.get(key);
154
250
  if (binding === undefined) {
155
- binding = this.create(key, projectName).catch((error) => {
251
+ binding = this.create(key, projectName, signal).catch((error) => {
156
252
  if (this.bindings.get(key) === binding)
157
253
  this.bindings.delete(key);
158
254
  throw error;
@@ -161,34 +257,79 @@ export class WorkspaceResolver {
161
257
  }
162
258
  return binding;
163
259
  }
164
- async create(slug, projectName) {
165
- const control = controlClient(join(this.config.socketsRoot, "orchestrator.sock"));
166
- const controlMetadata = metadata(this.config.controlToken, VERSION);
167
- let workspace;
260
+ async create(slug, projectName, signal) {
261
+ const control = controlClient(join(this.getConfig().socketsRoot, "orchestrator.sock"));
262
+ const controlMetadata = metadata(this.getConfig().controlToken, VERSION);
168
263
  try {
169
- workspace = await unary(control, "describeWorkspace", {
264
+ await unary(control, "describeWorkspace", {
170
265
  workspaceSlug: slug,
171
- }, controlMetadata);
266
+ }, controlMetadata, signal);
172
267
  }
173
268
  catch (error) {
174
269
  if (error.code !== grpc.status.NOT_FOUND)
175
270
  throw error;
176
- workspace = await unary(control, "createWorkspace", {
271
+ // A new workspace's default container is a creation: seed the default
272
+ // environment. An existing one keeps its stored env instead.
273
+ const env = mergeDefaultEnv(this.getConfig().containerEnv, undefined);
274
+ await unary(control, "createWorkspace", {
177
275
  workspaceSlug: slug,
178
276
  projectName,
179
- imageId: this.config.defaultImage,
277
+ imageId: this.getConfig().defaultImage,
180
278
  mounts: [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }],
181
- }, controlMetadata);
279
+ ...(Object.keys(env).length > 0 ? { env } : {}),
280
+ }, controlMetadata, signal);
182
281
  }
183
- const socket = workspace.agentSocketPath;
184
- return {
185
- guest: guestClient(socket),
186
- token: workspace.agentToken,
187
- socket,
188
- };
282
+ const row = await this.ensureContainer(slug, "default", signal);
283
+ return this.bindingFor(row, () => {
284
+ this.bindings.delete(slug);
285
+ return this.resolveBinding(slug, projectName);
286
+ });
189
287
  }
190
- async control(method, request) {
191
- return unary(controlClient(join(this.config.socketsRoot, "orchestrator.sock")), method, request, metadata(this.config.controlToken, VERSION));
288
+ // ensureContainer asks the orchestrator for a usable container, recreating it
289
+ // when it is missing, stopped, or runs a guest agent from an outdated image,
290
+ // and returns its row. A container the orchestrator does not know is reported
291
+ // as the caller's missing container.
292
+ async ensureContainer(slug, container, signal) {
293
+ try {
294
+ return await this.control("ensureContainer", {
295
+ workspaceSlug: slug,
296
+ container,
297
+ }, signal);
298
+ }
299
+ catch (error) {
300
+ if (error.code !== grpc.status.NOT_FOUND)
301
+ throw error;
302
+ throw containerNotFound(container, slug);
303
+ }
304
+ }
305
+ async control(method, request, signal) {
306
+ const result = await unary(controlClient(join(this.getConfig().socketsRoot, "orchestrator.sock")), method, request, metadata(this.getConfig().controlToken, VERSION), signal);
307
+ this.invalidateAfter(method, request);
308
+ return result;
309
+ }
310
+ // invalidateAfter drops the cached bindings a successful mutation invalidated.
311
+ // Only reachable after the call resolved, so a failed mutation keeps the
312
+ // warm entry it did not change.
313
+ invalidateAfter(method, request) {
314
+ const scope = CONTAINER_MUTATIONS[method];
315
+ if (scope === undefined)
316
+ return;
317
+ const fields = (typeof request === "object" && request !== null ? request : {});
318
+ const slug = typeof fields.workspaceSlug === "string"
319
+ ? fields.workspaceSlug
320
+ : "";
321
+ if (slug === "")
322
+ return;
323
+ if (scope === "workspace") {
324
+ this.forgetWorkspace(slug);
325
+ return;
326
+ }
327
+ const container = typeof fields.container === "string"
328
+ ? fields.container
329
+ : "";
330
+ if (container === "")
331
+ return;
332
+ this.forgetContainer(slug, container);
192
333
  }
193
334
  }
194
335
  // notFoundError builds the harness's missing-path error (`FsErrorCode`
@@ -214,10 +355,15 @@ export function containerNotFound(container, slug) {
214
355
  // when the error carried one) so callers can branch without parsing text.
215
356
  export function normalizeToolError(error) {
216
357
  const raw = error instanceof Error ? error.message : String(error);
217
- const message = raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
218
358
  const code = error.code;
219
- const name = typeof code === "number"
220
- ? grpc.status[code]
359
+ const numericCode = typeof code === "number" ? code : undefined;
360
+ // Only a real gRPC status renders the "<code> <NAME>: " prefix; a plain
361
+ // message that merely looks like one must stay intact.
362
+ const message = numericCode === undefined
363
+ ? raw
364
+ : raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
365
+ const name = numericCode !== undefined
366
+ ? grpc.status[numericCode]
221
367
  : typeof code === "string"
222
368
  ? code
223
369
  : undefined;
@@ -231,6 +377,15 @@ function waitForReady(agent, timeoutMs = 15000) {
231
377
  agent.waitForReady(Date.now() + timeoutMs, (error) => error ? reject(error) : resolve());
232
378
  });
233
379
  }
380
+ // pingAgent makes the guest readiness call, which also exercises the credential
381
+ // (the agent's auth interceptor guards every call, Ping included). Unlike
382
+ // waitForReady, which only proves connectivity, it fails when the token is
383
+ // rejected — which is exactly what a rotated credential looks like.
384
+ function pingAgent(binding) {
385
+ return new Promise((resolveDone, reject) => {
386
+ binding.guest.ping({}, metadata(binding.token), (error) => (error ? reject(error) : resolveDone()));
387
+ });
388
+ }
234
389
  // workspaceSlug validates the workspace id that names this workspace's pod
235
390
  // (dsh-podman-<slug>) and containers (dsh-podman-<slug>-<logical>). It must be
236
391
  // a UUID so a container name can never collide with a pod name.
@@ -242,9 +397,6 @@ export function workspaceSlug(id) {
242
397
  return value.toLowerCase();
243
398
  }
244
399
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
245
- export function containerRowFor(containers, slug, container) {
246
- return containers.find((row) => row.workspaceSlug === slug && row.containerName === container);
247
- }
248
400
  // metadata builds the call metadata. The plugin version is sent on control
249
401
  // calls so the orchestrator can refuse an incompatible plugin; guest calls omit
250
402
  // it, since the guest agent does not check it.
@@ -263,6 +415,21 @@ export function metadata(token, pluginVersion) {
263
415
  function defaultCwdOf(cwd) {
264
416
  return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
265
417
  }
418
+ // projectNameForPath maps a dsh workspace's host path to the project path a
419
+ // mount stores: the path relative to the projects root. It is a prefix test,
420
+ // not a String.replace, so a root that appears later in the path cannot corrupt
421
+ // the name, and a workspace outside the root fails with a clear error instead
422
+ // of silently producing an absolute "project name".
423
+ function projectNameForPath(projectsRoot, path) {
424
+ const root = String(projectsRoot ?? "").replace(/\/+$/, "");
425
+ const value = String(path ?? "").replace(/\/+$/, "");
426
+ if (root === "" || value === root)
427
+ return "";
428
+ if (!value.startsWith(`${root}/`)) {
429
+ throw new Error(`workspace path ${JSON.stringify(value)} is not under the projects root ${JSON.stringify(root)}`);
430
+ }
431
+ return value.slice(root.length + 1);
432
+ }
266
433
  // projectMountCovers reports whether a project mount of a container makes the
267
434
  // given container path reachable: the mount destination itself or a path below
268
435
  // it.