@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,184 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import { DIFF_BASIS_MAX_BYTES, detectLineEndings, fsError, guestChunks, guestStat, guestStatResponse, guestTextChunks, guestVersion, normalizeLineEndings, readDiffBasis, readGuestText, resolveGuestPath, restoreLineEndings, throwIfAborted, unaryGuest, writeGuestFile, } from "./guest-rpc.js";
5
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
6
+ export function createFilesystemProvider(resolver) {
7
+ return {
8
+ resolve: async (path, opts) => {
9
+ throwIfAborted(opts?.signal, "resolve");
10
+ const resolved = resolveGuestPath(path, opts?.cwd);
11
+ // An explicit container targets that container's guest; the harness's own
12
+ // tools never pass one and keep the session workspace's default.
13
+ const container = typeof opts?.container === "string" ? opts.container : "";
14
+ const binding = container !== "" && container !== "default"
15
+ ? await resolver.containerBinding(opts?.cwd, container)
16
+ : await resolver.resolveForPath(resolved, opts?.cwd);
17
+ return {
18
+ targetKey: resolved,
19
+ displayPath: resolved,
20
+ binding,
21
+ };
22
+ },
23
+ processPath: (target) => target.targetKey,
24
+ // The guest mounts each workspace at its mirrored path, so a host path in
25
+ // the execution world names the same path; anything relative has no world.
26
+ processPathFromHostPath: (hostPath) => isAbsolute(hostPath) ? resolvePath(hostPath) : undefined,
27
+ fileUrl: (target) => `file://${target.targetKey}`,
28
+ contains: (parent, child) => child.targetKey === parent.targetKey ||
29
+ child.targetKey.startsWith(`${parent.targetKey}/`),
30
+ readText: (target, signal) => readGuestText(target, signal),
31
+ streamText: (target, signal) => Promise.resolve(guestTextChunks(target, signal)),
32
+ readBytes: async (target, signal, maxBytes) => {
33
+ const chunks = [];
34
+ let total = 0;
35
+ for await (const buffer of guestChunks(target, { length: maxBytes + 1 }, signal)) {
36
+ total += buffer.length;
37
+ if (total > maxBytes) {
38
+ throw fsError("FS_TOO_LARGE", `cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`);
39
+ }
40
+ chunks.push(buffer);
41
+ }
42
+ return Buffer.concat(chunks, total);
43
+ },
44
+ readByteRange: async (target, range, signal) => {
45
+ if (range.length === 0)
46
+ return new Uint8Array(0);
47
+ const chunks = [];
48
+ let total = 0;
49
+ for await (const buffer of guestChunks(target, range, signal)) {
50
+ total += buffer.length;
51
+ chunks.push(buffer);
52
+ }
53
+ return Buffer.concat(chunks, total);
54
+ },
55
+ lstat: async (path, opts, signal) => {
56
+ throwIfAborted(signal, "lstat");
57
+ const resolved = resolveGuestPath(path, opts?.cwd);
58
+ const binding = await resolver.resolveForPath(resolved, opts?.cwd);
59
+ const result = await unaryGuest({ binding }, "stat", { path: resolved, noFollow: true }, signal);
60
+ if (!result?.exists)
61
+ return undefined;
62
+ return {
63
+ version: guestVersion(result),
64
+ type: result.isSymlink
65
+ ? "symlink"
66
+ : result.isDir
67
+ ? "directory"
68
+ : "file",
69
+ ...(result.size !== undefined && result.size !== null
70
+ ? { size: Number(result.size) }
71
+ : {}),
72
+ };
73
+ },
74
+ writeText: async (target, content, expected, signal) => {
75
+ throwIfAborted(signal, "write");
76
+ const current = await guestStatResponse(target, signal);
77
+ if (current.exists && current.isDir) {
78
+ throw fsError("FS_NOT_REGULAR_FILE", `cannot write "${target.displayPath}": not a regular file`);
79
+ }
80
+ const currentVersion = current.exists ? guestVersion(current) : undefined;
81
+ if (expected?.kind === "replaceIfVersion") {
82
+ // A file that is gone is a new file: creating it destroys nothing, so
83
+ // this backend creates it. (The harness's fs-local reports
84
+ // FS_STALE_VERSION here; a path the session read and something else
85
+ // removed must stay writable, so the container tools create it.)
86
+ if (current.exists && currentVersion !== expected.version) {
87
+ throw fsError("FS_STALE_VERSION", `cannot write "${target.displayPath}": file changed since it was read`);
88
+ }
89
+ }
90
+ else if (expected?.kind === "createIfAbsent" && current.exists) {
91
+ throw fsError("FS_NOT_OBSERVED", `cannot overwrite existing "${target.displayPath}" without reading it first`);
92
+ }
93
+ // The basis is gated on the incoming content, like the local backend:
94
+ // either side at/above the limit yields `before: null` and the consumer
95
+ // falls back to a whole-file diff. Both sides are LF-normalized so a CRLF
96
+ // overwrite does not read as every line changed.
97
+ const rawBasis = current.exists &&
98
+ Buffer.byteLength(content, "utf8") < DIFF_BASIS_MAX_BYTES
99
+ ? await readDiffBasis(target, signal)
100
+ : null;
101
+ const before = rawBasis === null ? null : normalizeLineEndings(rawBasis);
102
+ await writeGuestFile({ guest: target.binding.guest, token: target.binding.token }, target.targetKey, content, { create: true, truncate: true }, signal);
103
+ const after = await guestStatResponse(target, signal);
104
+ return {
105
+ operation: current.exists ? "update" : "create",
106
+ version: guestVersion(after),
107
+ before,
108
+ after: normalizeLineEndings(content),
109
+ };
110
+ },
111
+ stat: (target, signal) => guestStat(target, signal),
112
+ listDir: async (target, signal) => {
113
+ const response = await unaryGuest(target, "readDir", { path: target.targetKey }, signal);
114
+ const entries = Array.isArray(response?.entries)
115
+ ? response.entries
116
+ : [];
117
+ return entries.map((entry) => {
118
+ const childKey = join(target.targetKey, entry.name);
119
+ // Prefer the guest-reported followed type; fall back to the directory
120
+ // bit for an agent that predates the field.
121
+ const type = entry.type === "file" ||
122
+ entry.type === "directory" ||
123
+ entry.type === "other"
124
+ ? entry.type
125
+ : entry.isDir
126
+ ? "directory"
127
+ : "file";
128
+ return {
129
+ name: entry.name,
130
+ type,
131
+ target: {
132
+ targetKey: childKey,
133
+ displayPath: childKey,
134
+ binding: target.binding,
135
+ },
136
+ ...(type === "file" &&
137
+ entry.size !== undefined &&
138
+ entry.size !== null
139
+ ? { size: Number(entry.size) }
140
+ : {}),
141
+ };
142
+ });
143
+ },
144
+ mkdir: async (target, parents = true) => unaryGuest(target, "mkdir", { path: target.targetKey, parents }),
145
+ remove: async (target, recursive = false) => unaryGuest(target, "delete", { path: target.targetKey, recursive }),
146
+ editText: async (target, edit, expected, signal) => {
147
+ throwIfAborted(signal, "edit");
148
+ const current = await guestStatResponse(target, signal);
149
+ if (!current.exists) {
150
+ throw fsError("FS_STALE_VERSION", `cannot edit "${target.displayPath}": file changed since it was read`);
151
+ }
152
+ if (current.isDir) {
153
+ throw fsError("FS_NOT_REGULAR_FILE", `cannot edit "${target.displayPath}": not a regular file`);
154
+ }
155
+ if (expected !== undefined &&
156
+ guestVersion(current) !== expected.version) {
157
+ throw fsError("FS_STALE_VERSION", `cannot edit "${target.displayPath}": file changed since it was read`);
158
+ }
159
+ const raw = await readGuestText(target, signal);
160
+ const before = normalizeLineEndings(raw);
161
+ const lineEndings = detectLineEndings(raw);
162
+ const oldNorm = normalizeLineEndings(typeof edit.oldString === "string" ? edit.oldString : "");
163
+ if (oldNorm.length === 0) {
164
+ throw fsError("FS_EDIT_NOT_FOUND", "old_string must be a non-empty string");
165
+ }
166
+ const newNorm = normalizeLineEndings(typeof edit.newString === "string" ? edit.newString : "");
167
+ const occurrences = before.split(oldNorm).length - 1;
168
+ if (occurrences === 0) {
169
+ throw fsError("FS_EDIT_NOT_FOUND", `old_string was not found in "${target.displayPath}"`);
170
+ }
171
+ if (!edit.replaceAll && occurrences > 1) {
172
+ throw fsError("FS_AMBIGUOUS_EDIT", `old_string matched ${occurrences} times in "${target.displayPath}"; provide a more specific old_string or set replace_all to true`);
173
+ }
174
+ const after = before.split(oldNorm).join(newNorm);
175
+ await writeGuestFile({ guest: target.binding.guest, token: target.binding.token }, target.targetKey, restoreLineEndings(after, lineEndings), { create: true, truncate: true }, signal);
176
+ const afterInfo = await guestStatResponse(target, signal);
177
+ return {
178
+ version: guestVersion(afterInfo),
179
+ before,
180
+ after,
181
+ };
182
+ },
183
+ };
184
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,260 @@
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 { editGuest, editProvider, fakeGuest, providerFor, stubResolver } from "./test-support.js";
7
+ import { createFilesystemProvider } from "./index.js";
8
+ test("filesystem provider resolves absolute safe paths", async () => {
9
+ const provider = createFilesystemProvider(stubResolver);
10
+ const target = await provider.resolve("/projects/team/app", { cwd: "/x" });
11
+ assert.equal(target.targetKey, "/projects/team/app");
12
+ assert.equal(target.displayPath, "/projects/team/app");
13
+ assert.deepEqual(target.binding, { kind: "binding" });
14
+ });
15
+ test("filesystem provider resolves absolute paths without a cwd", async () => {
16
+ const seen = [];
17
+ const provider = createFilesystemProvider({
18
+ resolve: async () => ({ kind: "binding" }),
19
+ resolveForPath: async (path, cwd) => {
20
+ seen.push([path, cwd]);
21
+ return { kind: "binding" };
22
+ },
23
+ });
24
+ const target = await provider.resolve("/projects/team/app");
25
+ assert.equal(target.targetKey, "/projects/team/app");
26
+ assert.deepEqual(target.binding, { kind: "binding" });
27
+ assert.deepEqual(seen, [["/projects/team/app", undefined]]);
28
+ });
29
+ test("filesystem provider resolves relative paths against the cwd", async () => {
30
+ const provider = createFilesystemProvider(stubResolver);
31
+ const target = await provider.resolve("README.md", {
32
+ cwd: "/projects/team/app",
33
+ });
34
+ assert.equal(target.targetKey, "/projects/team/app/README.md");
35
+ assert.equal(target.displayPath, "/projects/team/app/README.md");
36
+ assert.deepEqual(target.binding, { kind: "binding" });
37
+ });
38
+ test("filesystem provider rejects unsafe paths", async () => {
39
+ const provider = createFilesystemProvider(stubResolver);
40
+ await assert.rejects(() => provider.resolve("/a/../b"));
41
+ await assert.rejects(() => provider.resolve("/a/b/../../etc"));
42
+ await assert.rejects(() => provider.resolve("../escape", { cwd: "/projects/team/app" }));
43
+ });
44
+ test("filesystem provider maps targets", () => {
45
+ const provider = createFilesystemProvider(stubResolver);
46
+ assert.equal(provider.fileUrl({ targetKey: "/a/b" }), "file:///a/b");
47
+ assert.equal(provider.processPath({ targetKey: "/a/b" }), "/a/b");
48
+ const parent = { targetKey: "/a" };
49
+ assert.equal(provider.contains(parent, { targetKey: "/a" }), true);
50
+ assert.equal(provider.contains(parent, { targetKey: "/a/b" }), true);
51
+ assert.equal(provider.contains(parent, { targetKey: "/a2" }), false);
52
+ assert.equal(provider.contains(parent, { targetKey: "/b" }), false);
53
+ });
54
+ test("filesystem provider lstat maps entry types without following links", async () => {
55
+ const requests = [];
56
+ const provider = createFilesystemProvider({
57
+ resolveForPath: async () => ({
58
+ guest: {
59
+ stat: (request, _metadata, callback) => {
60
+ requests.push(request);
61
+ callback(null, {
62
+ exists: true,
63
+ isDir: false,
64
+ isSymlink: true,
65
+ size: "3",
66
+ mode: "Lrwxrwxrwx",
67
+ modifiedAt: "t",
68
+ });
69
+ },
70
+ },
71
+ token: "t",
72
+ }),
73
+ });
74
+ const info = await provider.lstat("/projects/team/link", { cwd: "/projects/team" });
75
+ assert.equal(info.type, "symlink");
76
+ assert.equal(info.size, 3);
77
+ assert.deepEqual(requests, [{ path: "/projects/team/link", noFollow: true }]);
78
+ });
79
+ test("filesystem provider lstat reports an absent entry as undefined", async () => {
80
+ const provider = createFilesystemProvider({
81
+ resolveForPath: async () => ({
82
+ guest: {
83
+ stat: (_request, _metadata, callback) => callback(null, { exists: false }),
84
+ },
85
+ token: "t",
86
+ }),
87
+ });
88
+ assert.equal(await provider.lstat("/projects/team/missing", { cwd: "/x" }), undefined);
89
+ });
90
+ test("filesystem provider streamText decodes UTF-8 and rejects binary", async () => {
91
+ const text = providerFor(fakeGuest(Buffer.from("héllo")).guest);
92
+ const textTarget = await text.resolve("/a", { cwd: "/x" });
93
+ let decoded = "";
94
+ for await (const chunk of await text.streamText(textTarget))
95
+ decoded += chunk;
96
+ assert.equal(decoded, "héllo");
97
+ const binary = providerFor(fakeGuest(Buffer.from([0x68, 0x00, 0x69])).guest);
98
+ const binaryTarget = await binary.resolve("/a", { cwd: "/x" });
99
+ await assert.rejects(async () => {
100
+ for await (const _chunk of await binary.streamText(binaryTarget)) {
101
+ // Drain the stream so the binary sample is inspected.
102
+ }
103
+ }, (error) => error instanceof Error && error.code === "FS_NOT_TEXT");
104
+ });
105
+ test("filesystem provider readBytes returns content and caps at maxBytes", async () => {
106
+ const fake = fakeGuest(Buffer.from("hello"));
107
+ const provider = providerFor(fake.guest);
108
+ const target = await provider.resolve("/a", { cwd: "/x" });
109
+ const bytes = await provider.readBytes(target, undefined, 10);
110
+ assert.equal(Buffer.from(bytes).toString(), "hello");
111
+ assert.equal(fake.requests[0].length, 11);
112
+ await assert.rejects(() => provider.readBytes(target, undefined, 3), (error) => error instanceof Error && error.code === "FS_TOO_LARGE");
113
+ });
114
+ test("filesystem provider readByteRange returns the window and empty for zero length", async () => {
115
+ const fake = fakeGuest(Buffer.from("abcdef"));
116
+ const provider = providerFor(fake.guest);
117
+ const target = await provider.resolve("/a", { cwd: "/x" });
118
+ const empty = await provider.readByteRange(target, { offset: 0, length: 0 });
119
+ assert.equal(empty.length, 0);
120
+ assert.equal(fake.requests.length, 0);
121
+ const window = await provider.readByteRange(target, { offset: 1, length: 3 });
122
+ assert.equal(Buffer.from(window).toString(), "bcd");
123
+ assert.deepEqual(fake.requests[0], { path: "/a", offset: 1, length: 3 });
124
+ });
125
+ test("filesystem provider editText reports the harness error codes", async () => {
126
+ const binary = editProvider(editGuest({}, Buffer.from([0x68, 0x00, 0x69])));
127
+ const binaryTarget = await binary.resolve("/a", { cwd: "/x" });
128
+ await assert.rejects(() => binary.editText(binaryTarget, {
129
+ oldString: "h",
130
+ newString: "H",
131
+ replaceAll: false,
132
+ }), (error) => error.code === "FS_NOT_TEXT");
133
+ const notFound = editProvider(editGuest({}, Buffer.from("hello")));
134
+ const notFoundTarget = await notFound.resolve("/a", { cwd: "/x" });
135
+ await assert.rejects(() => notFound.editText(notFoundTarget, {
136
+ oldString: "zzz",
137
+ newString: "x",
138
+ replaceAll: false,
139
+ }), (error) => error.code === "FS_EDIT_NOT_FOUND");
140
+ const ambiguous = editProvider(editGuest({}, Buffer.from("aa")));
141
+ const ambiguousTarget = await ambiguous.resolve("/a", { cwd: "/x" });
142
+ await assert.rejects(() => ambiguous.editText(ambiguousTarget, {
143
+ oldString: "a",
144
+ newString: "b",
145
+ replaceAll: false,
146
+ }), (error) => error.code === "FS_AMBIGUOUS_EDIT");
147
+ const stale = editProvider(editGuest({}, Buffer.from("hello")));
148
+ const staleTarget = await stale.resolve("/a", { cwd: "/x" });
149
+ await assert.rejects(() => stale.editText(staleTarget, { oldString: "h", newString: "H", replaceAll: false }, { version: "other" }), (error) => error.code === "FS_STALE_VERSION");
150
+ });
151
+ test("filesystem provider writeText reports the harness error codes", async () => {
152
+ const existing = editProvider(editGuest({}, Buffer.from("hello")));
153
+ const target = await existing.resolve("/a", { cwd: "/x" });
154
+ await assert.rejects(() => existing.writeText(target, "x", { kind: "createIfAbsent" }), (error) => error.code === "FS_NOT_OBSERVED");
155
+ await assert.rejects(() => existing.writeText(target, "x", { kind: "replaceIfVersion", version: "other" }), (error) => error.code === "FS_STALE_VERSION");
156
+ const dir = editProvider(editGuest({ isDir: true }));
157
+ const dirTarget = await dir.resolve("/a", { cwd: "/x" });
158
+ await assert.rejects(() => dir.writeText(dirTarget, "x"), (error) => error.code === "FS_NOT_REGULAR_FILE");
159
+ });
160
+ test("filesystem provider writeText creates a file that was removed", async () => {
161
+ // A read-then-removed path is a new file: creating it destroys nothing, so
162
+ // the write must succeed instead of reporting a stale version.
163
+ const gone = editProvider(editGuest({ exists: false }));
164
+ const target = await gone.resolve("/a", { cwd: "/x" });
165
+ const outcome = await gone.writeText(target, "fresh", {
166
+ kind: "replaceIfVersion",
167
+ version: "agent:1:5:-rw-r--r--",
168
+ });
169
+ assert.equal(outcome.operation, "create");
170
+ assert.equal(outcome.before, null);
171
+ assert.equal(outcome.after, "fresh");
172
+ });
173
+ test("filesystem provider rejects a pre-aborted signal with FS_ABORTED", async () => {
174
+ const provider = editProvider(editGuest({}, Buffer.from("hello")));
175
+ const target = await provider.resolve("/a", { cwd: "/x" });
176
+ const controller = new AbortController();
177
+ controller.abort();
178
+ await assert.rejects(() => provider.readText(target, controller.signal), (error) => error.code === "FS_ABORTED");
179
+ });
180
+ test("filesystem provider editText normalizes line endings for matching", async () => {
181
+ const binding = editGuest({}, Buffer.from("a\r\nb\r\n"));
182
+ const provider = editProvider(binding);
183
+ const target = await provider.resolve("/a", { cwd: "/x" });
184
+ const outcome = await provider.editText(target, {
185
+ oldString: "b",
186
+ newString: "B",
187
+ replaceAll: false,
188
+ });
189
+ // The diff basis is LF-normalized, and the file keeps its CRLF style.
190
+ assert.equal(outcome.before, "a\nb\n");
191
+ assert.equal(outcome.after, "a\nB\n");
192
+ assert.equal(Buffer.concat(binding.writes).toString(), "a\r\nB\r\n");
193
+ });
194
+ test("filesystem provider maps absolute host paths into the execution world", () => {
195
+ const provider = createFilesystemProvider({});
196
+ assert.equal(provider.processPathFromHostPath("/home/u/project/f"), "/home/u/project/f");
197
+ assert.equal(provider.processPathFromHostPath("rel/f"), undefined);
198
+ });
199
+ test("filesystem provider listDir returns resolved child targets", async () => {
200
+ const requests = [];
201
+ const provider = createFilesystemProvider({
202
+ resolveForPath: async () => ({
203
+ guest: {
204
+ readDir: (request, _metadata, callback) => {
205
+ requests.push(request);
206
+ callback(null, {
207
+ entries: [
208
+ { name: "src", isDir: true, size: "0", type: "directory" },
209
+ { name: "a.txt", isDir: false, size: "3", type: "file" },
210
+ { name: "link", isDir: false, size: "1", type: "other" },
211
+ ],
212
+ });
213
+ },
214
+ },
215
+ token: "t",
216
+ }),
217
+ });
218
+ const target = await provider.resolve("/projects/team", { cwd: "/projects/team" });
219
+ const entries = await provider.listDir(target);
220
+ assert.deepEqual(requests, [{ path: "/projects/team" }]);
221
+ assert.deepEqual(entries.map((entry) => ({
222
+ name: entry.name,
223
+ type: entry.type,
224
+ path: entry.target.displayPath,
225
+ binding: entry.target.binding.token,
226
+ })), [
227
+ { name: "src", type: "directory", path: "/projects/team/src", binding: "t" },
228
+ { name: "a.txt", type: "file", path: "/projects/team/a.txt", binding: "t" },
229
+ { name: "link", type: "other", path: "/projects/team/link", binding: "t" },
230
+ ]);
231
+ assert.equal(entries[1].size, 3);
232
+ assert.equal(entries[0].size, undefined);
233
+ });
234
+ test("filesystem provider listDir falls back to the directory bit", async () => {
235
+ const provider = createFilesystemProvider({
236
+ resolveForPath: async () => ({
237
+ guest: {
238
+ readDir: (_request, _metadata, callback) => callback(null, {
239
+ entries: [
240
+ { name: "d", isDir: true, size: "0" },
241
+ { name: "f", isDir: false, size: "1" },
242
+ ],
243
+ }),
244
+ },
245
+ token: "t",
246
+ }),
247
+ });
248
+ const target = await provider.resolve("/w", { cwd: "/w" });
249
+ const entries = await provider.listDir(target);
250
+ assert.deepEqual(entries.map((entry) => entry.type), ["directory", "file"]);
251
+ });
252
+ test("filesystem provider writeText normalizes the diff basis", async () => {
253
+ const binding = editGuest({}, Buffer.from("a\r\nb\r\n"));
254
+ const provider = editProvider(binding);
255
+ const target = await provider.resolve("/a", { cwd: "/x" });
256
+ const outcome = await provider.writeText(target, "c\r\nd\r\n");
257
+ assert.equal(outcome.before, "a\nb\n");
258
+ assert.equal(outcome.after, "c\nd\n");
259
+ assert.equal(Buffer.concat(binding.writes).toString(), "c\r\nd\r\n");
260
+ });
@@ -0,0 +1,2 @@
1
+ export declare const VERSION = "0.2.0-rc.2";
2
+ export declare const GIT_COMMIT = "124ee2a";
@@ -0,0 +1,6 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ // Generated by scripts/generate-version.mjs; do not edit.
5
+ export const VERSION = "0.2.0-rc.2";
6
+ export const GIT_COMMIT = "124ee2a";
@@ -0,0 +1,133 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ syntax = "proto3";
6
+ package dshctl.v1;
7
+ option go_package = "github.com/Exagone313/dsh-podman/internal/genproto/dshctl/v1;dshctlv1";
8
+
9
+ service OrchestratorControl {
10
+ rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
11
+ rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace);
12
+ rpc DescribeWorkspace(DescribeWorkspaceRequest) returns (Workspace);
13
+ rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
14
+ rpc RemoveWorkspace(RemoveWorkspaceRequest) returns (RemoveWorkspaceResponse);
15
+ rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
16
+ rpc GetImage(GetImageRequest) returns (Image);
17
+ rpc BuildImage(BuildImageRequest) returns (Image);
18
+ rpc RebuildImage(RebuildImageRequest) returns (Image);
19
+ rpc RebuildAllImages(RebuildAllImagesRequest) returns (RebuildAllImagesResponse);
20
+ rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse);
21
+ rpc RebuildBaseImage(RebuildBaseImageRequest) returns (Image);
22
+ rpc PullBaseImage(PullBaseImageRequest) returns (Image);
23
+ rpc ListCaches(ListCachesRequest) returns (ListCachesResponse);
24
+ rpc CleanCaches(CleanCachesRequest) returns (CleanCachesResponse);
25
+ rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
26
+ rpc StartContainer(StartContainerRequest) returns (Container);
27
+ rpc RecreateContainer(RecreateContainerRequest) returns (Container);
28
+ rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse);
29
+ rpc AddContainerMount(AddContainerMountRequest) returns (Container);
30
+ rpc RemoveContainerMount(RemoveContainerMountRequest) returns (Container);
31
+ rpc UpdateContainerMount(UpdateContainerMountRequest) returns (Container);
32
+ rpc SetContainerPaths(SetContainerPathsRequest) returns (Container);
33
+ rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
34
+ rpc CreateVolume(CreateVolumeRequest) returns (Volume);
35
+ rpc RemoveVolume(RemoveVolumeRequest) returns (RemoveVolumeResponse);
36
+ rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse);
37
+ rpc CreateSecret(CreateSecretRequest) returns (Secret);
38
+ rpc WriteSecretValue(WriteSecretValueRequest) returns (Secret);
39
+ rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse);
40
+ rpc AddContainerSecret(AddContainerSecretRequest) returns (Container);
41
+ rpc RemoveContainerSecret(RemoveContainerSecretRequest) returns (Container);
42
+ }
43
+
44
+ message ListProjectsRequest {}
45
+ message ListProjectsResponse { repeated Project projects = 1; }
46
+ message Project { string name = 1; string host_path = 2; }
47
+ enum MountMode { MOUNT_MODE_UNSPECIFIED = 0; MOUNT_MODE_READ_ONLY = 1; MOUNT_MODE_READ_WRITE = 2; }
48
+ enum MountKind { MOUNT_KIND_UNSPECIFIED = 0; MOUNT_KIND_PROJECT = 1; MOUNT_KIND_TMPFS = 2; MOUNT_KIND_VOLUME = 3; MOUNT_KIND_SECRET = 4; }
49
+ message ProjectMount {
50
+ string project_name = 1;
51
+ MountMode mode = 2;
52
+ reserved 3;
53
+ string destination = 4;
54
+ MountKind kind = 5;
55
+ string volume = 6;
56
+ string secret = 7;
57
+ }
58
+ message CreateWorkspaceRequest { string workspace_slug = 1; string image_id = 2; repeated ProjectMount mounts = 3; map<string, string> env = 4; map<string, string> secret_env = 5; string project_name = 6; repeated string paths = 7; }
59
+ message DescribeWorkspaceRequest { string workspace_slug = 1; }
60
+ message RemoveWorkspaceRequest { string workspace_slug = 1; }
61
+ message RemoveWorkspaceResponse {}
62
+ message ListWorkspacesRequest {}
63
+ message ListWorkspacesResponse { repeated Workspace workspaces = 1; }
64
+ message Workspace {
65
+ string workspace_slug = 1;
66
+ string container_name = 2;
67
+ string image_id = 3;
68
+ repeated ProjectMount mounts = 4;
69
+ string status = 5;
70
+ string agent_socket_path = 6;
71
+ string agent_token = 7;
72
+ string created_at = 8;
73
+ map<string, string> env = 9;
74
+ map<string, string> secret_env = 10;
75
+ string project_name = 11;
76
+ }
77
+ message ListImagesRequest {}
78
+ message ListImagesResponse { repeated Image images = 1; }
79
+ message Image { string image_id = 1; string parent = 2; repeated string packages = 3; string image_tag = 4; string built_at = 5; bool is_base = 6; string status = 7; string primitive = 8; string package_manager = 9; bool base_public = 10; }
80
+ message RebuildImageRequest { string image_id = 1; }
81
+ message RebuildAllImagesRequest {}
82
+ message RebuildAllImagesResponse { repeated string rebuilt = 1; repeated string skipped = 2; }
83
+ message RemoveImageRequest { string image_id = 1; }
84
+ message RemoveImageResponse {}
85
+ message RebuildBaseImageRequest { string name = 1; }
86
+ message PullBaseImageRequest { string name = 1; }
87
+ message GetImageRequest { string image_id = 1; }
88
+ message BuildImageRequest { string image_id = 1; string parent = 2; repeated string packages = 3; }
89
+ message ListCachesRequest {}
90
+ message ListCachesResponse { repeated Cache caches = 1; }
91
+ message Cache { string manager = 1; string path = 2; int32 files = 3; int64 bytes = 4; }
92
+ enum CacheCleanMode { CACHE_CLEAN_MODE_UNSPECIFIED = 0; CACHE_CLEAN_MODE_KEEP_LATEST = 1; CACHE_CLEAN_MODE_ALL = 2; }
93
+ message CleanCachesRequest { CacheCleanMode mode = 1; }
94
+ message CleanCachesResponse { CacheCleanMode mode = 1; int32 removed_files = 2; int64 removed_bytes = 3; repeated Cache caches = 4; }
95
+ message ListContainersRequest {}
96
+ message ListContainersResponse { repeated Container containers = 1; }
97
+ message Container {
98
+ string container_name = 1;
99
+ string workspace_slug = 2;
100
+ string image_id = 3;
101
+ string status = 4;
102
+ string created_at = 5;
103
+ repeated ProjectMount mounts = 6;
104
+ string podman_name = 7;
105
+ string agent_socket_path = 8;
106
+ string agent_token = 9;
107
+ map<string, string> env = 10;
108
+ map<string, string> secret_env = 11;
109
+ repeated string paths = 12;
110
+ }
111
+ message StartContainerRequest { string workspace_slug = 1; string container = 2; string image_id = 3; repeated ProjectMount mounts = 4; map<string, string> env = 5; map<string, string> secret_env = 6; repeated string paths = 7; }
112
+ message RecreateContainerRequest { string workspace_slug = 1; string image_id = 2; string container = 3; repeated ProjectMount mounts = 4; map<string, string> env = 5; repeated string paths = 6; }
113
+ message RemoveContainerRequest { string workspace_slug = 1; string container = 2; }
114
+ message RemoveContainerResponse {}
115
+ message AddContainerMountRequest { string workspace_slug = 1; string container = 2; string project = 3; reserved 4; string destination = 5; MountMode mode = 6; MountKind kind = 7; string volume = 8; string secret = 9; }
116
+ message RemoveContainerMountRequest { string workspace_slug = 1; string container = 2; string project = 3; reserved 4; MountKind kind = 5; string volume = 6; string destination = 7; string secret = 8; }
117
+ message UpdateContainerMountRequest { string workspace_slug = 1; string container = 2; string project = 3; reserved 4; MountKind kind = 5; string volume = 6; string destination = 7; string secret = 8; MountMode mode = 9; }
118
+ message SetContainerPathsRequest { string workspace_slug = 1; string container = 2; repeated string paths = 3; }
119
+ message ListVolumesRequest {}
120
+ message ListVolumesResponse { repeated Volume volumes = 1; }
121
+ message Volume { string name = 1; }
122
+ message CreateVolumeRequest { string name = 1; }
123
+ message RemoveVolumeRequest { string name = 1; }
124
+ message RemoveVolumeResponse {}
125
+ message ListSecretsRequest {}
126
+ message ListSecretsResponse { repeated Secret secrets = 1; }
127
+ message Secret { string name = 1; }
128
+ message CreateSecretRequest { string name = 1; int32 length = 2; string charset = 3; }
129
+ message WriteSecretValueRequest { string name = 1; string value = 2; }
130
+ message RemoveSecretRequest { string name = 1; }
131
+ message RemoveSecretResponse {}
132
+ message AddContainerSecretRequest { string workspace_slug = 1; string container = 2; string env = 3; string secret = 4; }
133
+ message RemoveContainerSecretRequest { string workspace_slug = 1; string container = 2; string env = 3; }