@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
@@ -4,6 +4,7 @@ export interface WorkspaceBinding {
4
4
  token: string;
5
5
  socket: string;
6
6
  defaultCwd?: string;
7
+ refresh?: () => Promise<WorkspaceBinding>;
7
8
  }
8
9
  export interface BindingConfig {
9
10
  socketsRoot: string;
@@ -11,30 +12,38 @@ export interface BindingConfig {
11
12
  projectsRoot: string;
12
13
  controlToken: string;
13
14
  readyTimeoutMs?: number;
15
+ containerEnv?: Record<string, string>;
14
16
  }
15
17
  export declare class WorkspaceResolver {
16
18
  private readonly config;
17
19
  private readonly registry;
18
20
  private readonly bindings;
19
- constructor(config: BindingConfig, registry: any);
20
- setConfig(patch: Partial<BindingConfig>): void;
21
+ private readonly containerBindings;
22
+ constructor(config: BindingConfig | (() => BindingConfig), registry: any);
21
23
  getConfig(): Readonly<BindingConfig>;
22
- resolve(cwd: unknown): Promise<WorkspaceBinding>;
23
- resolveForPath(path: string, cwd: unknown): Promise<WorkspaceBinding>;
24
+ dispose(): void;
25
+ forgetContainer(slug: string, container: string): void;
26
+ forgetWorkspace(slug: string): void;
27
+ resolve(cwd: unknown, signal?: AbortSignal): Promise<WorkspaceBinding>;
28
+ resolveForPath(path: string, cwd: unknown, signal?: AbortSignal): Promise<WorkspaceBinding>;
24
29
  private containingWorkspace;
25
30
  private workspaceForCwd;
26
- resolveSlug(key: string): Promise<WorkspaceBinding>;
27
- containerBinding(cwd: unknown, container: string): Promise<WorkspaceBinding>;
31
+ resolveSlug(key: string, signal?: AbortSignal): Promise<WorkspaceBinding>;
32
+ containerBinding(cwd: unknown, container: string, signal?: AbortSignal): Promise<WorkspaceBinding>;
33
+ private resolveContainerBinding;
34
+ private buildContainerBinding;
28
35
  private ready;
29
36
  private readyTimeoutMs;
30
- private ensureReady;
37
+ private bindingFor;
38
+ private ensureAgentReady;
31
39
  private recoverContainer;
32
40
  private resolveBinding;
33
41
  private create;
34
- control<T>(method: string, request: unknown): Promise<T>;
42
+ private ensureContainer;
43
+ control<T>(method: string, request: unknown, signal?: AbortSignal): Promise<T>;
44
+ private invalidateAfter;
35
45
  }
36
46
  export declare function containerNotFound(container: string, slug?: string): Error;
37
47
  export declare function normalizeToolError(error: unknown): Error;
38
48
  export declare function workspaceSlug(id: unknown): string;
39
- export declare function containerRowFor(containers: any[], slug: string, container: string): any | undefined;
40
- export declare function metadata(token: string): grpc.Metadata;
49
+ export declare function metadata(token: string, pluginVersion?: string): grpc.Metadata;
@@ -1,31 +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";
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
+ };
6
23
  export class WorkspaceResolver {
7
24
  config;
8
25
  registry;
9
26
  bindings = new Map();
10
- 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) {
11
35
  this.config = config;
12
36
  this.registry = registry;
13
37
  }
14
- setConfig(patch) {
15
- Object.assign(this.config, patch);
16
- }
17
38
  getConfig() {
18
- 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
+ }
19
61
  }
20
- async resolve(cwd) {
62
+ async resolve(cwd, signal) {
21
63
  const workspace = await this.workspaceForCwd(cwd);
22
- const relativePath = String(workspace.path).replace(`${this.config.projectsRoot}/`, "");
23
64
  const key = workspaceSlug(workspace.id);
24
- 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);
25
67
  // The default container always keeps its project mount, so the session
26
68
  // directory is always mounted in it.
27
69
  const session = defaultCwdOf(cwd);
28
- return session === undefined ? binding : { ...binding, defaultCwd: session };
70
+ return session === undefined
71
+ ? binding
72
+ : { ...binding, defaultCwd: session };
29
73
  }
30
74
  // Resolve the workspace binding for a filesystem path when the caller did not
31
75
  // supply a session working directory. The 0.1.5 `fs.resolve` contract allows
@@ -33,13 +77,14 @@ export class WorkspaceResolver {
33
77
  // sensible owner, so match it by longest canonical-path prefix. A path
34
78
  // outside every workspace is not in any container's filesystem, so it is
35
79
  // reported as missing rather than failing the caller.
36
- async resolveForPath(path, cwd) {
80
+ async resolveForPath(path, cwd, signal) {
37
81
  if (typeof cwd === "string" && cwd !== "")
38
- return this.resolve(cwd);
82
+ return this.resolve(cwd, signal);
39
83
  if (isAbsolute(path)) {
40
84
  const workspace = this.containingWorkspace(path);
41
- if (workspace !== undefined)
42
- return this.resolve(String(workspace.path));
85
+ if (workspace !== undefined) {
86
+ return this.resolve(String(workspace.path), signal);
87
+ }
43
88
  throw notFoundError(`no DH workspace contains path ${JSON.stringify(path)}`);
44
89
  }
45
90
  throw new Error(`cannot resolve a DH workspace for path ${JSON.stringify(path)}; pass a session working directory`);
@@ -69,89 +114,141 @@ export class WorkspaceResolver {
69
114
  }
70
115
  return workspace;
71
116
  }
72
- resolveSlug(key) {
73
- return this.ready(key, key);
117
+ resolveSlug(key, signal) {
118
+ return this.ready(key, key, signal);
74
119
  }
75
- async containerBinding(cwd, container) {
120
+ async containerBinding(cwd, container, signal) {
76
121
  const workspace = await this.workspaceForCwd(cwd);
77
122
  const slug = workspaceSlug(workspace.id);
78
- const result = await this.control("listContainers", {});
79
- const row = containerRowFor(result.containers ?? [], slug, container);
80
- if (row === undefined) {
81
- 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
+ };
82
139
  }
83
- const socket = row.agentSocketPath;
84
- const binding = {
85
- guest: guestClient(socket),
86
- token: row.agentToken,
87
- socket,
88
- };
89
140
  const session = defaultCwdOf(cwd);
90
141
  if (session !== undefined &&
91
- projectMountCovers(this.config.projectsRoot, row.mounts ?? [], session)) {
92
- binding.defaultCwd = session;
142
+ projectMountCovers(this.getConfig().projectsRoot, entry.mounts, session)) {
143
+ return { ...entry.binding, defaultCwd: session };
93
144
  }
94
- await this.ensureReady(binding, slug, container);
95
- 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 ?? [] };
96
174
  }
97
175
  // ready resolves a workspace's default container and waits for its guest
98
176
  // agent, recreating the container once when it is reported running but its
99
177
  // agent never answers (a crashed agent or a lost socket).
100
- async ready(key, projectName) {
101
- let binding = await this.resolveBinding(key, projectName);
178
+ async ready(key, projectName, signal) {
179
+ let binding = await this.resolveBinding(key, projectName, signal);
102
180
  try {
103
- await waitForReady(binding.guest, this.readyTimeoutMs());
181
+ return await this.ensureAgentReady(binding);
104
182
  }
105
183
  catch {
106
184
  this.bindings.delete(key);
107
- binding = await this.resolveBinding(key, projectName);
185
+ binding = await this.resolveBinding(key, projectName, signal);
108
186
  try {
109
- await waitForReady(binding.guest, this.readyTimeoutMs());
187
+ return await this.ensureAgentReady(binding);
110
188
  }
111
189
  catch {
112
- await this.recoverContainer(key, "default");
190
+ await this.recoverContainer(key, "default", signal);
113
191
  this.bindings.delete(key);
114
- binding = await this.resolveBinding(key, projectName);
115
- await waitForReady(binding.guest, this.readyTimeoutMs());
192
+ binding = await this.resolveBinding(key, projectName, signal);
193
+ return await this.ensureAgentReady(binding);
116
194
  }
117
195
  }
118
- return binding;
119
196
  }
120
197
  readyTimeoutMs() {
121
- const configured = Number(this.config.readyTimeoutMs);
198
+ const configured = Number(this.getConfig().readyTimeoutMs);
122
199
  return Number.isFinite(configured) && configured > 0 ? configured : 15000;
123
200
  }
124
- // ensureReady waits for one binding's guest agent and recovers the container
125
- // once when the wait fails.
126
- 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());
127
219
  try {
128
- await waitForReady(binding.guest, this.readyTimeoutMs());
129
- return;
220
+ await pingAgent(binding);
221
+ return binding;
130
222
  }
131
- catch {
132
- // 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;
133
232
  }
134
- await this.recoverContainer(slug, container);
135
- await waitForReady(binding.guest, this.readyTimeoutMs());
136
233
  }
137
234
  // recoverContainer best-effort recreates a container whose agent is
138
235
  // unreachable. A failure here is not fatal: the caller's retry surfaces the
139
236
  // real readiness failure.
140
- async recoverContainer(slug, container) {
237
+ async recoverContainer(slug, container, signal) {
141
238
  try {
142
239
  await this.control("recreateContainer", {
143
240
  workspaceSlug: slug,
144
241
  container,
145
- });
242
+ }, signal);
146
243
  }
147
244
  catch {
148
245
  // Best-effort.
149
246
  }
150
247
  }
151
- resolveBinding(key, projectName) {
248
+ resolveBinding(key, projectName, signal) {
152
249
  let binding = this.bindings.get(key);
153
250
  if (binding === undefined) {
154
- binding = this.create(key, projectName).catch((error) => {
251
+ binding = this.create(key, projectName, signal).catch((error) => {
155
252
  if (this.bindings.get(key) === binding)
156
253
  this.bindings.delete(key);
157
254
  throw error;
@@ -160,34 +257,79 @@ export class WorkspaceResolver {
160
257
  }
161
258
  return binding;
162
259
  }
163
- async create(slug, projectName) {
164
- const control = controlClient(join(this.config.socketsRoot, "orchestrator.sock"));
165
- const controlMetadata = metadata(this.config.controlToken);
166
- 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);
167
263
  try {
168
- workspace = await unary(control, "describeWorkspace", {
264
+ await unary(control, "describeWorkspace", {
169
265
  workspaceSlug: slug,
170
- }, controlMetadata);
266
+ }, controlMetadata, signal);
171
267
  }
172
268
  catch (error) {
173
269
  if (error.code !== grpc.status.NOT_FOUND)
174
270
  throw error;
175
- 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", {
176
275
  workspaceSlug: slug,
177
276
  projectName,
178
- imageId: this.config.defaultImage,
277
+ imageId: this.getConfig().defaultImage,
179
278
  mounts: [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }],
180
- }, controlMetadata);
279
+ ...(Object.keys(env).length > 0 ? { env } : {}),
280
+ }, controlMetadata, signal);
281
+ }
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
+ });
287
+ }
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);
181
303
  }
182
- const socket = workspace.agentSocketPath;
183
- return {
184
- guest: guestClient(socket),
185
- token: workspace.agentToken,
186
- socket,
187
- };
188
304
  }
189
- async control(method, request) {
190
- return unary(controlClient(join(this.config.socketsRoot, "orchestrator.sock")), method, request, metadata(this.config.controlToken));
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);
191
333
  }
192
334
  }
193
335
  // notFoundError builds the harness's missing-path error (`FsErrorCode`
@@ -213,10 +355,15 @@ export function containerNotFound(container, slug) {
213
355
  // when the error carried one) so callers can branch without parsing text.
214
356
  export function normalizeToolError(error) {
215
357
  const raw = error instanceof Error ? error.message : String(error);
216
- const message = raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
217
358
  const code = error.code;
218
- const name = typeof code === "number"
219
- ? 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]
220
367
  : typeof code === "string"
221
368
  ? code
222
369
  : undefined;
@@ -230,6 +377,15 @@ function waitForReady(agent, timeoutMs = 15000) {
230
377
  agent.waitForReady(Date.now() + timeoutMs, (error) => error ? reject(error) : resolve());
231
378
  });
232
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
+ }
233
389
  // workspaceSlug validates the workspace id that names this workspace's pod
234
390
  // (dsh-podman-<slug>) and containers (dsh-podman-<slug>-<logical>). It must be
235
391
  // a UUID so a container name can never collide with a pod name.
@@ -241,14 +397,17 @@ export function workspaceSlug(id) {
241
397
  return value.toLowerCase();
242
398
  }
243
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;
244
- export function containerRowFor(containers, slug, container) {
245
- return containers.find((row) => row.workspaceSlug === slug && row.containerName === container);
246
- }
247
- export function metadata(token) {
400
+ // metadata builds the call metadata. The plugin version is sent on control
401
+ // calls so the orchestrator can refuse an incompatible plugin; guest calls omit
402
+ // it, since the guest agent does not check it.
403
+ export function metadata(token, pluginVersion) {
248
404
  const result = new grpc.Metadata();
249
405
  if (token !== "") {
250
406
  result.set("authorization", `bearer ${token}`);
251
407
  }
408
+ if (pluginVersion !== undefined && pluginVersion !== "") {
409
+ result.set("x-dsh-podman-plugin-version", pluginVersion);
410
+ }
252
411
  return result;
253
412
  }
254
413
  // defaultCwdOf returns the session working directory as a usable container
@@ -256,6 +415,21 @@ export function metadata(token) {
256
415
  function defaultCwdOf(cwd) {
257
416
  return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
258
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
+ }
259
433
  // projectMountCovers reports whether a project mount of a container makes the
260
434
  // given container path reachable: the mount destination itself or a path below
261
435
  // it.