@exagone313/dsh-podman 0.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/README.zh.md +41 -0
  4. package/cordis.patch.yml +17 -0
  5. package/dist/approval-reasons.d.ts +129 -0
  6. package/dist/approval-reasons.js +253 -0
  7. package/dist/approval-reasons.test.d.ts +1 -0
  8. package/dist/approval-reasons.test.js +115 -0
  9. package/dist/approval.d.ts +29 -0
  10. package/dist/approval.js +412 -0
  11. package/dist/approval.test.d.ts +1 -0
  12. package/dist/approval.test.js +462 -0
  13. package/dist/card-route.d.ts +15 -0
  14. package/dist/card-route.js +478 -0
  15. package/dist/card-route.test.d.ts +1 -0
  16. package/dist/card-route.test.js +181 -0
  17. package/dist/card-test-support.d.ts +15 -0
  18. package/dist/card-test-support.js +160 -0
  19. package/dist/client/ContainerCard.d.ts +6 -0
  20. package/dist/client/ContainerCard.js +79 -0
  21. package/dist/client/card-client.d.ts +6 -0
  22. package/dist/client/card-client.js +47 -0
  23. package/dist/client/card-protocol.d.ts +101 -0
  24. package/dist/client/card-protocol.js +10 -0
  25. package/dist/client/container-card-caches.d.ts +10 -0
  26. package/dist/client/container-card-caches.js +31 -0
  27. package/dist/client/container-card-controller.d.ts +107 -0
  28. package/dist/client/container-card-controller.js +207 -0
  29. package/dist/client/container-card-create-modal.d.ts +24 -0
  30. package/dist/client/container-card-create-modal.js +109 -0
  31. package/dist/client/container-card-directory-styles.d.ts +5 -0
  32. package/dist/client/container-card-directory-styles.js +246 -0
  33. package/dist/client/container-card-directory.d.ts +12 -0
  34. package/dist/client/container-card-directory.js +144 -0
  35. package/dist/client/container-card-editors.d.ts +30 -0
  36. package/dist/client/container-card-editors.js +116 -0
  37. package/dist/client/container-card-images.d.ts +37 -0
  38. package/dist/client/container-card-images.js +35 -0
  39. package/dist/client/container-card-paths.d.ts +20 -0
  40. package/dist/client/container-card-paths.js +88 -0
  41. package/dist/client/container-card-row.d.ts +30 -0
  42. package/dist/client/container-card-row.js +54 -0
  43. package/dist/client/container-card-secrets.d.ts +21 -0
  44. package/dist/client/container-card-secrets.js +71 -0
  45. package/dist/client/container-card-shared.d.ts +55 -0
  46. package/dist/client/container-card-shared.js +152 -0
  47. package/dist/client/container-card-styles.d.ts +21 -0
  48. package/dist/client/container-card-styles.js +143 -0
  49. package/dist/client/container-card-volumes.d.ts +12 -0
  50. package/dist/client/container-card-volumes.js +37 -0
  51. package/dist/client/container-card-workspace.d.ts +31 -0
  52. package/dist/client/container-card-workspace.js +24 -0
  53. package/dist/client/directory-picker.d.ts +12 -0
  54. package/dist/client/directory-picker.js +30 -0
  55. package/dist/client/index.d.ts +5 -0
  56. package/dist/client/index.js +4419 -0
  57. package/dist/client/locales.d.ts +9 -0
  58. package/dist/client/locales.js +370 -0
  59. package/dist/client/read-only-approval.d.ts +8 -0
  60. package/dist/client/read-only-approval.js +63 -0
  61. package/dist/client/slot-contract.d.ts +12 -0
  62. package/dist/client/slot-contract.js +4 -0
  63. package/dist/client/terminal-styles.d.ts +5 -0
  64. package/dist/client/terminal-styles.js +31 -0
  65. package/dist/client/tool-views.d.ts +8 -0
  66. package/dist/client/tool-views.js +241 -0
  67. package/dist/containers.test.d.ts +1 -0
  68. package/dist/containers.test.js +443 -0
  69. package/dist/daemons.test.d.ts +1 -0
  70. package/dist/daemons.test.js +153 -0
  71. package/dist/fs-provider.d.ts +23 -0
  72. package/dist/fs-provider.js +184 -0
  73. package/dist/fs-provider.test.d.ts +1 -0
  74. package/dist/fs-provider.test.js +260 -0
  75. package/dist/generated/version.d.ts +2 -0
  76. package/dist/generated/version.js +6 -0
  77. package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
  78. package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
  79. package/dist/grpc/runtime-client.d.ts +5 -0
  80. package/dist/grpc/runtime-client.js +43 -0
  81. package/dist/guest-rpc.d.ts +62 -0
  82. package/dist/guest-rpc.js +391 -0
  83. package/dist/images.test.d.ts +1 -0
  84. package/dist/images.test.js +79 -0
  85. package/dist/index.d.ts +22 -0
  86. package/dist/index.js +140 -0
  87. package/dist/misc.test.d.ts +1 -0
  88. package/dist/misc.test.js +141 -0
  89. package/dist/mount-enums.d.ts +22 -0
  90. package/dist/mount-enums.js +81 -0
  91. package/dist/mount-input.d.ts +14 -0
  92. package/dist/mount-input.js +64 -0
  93. package/dist/mounts.test.d.ts +1 -0
  94. package/dist/mounts.test.js +408 -0
  95. package/dist/output-reader.d.ts +34 -0
  96. package/dist/output-reader.js +87 -0
  97. package/dist/paths.test.d.ts +1 -0
  98. package/dist/paths.test.js +74 -0
  99. package/dist/preferences.d.ts +6 -0
  100. package/dist/preferences.js +27 -0
  101. package/dist/project-path.d.ts +6 -0
  102. package/dist/project-path.js +73 -0
  103. package/dist/project-path.test.d.ts +1 -0
  104. package/dist/project-path.test.js +52 -0
  105. package/dist/prompts.d.ts +13 -0
  106. package/dist/prompts.js +149 -0
  107. package/dist/public.d.ts +4 -0
  108. package/dist/public.js +63 -0
  109. package/dist/read-only-shell.d.ts +14 -0
  110. package/dist/read-only-shell.js +150 -0
  111. package/dist/read-only-shell.test.d.ts +1 -0
  112. package/dist/read-only-shell.test.js +127 -0
  113. package/dist/secrets.test.d.ts +1 -0
  114. package/dist/secrets.test.js +181 -0
  115. package/dist/settings-commands.test.d.ts +1 -0
  116. package/dist/settings-commands.test.js +549 -0
  117. package/dist/settings-create.test.d.ts +1 -0
  118. package/dist/settings-create.test.js +394 -0
  119. package/dist/settings-mounts.test.d.ts +1 -0
  120. package/dist/settings-mounts.test.js +467 -0
  121. package/dist/settings-paths.test.d.ts +1 -0
  122. package/dist/settings-paths.test.js +65 -0
  123. package/dist/settings-schema.d.ts +14 -0
  124. package/dist/settings-schema.js +14 -0
  125. package/dist/settings-workspaces.test.d.ts +1 -0
  126. package/dist/settings-workspaces.test.js +265 -0
  127. package/dist/spill-store.d.ts +17 -0
  128. package/dist/spill-store.js +54 -0
  129. package/dist/spill-store.test.d.ts +1 -0
  130. package/dist/spill-store.test.js +59 -0
  131. package/dist/subprocess.d.ts +9 -0
  132. package/dist/subprocess.js +369 -0
  133. package/dist/subprocess.test.d.ts +1 -0
  134. package/dist/subprocess.test.js +234 -0
  135. package/dist/test-support.d.ts +328 -0
  136. package/dist/test-support.js +437 -0
  137. package/dist/tool-defs.d.ts +26 -0
  138. package/dist/tool-defs.js +188 -0
  139. package/dist/tool-handlers.d.ts +2 -0
  140. package/dist/tool-handlers.js +546 -0
  141. package/dist/tool-params.d.ts +803 -0
  142. package/dist/tool-params.js +494 -0
  143. package/dist/tool-schemas.d.ts +2 -0
  144. package/dist/tool-schemas.js +5 -0
  145. package/dist/tool-views.d.ts +2 -0
  146. package/dist/tool-views.js +56 -0
  147. package/dist/views.test.d.ts +1 -0
  148. package/dist/views.test.js +23 -0
  149. package/dist/volumes.test.d.ts +1 -0
  150. package/dist/volumes.test.js +40 -0
  151. package/dist/workspace-binding.d.ts +40 -0
  152. package/dist/workspace-binding.js +275 -0
  153. package/dist/workspace-binding.test.d.ts +1 -0
  154. package/dist/workspace-binding.test.js +337 -0
  155. package/docs/architecture.md +173 -0
  156. package/docs/architecture.zh.md +129 -0
  157. package/docs/configuration.md +160 -0
  158. package/docs/configuration.zh.md +147 -0
  159. package/docs/development.md +142 -0
  160. package/docs/development.zh.md +135 -0
  161. package/docs/install-dsh-and-dsh-podman.md +165 -0
  162. package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
  163. package/docs/usage.md +393 -0
  164. package/docs/usage.zh.md +315 -0
  165. package/package.json +119 -0
@@ -0,0 +1,275 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import { guestClient, controlClient, grpc, unary, } from "./grpc/runtime-client.js";
5
+ import { isAbsolute, join } from "node:path";
6
+ export class WorkspaceResolver {
7
+ config;
8
+ registry;
9
+ bindings = new Map();
10
+ constructor(config, registry) {
11
+ this.config = config;
12
+ this.registry = registry;
13
+ }
14
+ setConfig(patch) {
15
+ Object.assign(this.config, patch);
16
+ }
17
+ getConfig() {
18
+ return this.config;
19
+ }
20
+ async resolve(cwd) {
21
+ const workspace = await this.workspaceForCwd(cwd);
22
+ const relativePath = String(workspace.path).replace(`${this.config.projectsRoot}/`, "");
23
+ const key = workspaceSlug(workspace.id);
24
+ const binding = await this.ready(key, relativePath);
25
+ // The default container always keeps its project mount, so the session
26
+ // directory is always mounted in it.
27
+ const session = defaultCwdOf(cwd);
28
+ return session === undefined ? binding : { ...binding, defaultCwd: session };
29
+ }
30
+ // Resolve the workspace binding for a filesystem path when the caller did not
31
+ // supply a session working directory. The 0.1.5 `fs.resolve` contract allows
32
+ // `{ signal }` alone, and the workspace that CONTAINS the path is the only
33
+ // sensible owner, so match it by longest canonical-path prefix. A path
34
+ // outside every workspace is not in any container's filesystem, so it is
35
+ // reported as missing rather than failing the caller.
36
+ async resolveForPath(path, cwd) {
37
+ if (typeof cwd === "string" && cwd !== "")
38
+ return this.resolve(cwd);
39
+ if (isAbsolute(path)) {
40
+ const workspace = this.containingWorkspace(path);
41
+ if (workspace !== undefined)
42
+ return this.resolve(String(workspace.path));
43
+ throw notFoundError(`no DH workspace contains path ${JSON.stringify(path)}`);
44
+ }
45
+ throw new Error(`cannot resolve a DH workspace for path ${JSON.stringify(path)}; pass a session working directory`);
46
+ }
47
+ containingWorkspace(path) {
48
+ const entries = this.registry?.list?.() ?? [];
49
+ let match;
50
+ for (const workspace of entries) {
51
+ const root = workspace?.path;
52
+ if (typeof root !== "string" || root === "")
53
+ continue;
54
+ if (path !== root && !path.startsWith(`${root}/`))
55
+ continue;
56
+ if (match === undefined || root.length > String(match.path).length) {
57
+ match = workspace;
58
+ }
59
+ }
60
+ return match;
61
+ }
62
+ async workspaceForCwd(cwd) {
63
+ if (typeof cwd !== "string" || cwd === "") {
64
+ throw new Error(`cannot resolve a DH workspace without a session working directory (got ${JSON.stringify(cwd)})`);
65
+ }
66
+ const workspace = await this.registry?.resolveByPath?.(cwd);
67
+ if (workspace === undefined) {
68
+ throw new Error(`no DH workspace owns session cwd ${JSON.stringify(cwd)}`);
69
+ }
70
+ return workspace;
71
+ }
72
+ resolveSlug(key) {
73
+ return this.ready(key, key);
74
+ }
75
+ async containerBinding(cwd, container) {
76
+ const workspace = await this.workspaceForCwd(cwd);
77
+ 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);
82
+ }
83
+ const socket = row.agentSocketPath;
84
+ const binding = {
85
+ guest: guestClient(socket),
86
+ token: row.agentToken,
87
+ socket,
88
+ };
89
+ const session = defaultCwdOf(cwd);
90
+ if (session !== undefined &&
91
+ projectMountCovers(this.config.projectsRoot, row.mounts ?? [], session)) {
92
+ binding.defaultCwd = session;
93
+ }
94
+ await this.ensureReady(binding, slug, container);
95
+ return binding;
96
+ }
97
+ // ready resolves a workspace's default container and waits for its guest
98
+ // agent, recreating the container once when it is reported running but its
99
+ // agent never answers (a crashed agent or a lost socket).
100
+ async ready(key, projectName) {
101
+ let binding = await this.resolveBinding(key, projectName);
102
+ try {
103
+ await waitForReady(binding.guest, this.readyTimeoutMs());
104
+ }
105
+ catch {
106
+ this.bindings.delete(key);
107
+ binding = await this.resolveBinding(key, projectName);
108
+ try {
109
+ await waitForReady(binding.guest, this.readyTimeoutMs());
110
+ }
111
+ catch {
112
+ await this.recoverContainer(key, "default");
113
+ this.bindings.delete(key);
114
+ binding = await this.resolveBinding(key, projectName);
115
+ await waitForReady(binding.guest, this.readyTimeoutMs());
116
+ }
117
+ }
118
+ return binding;
119
+ }
120
+ readyTimeoutMs() {
121
+ const configured = Number(this.config.readyTimeoutMs);
122
+ return Number.isFinite(configured) && configured > 0 ? configured : 15000;
123
+ }
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) {
127
+ try {
128
+ await waitForReady(binding.guest, this.readyTimeoutMs());
129
+ return;
130
+ }
131
+ catch {
132
+ // Recover below.
133
+ }
134
+ await this.recoverContainer(slug, container);
135
+ await waitForReady(binding.guest, this.readyTimeoutMs());
136
+ }
137
+ // recoverContainer best-effort recreates a container whose agent is
138
+ // unreachable. A failure here is not fatal: the caller's retry surfaces the
139
+ // real readiness failure.
140
+ async recoverContainer(slug, container) {
141
+ try {
142
+ await this.control("recreateContainer", {
143
+ workspaceSlug: slug,
144
+ container,
145
+ });
146
+ }
147
+ catch {
148
+ // Best-effort.
149
+ }
150
+ }
151
+ resolveBinding(key, projectName) {
152
+ let binding = this.bindings.get(key);
153
+ if (binding === undefined) {
154
+ binding = this.create(key, projectName).catch((error) => {
155
+ if (this.bindings.get(key) === binding)
156
+ this.bindings.delete(key);
157
+ throw error;
158
+ });
159
+ this.bindings.set(key, binding);
160
+ }
161
+ return binding;
162
+ }
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;
167
+ try {
168
+ workspace = await unary(control, "describeWorkspace", {
169
+ workspaceSlug: slug,
170
+ }, controlMetadata);
171
+ }
172
+ catch (error) {
173
+ if (error.code !== grpc.status.NOT_FOUND)
174
+ throw error;
175
+ workspace = await unary(control, "createWorkspace", {
176
+ workspaceSlug: slug,
177
+ projectName,
178
+ imageId: this.config.defaultImage,
179
+ mounts: [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }],
180
+ }, controlMetadata);
181
+ }
182
+ const socket = workspace.agentSocketPath;
183
+ return {
184
+ guest: guestClient(socket),
185
+ token: workspace.agentToken,
186
+ socket,
187
+ };
188
+ }
189
+ async control(method, request) {
190
+ return unary(controlClient(join(this.config.socketsRoot, "orchestrator.sock")), method, request, metadata(this.config.controlToken));
191
+ }
192
+ }
193
+ // notFoundError builds the harness's missing-path error (`FsErrorCode`
194
+ // `'FS_NOT_FOUND'`). A path outside every workspace is not part of any
195
+ // container's filesystem, so callers must see it as absent: agent-instructions'
196
+ // project-root walk probes ancestors above the workspace and only continues
197
+ // when this code comes back.
198
+ function notFoundError(message) {
199
+ const error = new Error(message);
200
+ error.code = "FS_NOT_FOUND";
201
+ return error;
202
+ }
203
+ // containerNotFound is the one not-found shape every container tool reports.
204
+ export function containerNotFound(container, slug) {
205
+ const error = new Error(slug === undefined
206
+ ? `container ${JSON.stringify(container)} not found`
207
+ : `container ${JSON.stringify(container)} not found in workspace ${JSON.stringify(slug)}`);
208
+ error.code = "NOT_FOUND";
209
+ return error;
210
+ }
211
+ // normalizeToolError gives every tool failure one shape: a message without
212
+ // grpc's "<code> <NAME>: " prefix, and a string `code` (the grpc status name
213
+ // when the error carried one) so callers can branch without parsing text.
214
+ export function normalizeToolError(error) {
215
+ const raw = error instanceof Error ? error.message : String(error);
216
+ const message = raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
217
+ const code = error.code;
218
+ const name = typeof code === "number"
219
+ ? grpc.status[code]
220
+ : typeof code === "string"
221
+ ? code
222
+ : undefined;
223
+ const normalized = new Error(message);
224
+ if (name !== undefined)
225
+ normalized.code = name;
226
+ return normalized;
227
+ }
228
+ function waitForReady(agent, timeoutMs = 15000) {
229
+ return new Promise((resolve, reject) => {
230
+ agent.waitForReady(Date.now() + timeoutMs, (error) => error ? reject(error) : resolve());
231
+ });
232
+ }
233
+ // workspaceSlug validates the workspace id that names this workspace's pod
234
+ // (dsh-podman-<slug>) and containers (dsh-podman-<slug>-<logical>). It must be
235
+ // a UUID so a container name can never collide with a pod name.
236
+ export function workspaceSlug(id) {
237
+ const value = String(id ?? "");
238
+ if (!UUID_PATTERN.test(value)) {
239
+ throw new Error(`invalid workspace id ${JSON.stringify(value)}: expected a UUID`);
240
+ }
241
+ return value.toLowerCase();
242
+ }
243
+ 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) {
248
+ const result = new grpc.Metadata();
249
+ if (token !== "") {
250
+ result.set("authorization", `bearer ${token}`);
251
+ }
252
+ return result;
253
+ }
254
+ // defaultCwdOf returns the session working directory as a usable container
255
+ // path, or undefined when it is unset.
256
+ function defaultCwdOf(cwd) {
257
+ return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
258
+ }
259
+ // projectMountCovers reports whether a project mount of a container makes the
260
+ // given container path reachable: the mount destination itself or a path below
261
+ // it.
262
+ function projectMountCovers(projectsRoot, mounts, path) {
263
+ return mounts.some((mount) => {
264
+ if (!isProjectMount(mount))
265
+ return false;
266
+ const destination = `${projectsRoot}/${mount.projectName}`;
267
+ return path === destination || path.startsWith(`${destination}/`);
268
+ });
269
+ }
270
+ // isProjectMount reports whether a proto mount is a project mount. An unset
271
+ // kind is the project default.
272
+ function isProjectMount(mount) {
273
+ const kind = mount?.kind;
274
+ return kind === undefined || kind === "" || kind === "MOUNT_KIND_PROJECT";
275
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,337 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import grpc from "@grpc/grpc-js";
7
+ import loader from "@grpc/proto-loader";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, resolve } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { mkdirSync } from "node:fs";
12
+ import { controlClient, guestClient } from "./grpc/runtime-client.js";
13
+ import { workspaceSlug, metadata, WorkspaceResolver, containerRowFor, containerNotFound, normalizeToolError, } from "./workspace-binding.js";
14
+ const SLUG = "2c573001-4171-4900-904b-12a5cc02737a";
15
+ const SLUG_SUB = "3d684112-5282-4a11-a15c-23b6dd13848b";
16
+ test("workspaceSlug accepts a UUID workspace id", () => {
17
+ assert.equal(workspaceSlug(SLUG), SLUG);
18
+ assert.equal(workspaceSlug(SLUG.toUpperCase()), SLUG);
19
+ });
20
+ test("workspaceSlug rejects anything that is not a UUID", () => {
21
+ const invalid = [
22
+ undefined,
23
+ null,
24
+ "",
25
+ "proj",
26
+ "team-app",
27
+ "a/b",
28
+ "..",
29
+ "a b",
30
+ `${SLUG}-x`,
31
+ SLUG.slice(0, -1),
32
+ ];
33
+ for (const value of invalid) {
34
+ assert.throws(() => workspaceSlug(value), /expected a UUID/, `expected ${JSON.stringify(value)} to be rejected`);
35
+ }
36
+ });
37
+ test("metadata carries the bearer token", () => {
38
+ const result = metadata("token-1");
39
+ assert.ok(result instanceof grpc.Metadata);
40
+ assert.equal(result.get("authorization")[0], "bearer token-1");
41
+ });
42
+ test("metadata omits the header when the token is empty", () => {
43
+ const result = metadata("");
44
+ assert.ok(result instanceof grpc.Metadata);
45
+ assert.equal(result.get("authorization").length, 0);
46
+ });
47
+ test("normalizeToolError strips the grpc prefix and names the code", () => {
48
+ const prefixed = Object.assign(new Error("5 NOT_FOUND: container not found"), { code: 5 });
49
+ const normalized = normalizeToolError(prefixed);
50
+ assert.equal(normalized.message, "container not found");
51
+ assert.equal(normalized.code, "NOT_FOUND");
52
+ const plain = normalizeToolError(new Error("boom"));
53
+ assert.equal(plain.message, "boom");
54
+ assert.equal(plain.code, undefined);
55
+ const named = Object.assign(new Error("bad input"), { code: "INVALID_ARGUMENT" });
56
+ assert.equal(normalizeToolError(named).code, "INVALID_ARGUMENT");
57
+ });
58
+ test("containerNotFound reports one stable shape", () => {
59
+ assert.equal(containerNotFound("db", "w1").message, 'container "db" not found in workspace "w1"');
60
+ assert.equal(containerNotFound("db").code, "NOT_FOUND");
61
+ assert.equal(containerNotFound("db").message, 'container "db" not found');
62
+ });
63
+ test("containerRowFor finds rows by workspace and name", () => {
64
+ const containers = [
65
+ { workspaceSlug: "team-app", containerName: "default", status: "running" },
66
+ { workspaceSlug: "team-app", containerName: "db", status: "stopped" },
67
+ { workspaceSlug: "other", containerName: "db", status: "running" },
68
+ ];
69
+ assert.deepEqual(containerRowFor(containers, "team-app", "db"), {
70
+ workspaceSlug: "team-app",
71
+ containerName: "db",
72
+ status: "stopped",
73
+ });
74
+ assert.deepEqual(containerRowFor(containers, "team-app", "default"), {
75
+ workspaceSlug: "team-app",
76
+ containerName: "default",
77
+ status: "running",
78
+ });
79
+ assert.equal(containerRowFor(containers, "team-app", "missing"), undefined);
80
+ assert.equal(containerRowFor([], "team-app", "default"), undefined);
81
+ });
82
+ test("control and guest proto files resolve next to the runtime", () => {
83
+ const control = controlClient("/tmp/dsh-proto-control.sock");
84
+ const guest = guestClient("/tmp/dsh-proto-guest.sock");
85
+ control.close();
86
+ guest.close();
87
+ });
88
+ async function startControlServer(containers = [], options = {}) {
89
+ const protoPath = resolve(dirname(fileURLToPath(import.meta.url)), "grpc/proto/dshctl/v1/control.proto");
90
+ const definition = loader.loadSync(protoPath, {
91
+ longs: String,
92
+ enums: String,
93
+ defaults: true,
94
+ });
95
+ const loaded = grpc.loadPackageDefinition(definition);
96
+ const server = new grpc.Server();
97
+ const received = [];
98
+ const createRequests = [];
99
+ const recreateRequests = [];
100
+ server.addService(loaded.dshctl.v1.OrchestratorControl.service, {
101
+ listWorkspaces: (call, callback) => {
102
+ received.push(call.metadata.get("authorization")[0]);
103
+ callback(null, { workspaces: [] });
104
+ },
105
+ describeWorkspace: (_call, callback) => {
106
+ callback({ code: grpc.status.NOT_FOUND, details: "workspace not found" });
107
+ },
108
+ listContainers: (_call, callback) => {
109
+ callback(null, { containers });
110
+ },
111
+ createWorkspace: (call, callback) => {
112
+ createRequests.push(call.request);
113
+ callback(null, {
114
+ workspaceSlug: call.request.workspaceSlug,
115
+ agentSocketPath: resolve(socketsRoot, "guest.sock"),
116
+ agentToken: "tok",
117
+ });
118
+ },
119
+ recreateContainer: (call, callback) => {
120
+ recreateRequests.push(call.request);
121
+ callback(null, { workspaceSlug: call.request.workspaceSlug });
122
+ },
123
+ });
124
+ const socketsRoot = resolve(tmpdir(), `dsh-control-test-${process.pid}-${Date.now()}-${Math.random()}`);
125
+ mkdirSync(socketsRoot, { recursive: true });
126
+ const socket = resolve(socketsRoot, "orchestrator.sock");
127
+ await new Promise((ok, fail) => server.bindAsync(`unix:${socket}`, grpc.ServerCredentials.createInsecure(), (error) => (error ? fail(error) : ok())));
128
+ // Also answer on the guest socket the fake createWorkspace hands back, so a
129
+ // resolved binding becomes ready immediately instead of waiting out the
130
+ // readiness deadline.
131
+ await new Promise((ok, fail) => server.bindAsync(`unix:${resolve(socketsRoot, "guest.sock")}`, grpc.ServerCredentials.createInsecure(), (error) => (error ? fail(error) : ok())));
132
+ // Point the fake container rows at the same guest socket, so a named
133
+ // container binding becomes ready immediately too.
134
+ if (options.rewriteContainerSocket !== false) {
135
+ for (const row of containers) {
136
+ row.agentSocketPath = resolve(socketsRoot, "guest.sock");
137
+ }
138
+ }
139
+ return {
140
+ socketsRoot,
141
+ received,
142
+ createRequests,
143
+ recreateRequests,
144
+ stop: () => server.forceShutdown(),
145
+ };
146
+ }
147
+ test("control calls carry the bearer token", async () => {
148
+ const { socketsRoot, received, stop } = await startControlServer();
149
+ try {
150
+ const resolver = new WorkspaceResolver({
151
+ socketsRoot,
152
+ defaultImage: "arch",
153
+ projectsRoot: "/projects",
154
+ controlToken: "tok-1",
155
+ }, undefined);
156
+ await resolver.control("listWorkspaces", {});
157
+ assert.equal(received[0], "bearer tok-1");
158
+ }
159
+ finally {
160
+ stop();
161
+ }
162
+ });
163
+ test("control calls omit the token when unset", async () => {
164
+ const { socketsRoot, received, stop } = await startControlServer();
165
+ try {
166
+ const resolver = new WorkspaceResolver({
167
+ socketsRoot,
168
+ defaultImage: "arch",
169
+ projectsRoot: "/projects",
170
+ controlToken: "",
171
+ }, undefined);
172
+ await resolver.control("listWorkspaces", {});
173
+ assert.equal(received[0], undefined);
174
+ }
175
+ finally {
176
+ stop();
177
+ }
178
+ });
179
+ test("the auto-created workspace mount stays read-write", async () => {
180
+ // Additional mounts default to read-only, but the workspace's own project
181
+ // directory must stay writable or the agent cannot edit the project.
182
+ const { socketsRoot, createRequests, stop } = await startControlServer();
183
+ try {
184
+ const resolver = new WorkspaceResolver({
185
+ socketsRoot,
186
+ defaultImage: "arch",
187
+ projectsRoot: "/projects",
188
+ controlToken: "",
189
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
190
+ await resolver.resolve("/projects/team");
191
+ assert.equal(createRequests.length, 1);
192
+ assert.equal(createRequests[0].projectName, "team");
193
+ const mounts = createRequests[0].mounts;
194
+ assert.equal(mounts.length, 1);
195
+ assert.equal(mounts[0].projectName, "team");
196
+ assert.equal(mounts[0].mode, "MOUNT_MODE_READ_WRITE");
197
+ }
198
+ finally {
199
+ stop();
200
+ }
201
+ });
202
+ test("resolve exposes the session directory as the default cwd", async () => {
203
+ const { socketsRoot, stop } = await startControlServer();
204
+ try {
205
+ const resolver = new WorkspaceResolver({
206
+ socketsRoot,
207
+ defaultImage: "arch",
208
+ projectsRoot: "/projects",
209
+ controlToken: "",
210
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
211
+ const binding = await resolver.resolve("/projects/team");
212
+ assert.equal(binding.defaultCwd, "/projects/team");
213
+ }
214
+ finally {
215
+ stop();
216
+ }
217
+ });
218
+ test("containerBinding exposes the session directory only when a project mount covers it", async () => {
219
+ const mounted = await startControlServer([{
220
+ workspaceSlug: SLUG,
221
+ containerName: "db",
222
+ agentSocketPath: "/run/x.sock",
223
+ agentToken: "tok",
224
+ mounts: [{ kind: "MOUNT_KIND_PROJECT", projectName: "team" }],
225
+ }]);
226
+ try {
227
+ const resolver = new WorkspaceResolver({
228
+ socketsRoot: mounted.socketsRoot,
229
+ defaultImage: "arch",
230
+ projectsRoot: "/projects",
231
+ controlToken: "",
232
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
233
+ const binding = await resolver.containerBinding("/projects/team", "db");
234
+ assert.equal(binding.defaultCwd, "/projects/team");
235
+ }
236
+ finally {
237
+ mounted.stop();
238
+ }
239
+ const unmounted = await startControlServer([{
240
+ workspaceSlug: SLUG,
241
+ containerName: "db",
242
+ agentSocketPath: "/run/x.sock",
243
+ agentToken: "tok",
244
+ mounts: [{ kind: "MOUNT_KIND_VOLUME", volume: "data", destination: "/data" }],
245
+ }]);
246
+ try {
247
+ const resolver = new WorkspaceResolver({
248
+ socketsRoot: unmounted.socketsRoot,
249
+ defaultImage: "arch",
250
+ projectsRoot: "/projects",
251
+ controlToken: "",
252
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
253
+ const binding = await resolver.containerBinding("/projects/team", "db");
254
+ assert.equal(binding.defaultCwd, undefined);
255
+ }
256
+ finally {
257
+ unmounted.stop();
258
+ }
259
+ });
260
+ test("containerBinding recreates a container whose agent never answers", async () => {
261
+ const control = await startControlServer([{
262
+ workspaceSlug: SLUG,
263
+ containerName: "db",
264
+ agentSocketPath: "/nonexistent/guest.sock",
265
+ agentToken: "tok",
266
+ mounts: [],
267
+ }], { rewriteContainerSocket: false });
268
+ try {
269
+ const resolver = new WorkspaceResolver({
270
+ socketsRoot: control.socketsRoot,
271
+ defaultImage: "arch",
272
+ projectsRoot: "/projects",
273
+ controlToken: "",
274
+ readyTimeoutMs: 50,
275
+ }, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
276
+ await assert.rejects(() => resolver.containerBinding("/projects/team", "db"));
277
+ assert.equal(control.recreateRequests.length, 1);
278
+ assert.equal(control.recreateRequests[0].container, "db");
279
+ }
280
+ finally {
281
+ control.stop();
282
+ }
283
+ });
284
+ test("resolveForPath derives the workspace from an absolute path without a cwd", async () => {
285
+ const { socketsRoot, createRequests, stop } = await startControlServer();
286
+ try {
287
+ const resolver = new WorkspaceResolver({
288
+ socketsRoot,
289
+ defaultImage: "arch",
290
+ projectsRoot: "/projects",
291
+ controlToken: "",
292
+ }, {
293
+ list: () => [
294
+ { id: SLUG, path: "/projects/team" },
295
+ { id: SLUG_SUB, path: "/projects/team/sub" },
296
+ ],
297
+ resolveByPath: (path) => path === "/projects/team/sub"
298
+ ? { id: SLUG_SUB, path: "/projects/team/sub" }
299
+ : { id: SLUG, path: "/projects/team" },
300
+ });
301
+ const binding = await resolver.resolveForPath("/projects/team/sub/AGENTS.md", undefined);
302
+ assert.equal(binding.defaultCwd, "/projects/team/sub");
303
+ assert.equal(createRequests[0].projectName, "team/sub");
304
+ }
305
+ finally {
306
+ stop();
307
+ }
308
+ });
309
+ test("resolveForPath rejects paths it cannot map to a workspace", async () => {
310
+ const resolver = new WorkspaceResolver({
311
+ socketsRoot: "/run/dsh",
312
+ defaultImage: "arch",
313
+ projectsRoot: "/projects",
314
+ controlToken: "",
315
+ }, {
316
+ list: () => [{ id: SLUG, path: "/projects/team" }],
317
+ resolveByPath: () => undefined,
318
+ });
319
+ await assert.rejects(() => resolver.resolveForPath("README.md", undefined), /session working directory/);
320
+ // A path outside every workspace is not in any container's filesystem: it is
321
+ // reported with the harness's missing-path code so callers treat it as absent
322
+ // (agent-instructions walks ancestors above the workspace this way).
323
+ await assert.rejects(() => resolver.resolveForPath("/elsewhere/file", undefined), (error) => error instanceof Error && error.code === "FS_NOT_FOUND");
324
+ });
325
+ test("resolve rejects a missing cwd without stringifying it", async () => {
326
+ const resolver = new WorkspaceResolver({
327
+ socketsRoot: "/run/dsh",
328
+ defaultImage: "arch",
329
+ projectsRoot: "/projects",
330
+ controlToken: "",
331
+ }, {
332
+ resolveByPath: () => {
333
+ throw new Error("resolveByPath must not be called with a missing cwd");
334
+ },
335
+ });
336
+ await assert.rejects(() => resolver.resolve(undefined), /session working directory/);
337
+ });