@exagone313/dsh-podman 0.2.0-rc.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +12 -0
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +103 -35
  11. package/dist/card-route.test.js +60 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +76 -70
  15. package/dist/client/card-protocol.d.ts +3 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +13 -18
  18. package/dist/client/container-card-controller.js +86 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13109 -2117
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +88 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.js +2 -1
  76. package/dist/env-rows.d.ts +14 -0
  77. package/dist/env-rows.js +52 -0
  78. package/dist/env-rows.test.d.ts +1 -0
  79. package/dist/env-rows.test.js +43 -0
  80. package/dist/fs-provider.d.ts +1 -0
  81. package/dist/fs-provider.js +26 -12
  82. package/dist/fs-provider.test.js +51 -10
  83. package/dist/generated/version.d.ts +2 -2
  84. package/dist/generated/version.js +2 -2
  85. package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
  86. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  87. package/dist/grpc/runtime-client.d.ts +3 -1
  88. package/dist/grpc/runtime-client.js +90 -17
  89. package/dist/guest-rpc.d.ts +15 -19
  90. package/dist/guest-rpc.js +289 -70
  91. package/dist/guest-rpc.test.d.ts +1 -0
  92. package/dist/guest-rpc.test.js +241 -0
  93. package/dist/guest-terminal.d.ts +30 -0
  94. package/dist/guest-terminal.js +196 -0
  95. package/dist/images.test.js +10 -3
  96. package/dist/index.d.ts +11 -16
  97. package/dist/index.js +42 -36
  98. package/dist/locales.test.d.ts +1 -0
  99. package/dist/locales.test.js +34 -0
  100. package/dist/misc.test.js +38 -10
  101. package/dist/mount-enums.js +2 -1
  102. package/dist/mount-input.d.ts +2 -0
  103. package/dist/mount-input.js +38 -22
  104. package/dist/mounts.test.js +81 -11
  105. package/dist/naming.test.d.ts +1 -0
  106. package/dist/naming.test.js +28 -0
  107. package/dist/output-reader.js +36 -4
  108. package/dist/package-deps.test.d.ts +1 -0
  109. package/dist/package-deps.test.js +49 -0
  110. package/dist/paths.test.js +4 -2
  111. package/dist/plugin-meta.test.d.ts +1 -0
  112. package/dist/plugin-meta.test.js +29 -0
  113. package/dist/project-path.js +33 -7
  114. package/dist/project-path.test.js +14 -0
  115. package/dist/prompts.d.ts +0 -4
  116. package/dist/prompts.js +15 -84
  117. package/dist/read-only-shell.js +3 -1
  118. package/dist/read-only-shell.test.js +80 -25
  119. package/dist/runtime-client.test.d.ts +1 -0
  120. package/dist/runtime-client.test.js +53 -0
  121. package/dist/secrets.test.js +21 -6
  122. package/dist/settings-commands.test.js +61 -14
  123. package/dist/settings-create.test.js +32 -10
  124. package/dist/settings-default-env.test.d.ts +1 -0
  125. package/dist/settings-default-env.test.js +175 -0
  126. package/dist/settings-mounts.test.js +46 -7
  127. package/dist/settings-schema.d.ts +6 -11
  128. package/dist/settings-schema.js +4 -8
  129. package/dist/settings-schema.test.d.ts +1 -0
  130. package/dist/settings-schema.test.js +35 -0
  131. package/dist/settings-workspaces.test.js +36 -63
  132. package/dist/spill-store.d.ts +1 -0
  133. package/dist/spill-store.js +17 -4
  134. package/dist/spill-store.test.js +31 -8
  135. package/dist/subprocess.d.ts +4 -0
  136. package/dist/subprocess.js +148 -162
  137. package/dist/subprocess.test.js +118 -8
  138. package/dist/terminal-preference.test.d.ts +1 -0
  139. package/dist/terminal-preference.test.js +62 -0
  140. package/dist/terminal-route.d.ts +10 -0
  141. package/dist/terminal-route.js +270 -0
  142. package/dist/terminal-route.test.d.ts +1 -0
  143. package/dist/terminal-route.test.js +265 -0
  144. package/dist/terminal-sessions.d.ts +69 -0
  145. package/dist/terminal-sessions.js +252 -0
  146. package/dist/terminal-shells.d.ts +22 -0
  147. package/dist/terminal-shells.js +99 -0
  148. package/dist/terminal-tab.test.d.ts +1 -0
  149. package/dist/terminal-tab.test.js +58 -0
  150. package/dist/terminal-targets.test.d.ts +1 -0
  151. package/dist/terminal-targets.test.js +43 -0
  152. package/dist/terminal-titles.test.d.ts +1 -0
  153. package/dist/terminal-titles.test.js +36 -0
  154. package/dist/test-support.d.ts +35 -5
  155. package/dist/test-support.js +91 -29
  156. package/dist/tool-defs.js +19 -4
  157. package/dist/tool-handlers.js +80 -28
  158. package/dist/tool-params.js +19 -5
  159. package/dist/volumes.test.js +1 -1
  160. package/dist/workspace-binding.d.ts +18 -9
  161. package/dist/workspace-binding.js +244 -77
  162. package/dist/workspace-binding.test.js +189 -25
  163. package/docs/architecture.md +62 -21
  164. package/docs/architecture.zh.md +57 -13
  165. package/docs/configuration.md +61 -20
  166. package/docs/configuration.zh.md +53 -19
  167. package/docs/development-prompt.md +72 -0
  168. package/docs/development-prompt.zh.md +69 -0
  169. package/docs/development.md +141 -21
  170. package/docs/development.zh.md +125 -17
  171. package/docs/install-dsh-and-dsh-podman.md +21 -8
  172. package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
  173. package/docs/uninstall.md +83 -0
  174. package/docs/uninstall.zh.md +79 -0
  175. package/docs/update.md +120 -0
  176. package/docs/update.zh.md +114 -0
  177. package/docs/usage.md +108 -61
  178. package/docs/usage.zh.md +76 -38
  179. package/locale/en.json +6 -0
  180. package/locale/zh.json +6 -0
  181. package/package.json +59 -26
  182. package/quadlet/dsh-podman-orchestrator.container +46 -0
  183. package/quadlet/dsh.container +35 -0
  184. package/LICENSE.pkg +0 -17103
  185. package/dist/preferences.d.ts +0 -6
  186. package/dist/preferences.js +0 -27
@@ -0,0 +1,241 @@
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 { EventEmitter } from "node:events";
7
+ import { runExec, sessionWorkspaceSlug, sliceLines, streamLines, withGuestAuth, } from "./guest-rpc.js";
8
+ import { grpc } from "./grpc/runtime-client.js";
9
+ // pingOnce is a stand-in for a real guest call: it invokes the guest client and
10
+ // settles from its callback.
11
+ function pingOnce(guest, _token) {
12
+ return new Promise((resolve, reject) => {
13
+ guest.ping({}, undefined, (error, value) => error ? reject(error) : resolve(value));
14
+ });
15
+ }
16
+ function rejectingGuest() {
17
+ return {
18
+ guest: {
19
+ ping: (_request, _metadata, callback) => callback(Object.assign(new Error("invalid agent token"), {
20
+ code: grpc.status.UNAUTHENTICATED,
21
+ })),
22
+ },
23
+ token: "old",
24
+ };
25
+ }
26
+ test("withGuestAuth retries once with a refreshed binding", async () => {
27
+ const seen = [];
28
+ const fresh = {
29
+ guest: {
30
+ ping: (_request, _metadata, callback) => callback(null, { version: "v" }),
31
+ },
32
+ token: "new",
33
+ };
34
+ const binding = { ...rejectingGuest(), refresh: async () => fresh };
35
+ const result = await withGuestAuth(binding, (guest, token) => {
36
+ seen.push(token);
37
+ return pingOnce(guest, token);
38
+ });
39
+ assert.deepEqual(seen, ["old", "new"]);
40
+ assert.deepEqual(result, { version: "v" });
41
+ });
42
+ test("withGuestAuth surfaces a second rejection", async () => {
43
+ const rejecting = rejectingGuest();
44
+ const binding = { ...rejecting, refresh: async () => rejecting };
45
+ await assert.rejects(() => withGuestAuth(binding, pingOnce), /invalid agent token/);
46
+ });
47
+ test("withGuestAuth does not refresh on a non-auth error", async () => {
48
+ let refreshed = false;
49
+ const binding = {
50
+ guest: {
51
+ ping: (_request, _metadata, callback) => callback(Object.assign(new Error("boom"), { code: grpc.status.INTERNAL })),
52
+ },
53
+ token: "t",
54
+ refresh: async () => {
55
+ refreshed = true;
56
+ return binding;
57
+ },
58
+ };
59
+ await assert.rejects(() => withGuestAuth(binding, pingOnce), /boom/);
60
+ assert.equal(refreshed, false);
61
+ });
62
+ test("withGuestAuth surfaces the error when the binding cannot refresh", async () => {
63
+ const binding = rejectingGuest();
64
+ await assert.rejects(() => withGuestAuth(binding, pingOnce), /invalid agent token/);
65
+ });
66
+ // FakeExecStream is the guest Exec bidi stream a real channel would give us.
67
+ class FakeExecStream extends EventEmitter {
68
+ written = [];
69
+ ended = false;
70
+ cancelled = false;
71
+ write(message) {
72
+ this.written.push(message);
73
+ return true;
74
+ }
75
+ end() {
76
+ this.ended = true;
77
+ }
78
+ cancel() {
79
+ this.cancelled = true;
80
+ }
81
+ }
82
+ // execGuest returns a guest double driving one FakeExecStream. `signal` and
83
+ // `delete` are the calls runExec makes for cancellation and spill cleanup.
84
+ function execGuest(stream) {
85
+ const signals = [];
86
+ return {
87
+ stream,
88
+ signals,
89
+ guest: {
90
+ exec: () => stream,
91
+ signal: (request, _metadata, callback) => {
92
+ signals.push(request.signal);
93
+ callback(null, {});
94
+ },
95
+ delete: (_request, _metadata, callback) => callback(null, {}),
96
+ },
97
+ };
98
+ }
99
+ const EXIT_OK = {
100
+ exitCode: 0,
101
+ signaled: false,
102
+ stdoutSpillValid: true,
103
+ stderrSpillValid: true,
104
+ };
105
+ test("runExec resolves on exit and asks the guest for a spill copy", async () => {
106
+ const harness = execGuest(new FakeExecStream());
107
+ const binding = { guest: harness.guest, token: "t" };
108
+ const promise = runExec(binding, ["echo", "hi"]);
109
+ harness.stream.emit("data", { processId: "1" });
110
+ harness.stream.emit("data", { stdoutChunk: Buffer.from("hi\n") });
111
+ harness.stream.emit("data", { exit: EXIT_OK });
112
+ const result = await promise;
113
+ assert.equal(result.exitCode, 0);
114
+ assert.equal(result.stdout, "hi\n");
115
+ const start = harness.stream.written[0].start;
116
+ assert.match(start.spillStdout.path, /^\/tmp\/dsh-podman\/[0-9a-f-]+\.stdout$/);
117
+ assert.equal(start.spillStderr.path.endsWith(".stderr"), true);
118
+ // The output fit in memory, so the spill file is deleted again.
119
+ assert.equal(result.stdoutSpillPath, undefined);
120
+ });
121
+ test("runExec rejects when the stream ends without an exit message", async () => {
122
+ const harness = execGuest(new FakeExecStream());
123
+ const binding = { guest: harness.guest, token: "t" };
124
+ const promise = runExec(binding, ["hang"]);
125
+ harness.stream.emit("end");
126
+ await assert.rejects(promise, /ended before the process exited/);
127
+ });
128
+ test("runExec aborts with the turn signal, then escalates to SIGKILL", async () => {
129
+ const harness = execGuest(new FakeExecStream());
130
+ const binding = { guest: harness.guest, token: "t" };
131
+ const controller = new AbortController();
132
+ const promise = runExec(binding, ["sleep", "99"], undefined, undefined, undefined, undefined, {
133
+ signal: controller.signal,
134
+ killGraceMs: 60,
135
+ });
136
+ harness.stream.emit("data", { processId: "7" });
137
+ controller.abort();
138
+ await assert.rejects(promise, /aborted/);
139
+ await new Promise((resolve) => setImmediate(resolve));
140
+ assert.deepEqual(harness.signals, ["SIGTERM"]);
141
+ // The stream stays open for the grace period, so a command that ignores
142
+ // SIGTERM can still be killed by its process group.
143
+ assert.equal(harness.stream.cancelled, false);
144
+ await new Promise((resolve) => setTimeout(resolve, 100));
145
+ assert.deepEqual(harness.signals, ["SIGTERM", "SIGKILL"]);
146
+ assert.equal(harness.stream.cancelled, true);
147
+ });
148
+ test("runExec leaves a command that exits within the grace period alone", async () => {
149
+ const harness = execGuest(new FakeExecStream());
150
+ const binding = { guest: harness.guest, token: "t" };
151
+ const controller = new AbortController();
152
+ const promise = runExec(binding, ["sleep", "99"], undefined, undefined, undefined, undefined, {
153
+ signal: controller.signal,
154
+ killGraceMs: 30,
155
+ });
156
+ harness.stream.emit("data", { processId: "7" });
157
+ controller.abort();
158
+ await assert.rejects(promise, /aborted/);
159
+ harness.stream.emit("data", { exit: EXIT_OK });
160
+ await new Promise((resolve) => setTimeout(resolve, 50));
161
+ assert.deepEqual(harness.signals, ["SIGTERM"]);
162
+ assert.equal(harness.stream.cancelled, false);
163
+ });
164
+ test("runExec cancels an abort that arrives before the process id", async () => {
165
+ const harness = execGuest(new FakeExecStream());
166
+ const binding = { guest: harness.guest, token: "t" };
167
+ const controller = new AbortController();
168
+ const promise = runExec(binding, ["sleep", "99"], undefined, undefined, undefined, undefined, {
169
+ signal: controller.signal,
170
+ });
171
+ controller.abort();
172
+ await assert.rejects(promise, /aborted/);
173
+ assert.equal(harness.stream.cancelled, true);
174
+ assert.deepEqual(harness.signals, []);
175
+ });
176
+ test("runExec rejects a pre-aborted call without starting it", async () => {
177
+ const harness = execGuest(new FakeExecStream());
178
+ const binding = { guest: harness.guest, token: "t" };
179
+ const controller = new AbortController();
180
+ controller.abort();
181
+ await assert.rejects(() => runExec(binding, ["x"], undefined, undefined, undefined, undefined, {
182
+ signal: controller.signal,
183
+ }), /aborted/);
184
+ assert.equal(harness.stream.written.length, 0);
185
+ });
186
+ test("runExec keeps a bounded tail and points at the spill on truncation", async () => {
187
+ const harness = execGuest(new FakeExecStream());
188
+ const binding = { guest: harness.guest, token: "t" };
189
+ const promise = runExec(binding, ["big"], undefined, undefined, undefined, undefined, {
190
+ maxBytes: 4,
191
+ spillBytes: 1024,
192
+ });
193
+ harness.stream.emit("data", { stdoutChunk: Buffer.from("0123456789") });
194
+ harness.stream.emit("data", { exit: EXIT_OK });
195
+ const result = await promise;
196
+ assert.equal(result.stdout, "6789", "only the bounded tail is retained");
197
+ assert.match(result.stdoutSpillPath ?? "", /^\/tmp\/dsh-podman\/[0-9a-f-]+\.stdout$/);
198
+ });
199
+ test("sessionWorkspaceSlug fails instead of fabricating a default workspace", async () => {
200
+ const resolver = { registry: { resolveByPath: async () => undefined } };
201
+ await assert.rejects(() => sessionWorkspaceSlug(resolver, "/projects/gone"), /cannot resolve a DH workspace/);
202
+ });
203
+ // chunks yields one character at a time, the worst case for a streaming line
204
+ // reader because every boundary can fall inside a line.
205
+ async function* characters(content) {
206
+ for (const character of content)
207
+ yield character;
208
+ }
209
+ test("streamLines matches sliceLines for every window shape", async () => {
210
+ const cases = [
211
+ ["", 1, 5],
212
+ ["a", 1, 5],
213
+ ["a\n", 1, 5],
214
+ ["a\nb\nc", 1, 2],
215
+ ["a\nb\nc\n", 1, 2],
216
+ ["a\nb\nc\n", 2, 2],
217
+ ["a\nb\nc\n", 2, 10],
218
+ ["a\nb", 2, 2],
219
+ ["a\nb", 5, 2],
220
+ ["x\ny\nz\n", 3, 10],
221
+ ["1\n2\n3\n4\n5", 2, 3],
222
+ ["\n\n\n", 2, 2],
223
+ ];
224
+ for (const [content, offset, limit] of cases) {
225
+ const expected = sliceLines(content, offset, limit);
226
+ const streamed = await streamLines(characters(content), offset, limit);
227
+ assert.equal(streamed, expected, `content=${JSON.stringify(content)} offset=${offset} limit=${limit}`);
228
+ }
229
+ });
230
+ test("streamLines stops consuming once the window is filled", async () => {
231
+ let consumed = 0;
232
+ async function* source() {
233
+ for (let index = 1; index <= 100; index++) {
234
+ consumed++;
235
+ yield `${index}\n`;
236
+ }
237
+ }
238
+ const out = await streamLines(source(), 1, 2);
239
+ assert.equal(out, "1\n2");
240
+ assert.equal(consumed, 2);
241
+ });
@@ -0,0 +1,30 @@
1
+ import { type WorkspaceBinding } from "./workspace-binding.js";
2
+ export interface GuestTerminalOptions {
3
+ argv: readonly string[];
4
+ cwd: string;
5
+ env?: Readonly<Record<string, string>>;
6
+ cols: number;
7
+ rows: number;
8
+ terminalType?: string;
9
+ }
10
+ export interface GuestTerminalExit {
11
+ exitCode: number | null;
12
+ signal: string | null;
13
+ }
14
+ export interface GuestTerminal {
15
+ readonly pid: number;
16
+ readonly started: Promise<number>;
17
+ readonly done: Promise<GuestTerminalExit>;
18
+ exited(): boolean;
19
+ activityRevision(): number;
20
+ onOutput(listener: (chunk: Buffer) => void): () => void;
21
+ write(data: Buffer | string): void;
22
+ resize(cols: number, rows: number): void;
23
+ inspectForeground(): Promise<{
24
+ processGroupId: number;
25
+ inputWaiting: boolean;
26
+ } | undefined>;
27
+ signalForeground(signal: string): Promise<number>;
28
+ terminate(): Promise<void>;
29
+ }
30
+ export declare function openGuestTerminal(binding: WorkspaceBinding, options: GuestTerminalOptions): GuestTerminal;
@@ -0,0 +1,196 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ // One interactive guest-terminal stream. The harness's subprocess provider and
5
+ // the browser terminal routes both build on this: the guest owns the pty and
6
+ // reports started/stdout/exit plus foreground queries, so the terminal protocol
7
+ // is spoken in exactly one place.
8
+ import { remoteArgv } from "./guest-rpc.js";
9
+ import { metadata } from "./workspace-binding.js";
10
+ import { randomUUID } from "node:crypto";
11
+ export function openGuestTerminal(binding, options) {
12
+ const call = binding.guest.terminal(metadata(binding.token));
13
+ const listeners = new Set();
14
+ const pending = new Map();
15
+ let pid = -1;
16
+ let exited = false;
17
+ let terminated = false;
18
+ let activity = 0;
19
+ let resolveStarted;
20
+ let rejectStarted;
21
+ const started = new Promise((resolveStartedPromise, rejectStartedPromise) => {
22
+ resolveStarted = resolveStartedPromise;
23
+ rejectStarted = rejectStartedPromise;
24
+ });
25
+ // A caller that never awaits startup must not crash on an unhandled rejection.
26
+ started.catch(() => { });
27
+ let resolveExit;
28
+ let rejectExit;
29
+ const done = new Promise((resolveExitPromise, rejectExitPromise) => {
30
+ resolveExit = resolveExitPromise;
31
+ rejectExit = rejectExitPromise;
32
+ });
33
+ done.catch(() => { });
34
+ const failPending = (error) => {
35
+ for (const waiter of pending.values())
36
+ waiter.reject(error);
37
+ pending.clear();
38
+ };
39
+ // Settle the stream exactly once, however it ends: an exit message, a stream
40
+ // error, or a stream that ends without one. Every path also releases pending
41
+ // foreground queries so a caller cannot hang on them.
42
+ const settle = (error, outcome) => {
43
+ if (exited)
44
+ return;
45
+ exited = true;
46
+ if (error !== undefined) {
47
+ rejectStarted(error);
48
+ rejectExit(error);
49
+ failPending(error);
50
+ return;
51
+ }
52
+ if (outcome !== undefined)
53
+ resolveExit(outcome);
54
+ failPending(new Error("terminal has exited"));
55
+ };
56
+ call.on("data", (message) => {
57
+ if (message.started) {
58
+ pid = Number(message.started.pid);
59
+ resolveStarted(pid);
60
+ return;
61
+ }
62
+ if (message.stdoutChunk) {
63
+ activity++;
64
+ const chunk = Buffer.from(message.stdoutChunk);
65
+ for (const listener of listeners)
66
+ listener(chunk);
67
+ return;
68
+ }
69
+ if (message.exit) {
70
+ settle(undefined, {
71
+ exitCode: message.exit.exitCode,
72
+ signal: message.exit.signaled ? message.exit.signal : null,
73
+ });
74
+ return;
75
+ }
76
+ if (message.foreground) {
77
+ activity++;
78
+ const waiter = pending.get(message.foreground.requestId);
79
+ if (waiter !== undefined) {
80
+ pending.delete(message.foreground.requestId);
81
+ waiter.resolve(message.foreground);
82
+ }
83
+ return;
84
+ }
85
+ if (message.signalled) {
86
+ const waiter = pending.get(message.signalled.requestId);
87
+ if (waiter !== undefined) {
88
+ pending.delete(message.signalled.requestId);
89
+ waiter.resolve(message.signalled);
90
+ }
91
+ }
92
+ });
93
+ call.on("error", (error) => {
94
+ settle(error instanceof Error ? error : new Error(String(error)));
95
+ });
96
+ call.on("end", () => {
97
+ settle(new Error("terminal stream ended before the process exited"));
98
+ });
99
+ const env = { ...(options.env ?? {}) };
100
+ if (options.terminalType !== undefined &&
101
+ options.terminalType !== "" &&
102
+ env.TERM === undefined) {
103
+ env.TERM = options.terminalType;
104
+ }
105
+ call.write({
106
+ start: {
107
+ argv: remoteArgv(options.argv),
108
+ cwd: options.cwd,
109
+ env,
110
+ rows: options.rows,
111
+ cols: options.cols,
112
+ },
113
+ });
114
+ const request = (message) => {
115
+ const requestId = randomUUID();
116
+ return new Promise((resolveWaiter, rejectWaiter) => {
117
+ pending.set(requestId, { resolve: resolveWaiter, reject: rejectWaiter });
118
+ try {
119
+ call.write(message(requestId));
120
+ }
121
+ catch (error) {
122
+ pending.delete(requestId);
123
+ rejectWaiter(error);
124
+ }
125
+ });
126
+ };
127
+ return {
128
+ get pid() {
129
+ return pid;
130
+ },
131
+ started,
132
+ done,
133
+ exited: () => exited,
134
+ activityRevision: () => activity,
135
+ onOutput: (listener) => {
136
+ listeners.add(listener);
137
+ return () => {
138
+ listeners.delete(listener);
139
+ };
140
+ },
141
+ write: (data) => {
142
+ if (exited)
143
+ throw new Error("terminal has exited");
144
+ activity++;
145
+ call.write({ stdinChunk: Buffer.from(data) });
146
+ },
147
+ resize: (cols, rows) => {
148
+ if (exited)
149
+ throw new Error("terminal has exited");
150
+ activity++;
151
+ call.write({ resize: { rows, cols } });
152
+ },
153
+ inspectForeground: async () => {
154
+ if (exited)
155
+ return undefined;
156
+ const response = await request((requestId) => ({
157
+ inspectRequestId: requestId,
158
+ }));
159
+ return response.found
160
+ ? {
161
+ processGroupId: response.processGroupId,
162
+ inputWaiting: response.inputWaiting,
163
+ }
164
+ : undefined;
165
+ },
166
+ signalForeground: async (signal) => {
167
+ if (exited)
168
+ throw new Error("terminal has exited");
169
+ const response = await request((requestId) => ({
170
+ signal: { signal, requestId },
171
+ }));
172
+ if (!response.found) {
173
+ throw new Error(`no foreground process group to signal ${signal}`);
174
+ }
175
+ return response.processGroupId;
176
+ },
177
+ terminate: async () => {
178
+ if (terminated)
179
+ return;
180
+ terminated = true;
181
+ try {
182
+ call.write({ close: true });
183
+ call.end();
184
+ }
185
+ catch {
186
+ // A finished call cannot be closed again; nothing to do.
187
+ }
188
+ try {
189
+ await done;
190
+ }
191
+ catch {
192
+ // Termination is idempotent and never rejects.
193
+ }
194
+ },
195
+ };
196
+ }
@@ -4,7 +4,7 @@
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { EXPECTED_TOOLS } from "./test-support.js";
7
- import { TOOLS, imageRemoveParameters, toolHandlers } from "./index.js";
7
+ import { imageRemoveParameters, toolHandlers, TOOLS } from "./index.js";
8
8
  test("tool set covers the image and container surface", () => {
9
9
  assert.deepEqual(TOOLS.map((tool) => tool.name).sort(), [...EXPECTED_TOOLS].sort());
10
10
  });
@@ -12,7 +12,11 @@ test("image_build requires imageId, parent and packages", () => {
12
12
  const tool = TOOLS.find((entry) => entry.name === "image_build");
13
13
  assert.ok(tool, "image_build registered");
14
14
  assert.equal(tool.approval, true, "image_build must require approval");
15
- assert.deepEqual(tool.parameters.required, ["imageId", "parent", "packages"]);
15
+ assert.deepEqual(tool.parameters.required, [
16
+ "imageId",
17
+ "parent",
18
+ "packages",
19
+ ]);
16
20
  assert.equal(tool.parameters.properties.imageId.description, "Image short name.");
17
21
  assert.ok(tool.parameters.properties.parent.description.includes("Short name of the parent image"), "parent must be documented as a short name");
18
22
  });
@@ -72,7 +76,10 @@ test("image_list returns image objects", async () => {
72
76
  });
73
77
  test("image_rebuild_all returns rebuilt and skipped arrays", async () => {
74
78
  const resolver = {
75
- control: async () => ({ rebuilt: ["archlinux", "dev"], skipped: ["broken"] }),
79
+ control: async () => ({
80
+ rebuilt: ["archlinux", "dev"],
81
+ skipped: ["broken"],
82
+ }),
76
83
  };
77
84
  const out = await toolHandlers.image_rebuild_all(resolver, {}, {});
78
85
  assert.deepEqual(out, { rebuilt: ["archlinux", "dev"], skipped: ["broken"] });
package/dist/index.d.ts CHANGED
@@ -1,22 +1,17 @@
1
+ import { type Config } from "./settings-schema.js";
1
2
  export declare const name = "podman";
2
3
  export declare const inject: string[];
3
- export interface PluginConfig {
4
- socketsRoot?: string;
5
- defaultImage?: string;
6
- projectsRoot?: string;
7
- controlToken?: string;
8
- imagePrefix?: string;
9
- }
10
- export declare function apply(ctx: any, config?: PluginConfig): void;
11
- export { PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, approvalDecision, isSandboxEscalation, mountDestinationsReason, preExecutePolicy, summarizeArgs, } from "./approval.js";
4
+ export declare function apply(ctx: any, config: Config): void;
5
+ export { approvalDecision, isSandboxEscalation, mountDestinationsReason, PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, preExecutePolicy, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, summarizeArgs, } from "./approval.js";
12
6
  export type { SessionFacts } from "./approval.js";
13
- export { READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, createReadOnlyShellGate, } from "./read-only-shell.js";
14
- export { FilesystemProvider, createFilesystemProvider } from "./fs-provider.js";
15
- export { resolveGuestCwd, resolveGuestPath, remoteArgv, globCwd } from "./guest-rpc.js";
7
+ export { createReadOnlyShellGate, READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, } from "./read-only-shell.js";
8
+ export { createFilesystemProvider, FilesystemProvider } from "./fs-provider.js";
9
+ export { globCwd, remoteArgv, resolveGuestCwd, resolveGuestPath, } from "./guest-rpc.js";
16
10
  export { inferMountKind, projectMountDestinationReason, projectMountMirror, } from "./mount-input.js";
17
- export { HARNESS_SOURCE_SECTION, PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, ensurePodmanOpsPreset, homePresetsRoot, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
18
- export { publicContainer, publicDaemon, publicImage, publicMount } from "./public.js";
19
- export { OutputReader, SubprocessProvider, createSubprocessProvider, outputReader, } from "./subprocess.js";
11
+ export { HARNESS_SOURCE_SECTION, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
12
+ export { Config } from "./settings-schema.js";
13
+ export { publicContainer, publicDaemon, publicImage, publicMount, } from "./public.js";
14
+ export { createSubprocessProvider, OutputReader, outputReader, SubprocessProvider, } from "./subprocess.js";
20
15
  export { toolHandlers } from "./tool-handlers.js";
21
- export { TOOLS, ToolDefinition, containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, toolOutput, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
16
+ export { containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, ToolDefinition, toolOutput, TOOLS, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
22
17
  export { toolCallView, toolResultView } from "./tool-views.js";
package/dist/index.js CHANGED
@@ -1,32 +1,27 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { isSandboxEscalation, preExecutePolicy } from "./approval.js";
4
+ import { isSandboxEscalation, preExecutePolicy, } from "./approval.js";
5
5
  import { resolveReasonLocale } from "./approval-reasons.js";
6
6
  import { createFilesystemProvider } from "./fs-provider.js";
7
7
  import { createSpillStore } from "./spill-store.js";
8
- import { ensurePodmanOpsPreset, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
8
+ import { podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
9
9
  import { createSubprocessProvider } from "./subprocess.js";
10
- import { createReadOnlyShellGate, REMOUNT_TOOL_NAME } from "./read-only-shell.js";
10
+ import { createReadOnlyShellGate, REMOUNT_TOOL_NAME, } from "./read-only-shell.js";
11
11
  import { toolHandlers } from "./tool-handlers.js";
12
- import { TOOLS, TOOL_DESCRIPTIONS, defineTool, toolOutput } from "./tool-schemas.js";
12
+ import { defineTool, TOOL_DESCRIPTIONS, toolOutput, TOOLS, } from "./tool-schemas.js";
13
13
  import { toolCallView, toolResultView } from "./tool-views.js";
14
- import { installContainerPreferences } from "./preferences.js";
15
14
  import { registerCardRoute } from "./card-route.js";
16
- import { WorkspaceResolver, normalizeToolError } from "./workspace-binding.js";
17
- function withTrailingSlash(value) {
18
- return value.endsWith("/") ? value : `${value}/`;
19
- }
15
+ import { registerTerminalRoutes } from "./terminal-route.js";
16
+ import { TerminalSessions } from "./terminal-sessions.js";
17
+ import { normalizeToolError, WorkspaceResolver } from "./workspace-binding.js";
20
18
  export const name = "podman";
21
19
  export const inject = ["tools", "workspaceRegistry"];
22
- export function apply(ctx, config = {}) {
20
+ export function apply(ctx, config) {
23
21
  // Approval text follows the session language: the browser client records its
24
- // active locale in the plugin namespace, with the durable user preference as
25
- // the fallback. Without a settings provider everything renders in English.
26
- let readLocale = () => "en";
27
- ctx.inject(["settings"], (settingsCtx) => {
28
- readLocale = () => resolveReasonLocale(settingsCtx.settings);
29
- });
22
+ // active locale in the plugin's own volatile `uiLocale` preference, so the
23
+ // value is always read live and an unset one renders in English.
24
+ const readLocale = () => resolveReasonLocale(config.uiLocale.get());
30
25
  // The session's permission knobs live in the harness's own services: the
31
26
  // sandbox policy resolves the effective mode (approved override, last logged
32
27
  // mode, then the deployment default), the approval service reports the logged
@@ -76,19 +71,20 @@ export function apply(ctx, config = {}) {
76
71
  // listener is prepended so it runs before the bridge that forwards approvals
77
72
  // to the browser, and it delegates everything else.
78
73
  ctx.on("approval/request", (request, next) => isSandboxEscalation(request) ? Promise.resolve("allowed-once") : next(), { prepend: true });
79
- ensurePodmanOpsPreset(ctx);
80
- const imagePrefix = withTrailingSlash(config.imagePrefix ?? process.env.DSH_PODMAN_IMAGE_PREFIX ?? "localhost/dsh-podman/");
81
- const resolver = new WorkspaceResolver({
82
- socketsRoot: config.socketsRoot ??
83
- process.env.DSH_PODMAN_SOCKETS_ROOT ??
84
- "/run/dsh-podman",
85
- defaultImage: config.defaultImage ?? "archlinux",
86
- projectsRoot: config.projectsRoot ??
87
- process.env.DSH_PODMAN_PROJECTS_ROOT ??
88
- "/projects",
89
- controlToken: config.controlToken ?? process.env.DSH_PODMAN_ORCHESTRATOR_TOKEN ?? "",
90
- }, ctx.workspaceRegistry);
74
+ const resolver = new WorkspaceResolver(
75
+ // A reader, not a snapshot: every preference is a volatile config field an
76
+ // edit commits in place, so the resolver observes changes without a reload.
77
+ () => ({
78
+ // Both roots are env-only, so the plugin and the orchestrator always
79
+ // resolve the same paths.
80
+ socketsRoot: process.env.DSH_PODMAN_SOCKETS_ROOT ?? "/run/dsh-podman",
81
+ defaultImage: config.defaultImage.get() || "archlinux",
82
+ projectsRoot: process.env.DSH_PODMAN_PROJECTS_ROOT ?? "/projects",
83
+ controlToken: process.env.DSH_PODMAN_ORCHESTRATOR_TOKEN ?? "",
84
+ containerEnv: config.containerEnv.get(),
85
+ }), ctx.workspaceRegistry);
91
86
  ctx.provide("workspaceResolver", resolver);
87
+ ctx.effect(() => () => resolver.dispose(), "podman: resolver cleanup");
92
88
  const subprocess = createSubprocessProvider(resolver);
93
89
  ctx.provide("subprocess", subprocess);
94
90
  ctx.effect(() => () => subprocess.dispose(), "podman: subprocess cleanup");
@@ -102,8 +98,17 @@ export function apply(ctx, config = {}) {
102
98
  promptCtx.on("system-prompt/assemble", async (_assembly, _context, next) => withoutHarnessSourceSection(await next()), { global: true, prepend: true });
103
99
  });
104
100
  registerTools(ctx, resolver);
105
- installContainerPreferences(ctx, resolver);
106
- registerCardRoute(ctx, resolver, ctx.workspaceRegistry);
101
+ // The plugin ships its own configuration card, so the host must not
102
+ // auto-generate a settings page for the preferences namespace.
103
+ ctx.inject(["settings"], (child) => {
104
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), "podman: settings presentation");
105
+ });
106
+ registerCardRoute(ctx, resolver, ctx.workspaceRegistry, readLocale);
107
+ // The Podman terminal tab talks to guest ptys through its own routes; the
108
+ // registry keeps shells alive while the browser is away.
109
+ const terminalSessions = new TerminalSessions({ resolver });
110
+ ctx.effect(() => () => terminalSessions.dispose(), "podman: terminal sessions cleanup");
111
+ registerTerminalRoutes(ctx, resolver, terminalSessions, ctx.workspaceRegistry);
107
112
  }
108
113
  function registerTools(ctx, resolver) {
109
114
  for (const tool of TOOLS) {
@@ -127,14 +132,15 @@ function registerTools(ctx, resolver) {
127
132
  }));
128
133
  }
129
134
  }
130
- export { PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, approvalDecision, isSandboxEscalation, mountDestinationsReason, preExecutePolicy, summarizeArgs, } from "./approval.js";
131
- export { READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, createReadOnlyShellGate, } from "./read-only-shell.js";
135
+ export { approvalDecision, isSandboxEscalation, mountDestinationsReason, PODMAN_OPS_APPROVAL_TOOLS, PODMAN_OPS_PRESET, preExecutePolicy, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, summarizeArgs, } from "./approval.js";
136
+ export { createReadOnlyShellGate, READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, } from "./read-only-shell.js";
132
137
  export { createFilesystemProvider } from "./fs-provider.js";
133
- export { resolveGuestCwd, resolveGuestPath, remoteArgv, globCwd } from "./guest-rpc.js";
138
+ export { globCwd, remoteArgv, resolveGuestCwd, resolveGuestPath, } from "./guest-rpc.js";
134
139
  export { inferMountKind, projectMountDestinationReason, projectMountMirror, } from "./mount-input.js";
135
- export { HARNESS_SOURCE_SECTION, PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, ensurePodmanOpsPreset, homePresetsRoot, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
136
- export { publicContainer, publicDaemon, publicImage, publicMount } from "./public.js";
140
+ export { HARNESS_SOURCE_SECTION, podmanRuntimeSection, withoutHarnessSourceSection, } from "./prompts.js";
141
+ export { Config } from "./settings-schema.js";
142
+ export { publicContainer, publicDaemon, publicImage, publicMount, } from "./public.js";
137
143
  export { createSubprocessProvider, outputReader, } from "./subprocess.js";
138
144
  export { toolHandlers } from "./tool-handlers.js";
139
- export { TOOLS, containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, toolOutput, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
145
+ export { containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, defineTool, envParam, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, toolOutput, TOOLS, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-schemas.js";
140
146
  export { toolCallView, toolResultView } from "./tool-views.js";
@@ -0,0 +1 @@
1
+ export {};