@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
|
@@ -10,10 +10,22 @@ import { dirname, resolve } from "node:path";
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { mkdirSync } from "node:fs";
|
|
12
12
|
import { controlClient, guestClient } from "./grpc/runtime-client.js";
|
|
13
|
-
import {
|
|
13
|
+
import { containerNotFound, metadata, normalizeToolError, WorkspaceResolver, workspaceSlug, } from "./workspace-binding.js";
|
|
14
14
|
import { VERSION } from "./generated/version.js";
|
|
15
15
|
const SLUG = "2c573001-4171-4900-904b-12a5cc02737a";
|
|
16
16
|
const SLUG_SUB = "3d684112-5282-4a11-a15c-23b6dd13848b";
|
|
17
|
+
test("normalizeToolError strips the prefix only for a gRPC status", () => {
|
|
18
|
+
const statusError = Object.assign(new Error("5 NOT_FOUND: missing"), {
|
|
19
|
+
code: 5,
|
|
20
|
+
});
|
|
21
|
+
const normalized = normalizeToolError(statusError);
|
|
22
|
+
assert.equal(normalized.message, "missing");
|
|
23
|
+
assert.equal(normalized.code, "NOT_FOUND");
|
|
24
|
+
// A plain message that merely looks like a status keeps its text.
|
|
25
|
+
const plain = normalizeToolError(new Error("5 NOT_FOUND: missing"));
|
|
26
|
+
assert.equal(plain.message, "5 NOT_FOUND: missing");
|
|
27
|
+
assert.equal(plain.code, undefined);
|
|
28
|
+
});
|
|
17
29
|
test("workspaceSlug accepts a UUID workspace id", () => {
|
|
18
30
|
assert.equal(workspaceSlug(SLUG), SLUG);
|
|
19
31
|
assert.equal(workspaceSlug(SLUG.toUpperCase()), SLUG);
|
|
@@ -59,7 +71,9 @@ test("normalizeToolError strips the grpc prefix and names the code", () => {
|
|
|
59
71
|
const plain = normalizeToolError(new Error("boom"));
|
|
60
72
|
assert.equal(plain.message, "boom");
|
|
61
73
|
assert.equal(plain.code, undefined);
|
|
62
|
-
const named = Object.assign(new Error("bad input"), {
|
|
74
|
+
const named = Object.assign(new Error("bad input"), {
|
|
75
|
+
code: "INVALID_ARGUMENT",
|
|
76
|
+
});
|
|
63
77
|
assert.equal(normalizeToolError(named).code, "INVALID_ARGUMENT");
|
|
64
78
|
});
|
|
65
79
|
test("containerNotFound reports one stable shape", () => {
|
|
@@ -67,25 +81,6 @@ test("containerNotFound reports one stable shape", () => {
|
|
|
67
81
|
assert.equal(containerNotFound("db").code, "NOT_FOUND");
|
|
68
82
|
assert.equal(containerNotFound("db").message, 'container "db" not found');
|
|
69
83
|
});
|
|
70
|
-
test("containerRowFor finds rows by workspace and name", () => {
|
|
71
|
-
const containers = [
|
|
72
|
-
{ workspaceSlug: "team-app", containerName: "default", status: "running" },
|
|
73
|
-
{ workspaceSlug: "team-app", containerName: "db", status: "stopped" },
|
|
74
|
-
{ workspaceSlug: "other", containerName: "db", status: "running" },
|
|
75
|
-
];
|
|
76
|
-
assert.deepEqual(containerRowFor(containers, "team-app", "db"), {
|
|
77
|
-
workspaceSlug: "team-app",
|
|
78
|
-
containerName: "db",
|
|
79
|
-
status: "stopped",
|
|
80
|
-
});
|
|
81
|
-
assert.deepEqual(containerRowFor(containers, "team-app", "default"), {
|
|
82
|
-
workspaceSlug: "team-app",
|
|
83
|
-
containerName: "default",
|
|
84
|
-
status: "running",
|
|
85
|
-
});
|
|
86
|
-
assert.equal(containerRowFor(containers, "team-app", "missing"), undefined);
|
|
87
|
-
assert.equal(containerRowFor([], "team-app", "default"), undefined);
|
|
88
|
-
});
|
|
89
84
|
test("control and guest proto files resolve next to the runtime", () => {
|
|
90
85
|
const control = controlClient("/tmp/dsh-proto-control.sock");
|
|
91
86
|
const guest = guestClient("/tmp/dsh-proto-guest.sock");
|
|
@@ -100,7 +95,25 @@ async function startControlServer(containers = [], options = {}) {
|
|
|
100
95
|
defaults: true,
|
|
101
96
|
});
|
|
102
97
|
const loaded = grpc.loadPackageDefinition(definition);
|
|
98
|
+
const guestDefinition = loader.loadSync(resolve(dirname(fileURLToPath(import.meta.url)), "grpc/proto/dshguest/v1/guest.proto"), { longs: String, enums: String, defaults: true });
|
|
99
|
+
const guestLoaded = grpc.loadPackageDefinition(guestDefinition);
|
|
103
100
|
const server = new grpc.Server();
|
|
101
|
+
// With staleToken, the first container row carries a credential the agent
|
|
102
|
+
// rejects, so the caller must refresh before it can proceed.
|
|
103
|
+
let ensureCalls = 0;
|
|
104
|
+
server.addService(guestLoaded.dshguest.v1.WorkspaceGuestAgent.service, {
|
|
105
|
+
ping: (call, callback) => {
|
|
106
|
+
const bearer = String(call.metadata.get("authorization")[0] ?? "");
|
|
107
|
+
if (bearer === "bearer stale") {
|
|
108
|
+
callback({
|
|
109
|
+
code: grpc.status.UNAUTHENTICATED,
|
|
110
|
+
details: "invalid agent token",
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
callback(null, { version: "test", commit: "test" });
|
|
115
|
+
},
|
|
116
|
+
});
|
|
104
117
|
const received = [];
|
|
105
118
|
const versions = [];
|
|
106
119
|
const createRequests = [];
|
|
@@ -114,8 +127,28 @@ async function startControlServer(containers = [], options = {}) {
|
|
|
114
127
|
describeWorkspace: (_call, callback) => {
|
|
115
128
|
callback({ code: grpc.status.NOT_FOUND, details: "workspace not found" });
|
|
116
129
|
},
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
ensureContainer: (call, callback) => {
|
|
131
|
+
const agentToken = options.staleToken === true && ensureCalls++ === 0
|
|
132
|
+
? "stale"
|
|
133
|
+
: "tok";
|
|
134
|
+
const row = containers.find((candidate) => candidate.workspaceSlug === call.request.workspaceSlug &&
|
|
135
|
+
candidate.containerName === call.request.container);
|
|
136
|
+
if (row !== undefined) {
|
|
137
|
+
callback(null, { ...row, agentToken });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (call.request.container === "default") {
|
|
141
|
+
// The default container createWorkspace just made.
|
|
142
|
+
callback(null, {
|
|
143
|
+
workspaceSlug: call.request.workspaceSlug,
|
|
144
|
+
containerName: "default",
|
|
145
|
+
agentSocketPath: resolve(socketsRoot, "guest.sock"),
|
|
146
|
+
agentToken,
|
|
147
|
+
mounts: [],
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
callback({ code: grpc.status.NOT_FOUND, details: "container not found" });
|
|
119
152
|
},
|
|
120
153
|
createWorkspace: (call, callback) => {
|
|
121
154
|
createRequests.push(call.request);
|
|
@@ -129,6 +162,12 @@ async function startControlServer(containers = [], options = {}) {
|
|
|
129
162
|
recreateRequests.push(call.request);
|
|
130
163
|
callback(null, { workspaceSlug: call.request.workspaceSlug });
|
|
131
164
|
},
|
|
165
|
+
addContainerMount: (_call, callback) => {
|
|
166
|
+
callback(null, {});
|
|
167
|
+
},
|
|
168
|
+
removeWorkspace: (_call, callback) => {
|
|
169
|
+
callback(null, {});
|
|
170
|
+
},
|
|
132
171
|
});
|
|
133
172
|
const socketsRoot = resolve(tmpdir(), `dsh-control-test-${process.pid}-${Date.now()}-${Math.random()}`);
|
|
134
173
|
mkdirSync(socketsRoot, { recursive: true });
|
|
@@ -226,6 +265,60 @@ test("resolve exposes the session directory as the default cwd", async () => {
|
|
|
226
265
|
stop();
|
|
227
266
|
}
|
|
228
267
|
});
|
|
268
|
+
test("resolve refreshes a binding whose token the agent rejects", async () => {
|
|
269
|
+
// The first row carries a credential the guest agent rejects; resolving must
|
|
270
|
+
// re-run the orchestrator's ensure and end with the usable token.
|
|
271
|
+
const { socketsRoot, stop } = await startControlServer([], {
|
|
272
|
+
staleToken: true,
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
const resolver = new WorkspaceResolver({
|
|
276
|
+
socketsRoot,
|
|
277
|
+
defaultImage: "arch",
|
|
278
|
+
projectsRoot: "/projects",
|
|
279
|
+
controlToken: "",
|
|
280
|
+
}, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
|
|
281
|
+
const binding = await resolver.resolve("/projects/team");
|
|
282
|
+
assert.equal(binding.token, "tok");
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
stop();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
test("the auto-created workspace seeds the default environment", async () => {
|
|
289
|
+
const { socketsRoot, createRequests, stop } = await startControlServer();
|
|
290
|
+
try {
|
|
291
|
+
const resolver = new WorkspaceResolver({
|
|
292
|
+
socketsRoot,
|
|
293
|
+
defaultImage: "arch",
|
|
294
|
+
projectsRoot: "/projects",
|
|
295
|
+
controlToken: "",
|
|
296
|
+
containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
|
|
297
|
+
}, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
|
|
298
|
+
await resolver.resolve("/projects/team");
|
|
299
|
+
assert.equal(createRequests.length, 1);
|
|
300
|
+
assert.deepEqual(createRequests[0].env, { GIT_AUTHOR_NAME: "John Doe" });
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
stop();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
test("the auto-created workspace omits env without defaults", async () => {
|
|
307
|
+
const { socketsRoot, createRequests, stop } = await startControlServer();
|
|
308
|
+
try {
|
|
309
|
+
const resolver = new WorkspaceResolver({
|
|
310
|
+
socketsRoot,
|
|
311
|
+
defaultImage: "arch",
|
|
312
|
+
projectsRoot: "/projects",
|
|
313
|
+
controlToken: "",
|
|
314
|
+
}, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
|
|
315
|
+
await resolver.resolve("/projects/team");
|
|
316
|
+
assert.deepEqual(createRequests[0].env, {});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
stop();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
229
322
|
test("containerBinding exposes the session directory only when a project mount covers it", async () => {
|
|
230
323
|
const mounted = await startControlServer([{
|
|
231
324
|
workspaceSlug: SLUG,
|
|
@@ -252,7 +345,11 @@ test("containerBinding exposes the session directory only when a project mount c
|
|
|
252
345
|
containerName: "db",
|
|
253
346
|
agentSocketPath: "/run/x.sock",
|
|
254
347
|
agentToken: "tok",
|
|
255
|
-
mounts: [{
|
|
348
|
+
mounts: [{
|
|
349
|
+
kind: "MOUNT_KIND_VOLUME",
|
|
350
|
+
volume: "data",
|
|
351
|
+
destination: "/data",
|
|
352
|
+
}],
|
|
256
353
|
}]);
|
|
257
354
|
try {
|
|
258
355
|
const resolver = new WorkspaceResolver({
|
|
@@ -268,6 +365,72 @@ test("containerBinding exposes the session directory only when a project mount c
|
|
|
268
365
|
unmounted.stop();
|
|
269
366
|
}
|
|
270
367
|
});
|
|
368
|
+
test("containerBinding re-resolves the mount set after a container mutation", async () => {
|
|
369
|
+
const rows = [
|
|
370
|
+
{
|
|
371
|
+
workspaceSlug: SLUG,
|
|
372
|
+
containerName: "db",
|
|
373
|
+
agentSocketPath: "/run/x.sock",
|
|
374
|
+
agentToken: "tok",
|
|
375
|
+
mounts: [{ kind: "MOUNT_KIND_PROJECT", projectName: "team" }],
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
const control = await startControlServer(rows);
|
|
379
|
+
try {
|
|
380
|
+
const resolver = new WorkspaceResolver({
|
|
381
|
+
socketsRoot: control.socketsRoot,
|
|
382
|
+
defaultImage: "arch",
|
|
383
|
+
projectsRoot: "/projects",
|
|
384
|
+
controlToken: "",
|
|
385
|
+
}, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
|
|
386
|
+
const first = await resolver.containerBinding("/projects/team", "db");
|
|
387
|
+
assert.equal(first.defaultCwd, "/projects/team");
|
|
388
|
+
// The container no longer mounts the session directory, but the cached
|
|
389
|
+
// entry still describes the row it was built from.
|
|
390
|
+
rows[0].mounts = [
|
|
391
|
+
{ kind: "MOUNT_KIND_VOLUME", volume: "data", destination: "/data" },
|
|
392
|
+
];
|
|
393
|
+
const stale = await resolver.containerBinding("/projects/team", "db");
|
|
394
|
+
assert.equal(stale.defaultCwd, "/projects/team", "the cache is served until it is invalidated");
|
|
395
|
+
await resolver.control("addContainerMount", {
|
|
396
|
+
workspaceSlug: SLUG,
|
|
397
|
+
container: "db",
|
|
398
|
+
});
|
|
399
|
+
const fresh = await resolver.containerBinding("/projects/team", "db");
|
|
400
|
+
assert.equal(fresh.defaultCwd, undefined, "a mutation re-resolves the mount set");
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
control.stop();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
test("removing a workspace drops every cached container binding", async () => {
|
|
407
|
+
const rows = [
|
|
408
|
+
{
|
|
409
|
+
workspaceSlug: SLUG,
|
|
410
|
+
containerName: "db",
|
|
411
|
+
agentSocketPath: "/run/x.sock",
|
|
412
|
+
agentToken: "tok",
|
|
413
|
+
mounts: [{ kind: "MOUNT_KIND_PROJECT", projectName: "team" }],
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
const control = await startControlServer(rows);
|
|
417
|
+
try {
|
|
418
|
+
const resolver = new WorkspaceResolver({
|
|
419
|
+
socketsRoot: control.socketsRoot,
|
|
420
|
+
defaultImage: "arch",
|
|
421
|
+
projectsRoot: "/projects",
|
|
422
|
+
controlToken: "",
|
|
423
|
+
}, { resolveByPath: () => ({ id: SLUG, path: "/projects/team" }) });
|
|
424
|
+
await resolver.containerBinding("/projects/team", "db");
|
|
425
|
+
rows[0].mounts = [];
|
|
426
|
+
await resolver.control("removeWorkspace", { workspaceSlug: SLUG });
|
|
427
|
+
const fresh = await resolver.containerBinding("/projects/team", "db");
|
|
428
|
+
assert.equal(fresh.defaultCwd, undefined);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
control.stop();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
271
434
|
test("containerBinding recreates a container whose agent never answers", async () => {
|
|
272
435
|
const control = await startControlServer([{
|
|
273
436
|
workspaceSlug: SLUG,
|
|
@@ -331,7 +494,8 @@ test("resolveForPath rejects paths it cannot map to a workspace", async () => {
|
|
|
331
494
|
// A path outside every workspace is not in any container's filesystem: it is
|
|
332
495
|
// reported with the harness's missing-path code so callers treat it as absent
|
|
333
496
|
// (agent-instructions walks ancestors above the workspace this way).
|
|
334
|
-
await assert.rejects(() => resolver.resolveForPath("/elsewhere/file", undefined), (error) => error instanceof Error &&
|
|
497
|
+
await assert.rejects(() => resolver.resolveForPath("/elsewhere/file", undefined), (error) => error instanceof Error &&
|
|
498
|
+
error.code === "FS_NOT_FOUND");
|
|
335
499
|
});
|
|
336
500
|
test("resolve rejects a missing cwd without stringifying it", async () => {
|
|
337
501
|
const resolver = new WorkspaceResolver({
|
package/docs/architecture.md
CHANGED
|
@@ -31,12 +31,15 @@ call from a plugin whose **major** version differs, naming both versions and
|
|
|
31
31
|
which side is behind — an out-of-sync deployment fails loudly instead of being
|
|
32
32
|
misread. A missing or unparseable version is refused as well. A minor or patch
|
|
33
33
|
difference is compatible: the call proceeds, the orchestrator logs it once per
|
|
34
|
-
plugin version, and the
|
|
34
|
+
plugin version, and the Podman page shows it. `GetVersion` is exempt from the
|
|
35
35
|
check, so a plugin can always learn the orchestrator's version.
|
|
36
36
|
|
|
37
37
|
The orchestrator service exposes these gRPC methods (backing both the UI and the
|
|
38
38
|
tools): `ListContainers` (returns only the guest containers the orchestrator
|
|
39
39
|
created — containers it does not own are never exposed),
|
|
40
|
+
`EnsureContainer{workspace_slug, container}` (the single attach path: returns
|
|
41
|
+
the named container, recreating it first when it is missing, stopped, or running
|
|
42
|
+
an outdated guest agent),
|
|
40
43
|
`StartContainer{workspace_slug, container, image_id, mounts, env, secret_env,
|
|
41
44
|
paths}`
|
|
42
45
|
(creates or replaces a container in the workspace's pod; an empty `container`
|
|
@@ -45,8 +48,11 @@ targets the default container and other names are validated),
|
|
|
45
48
|
(stops, removes, and recreates a container, optionally with a new image, project
|
|
46
49
|
mounts, environment, or PATH additions; an empty `image_id` keeps the
|
|
47
50
|
workspace's current image), `RemoveContainer`, `AddContainerMount`,
|
|
48
|
-
`
|
|
49
|
-
`RemoveContainerSecret`.
|
|
51
|
+
`UpdateContainerMount`, `RemoveContainerMount`, `SetContainerPaths`,
|
|
52
|
+
`AddContainerSecret`, and `RemoveContainerSecret`. The service also exposes the
|
|
53
|
+
workspace, volume, secret, image (build, rebuild, remove and base-image pull),
|
|
54
|
+
and cache (list and clean) operations that back the Podman page, plus
|
|
55
|
+
`GetVersion`.
|
|
50
56
|
|
|
51
57
|
## Workspaces and pods
|
|
52
58
|
|
|
@@ -65,10 +71,10 @@ model reads the artifact back with the container file tools instead of a host
|
|
|
65
71
|
path the container cannot reach.
|
|
66
72
|
|
|
67
73
|
A workspace's pod is torn down when its last container is removed, or directly
|
|
68
|
-
through `RemoveWorkspace` (the
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
through `RemoveWorkspace` (the Podman page's **Remove pod** action), which stops
|
|
75
|
+
the containers' daemons, removes the pod and its containers, cleans their socket
|
|
76
|
+
directories, and drops the stored workspace. Volumes, secrets, and project data
|
|
77
|
+
are left untouched.
|
|
72
78
|
|
|
73
79
|
Beyond project mounts, a container can mount named volumes (prefixed
|
|
74
80
|
`DSH_PODMAN_VOLUME_PREFIX`, default `dsh-podman-`, and auto-created by podman on
|
|
@@ -111,6 +117,13 @@ and writes files. Each guest container gets exactly one socket directory
|
|
|
111
117
|
bind-mounted into it, so a guest never sees the orchestrator's control socket
|
|
112
118
|
nor any other workspace's socket directory.
|
|
113
119
|
|
|
120
|
+
The guest API exposes no watcher, so the plugin's `ctx.fs` provider reports
|
|
121
|
+
`FS_IO_ERROR` (`Filesystem watching is not supported by this provider.`) for
|
|
122
|
+
`watch`. The harness's workspace-file change stream therefore answers
|
|
123
|
+
`workspace-file/watch-unsupported`, and the browser's file tree refreshes on
|
|
124
|
+
demand instead of live — the same stance the harness's SSH filesystem provider
|
|
125
|
+
takes for a remote path.
|
|
126
|
+
|
|
114
127
|
Commands and daemons inherit the guest agent's environment, minus the reserved
|
|
115
128
|
`DSH_PODMAN` namespace: the agent's own token stays with the agent instead of
|
|
116
129
|
being copied into everything it starts. A daemon started with `inheritEnv=false`
|
|
@@ -155,20 +168,22 @@ Containerfile, so a build writes nothing to the orchestrator's state directory.
|
|
|
155
168
|
from a public registry per `DSH_PODMAN_BASE_IMAGE_PREFIX`), then rebuilds the
|
|
156
169
|
stored custom images **in dependency order** — each parent before the images
|
|
157
170
|
derived from it. An image whose rebuild fails, and every image that depends on
|
|
158
|
-
it, is reported in `skipped` while the rest continue.
|
|
171
|
+
it, is reported in `skipped` while the rest continue. A base image the call
|
|
172
|
+
could not build or pull is reported there too, by its short name; `rebuilt`
|
|
173
|
+
lists only the custom images, so it never names a base the call had to ensure.
|
|
159
174
|
|
|
160
175
|
When a host cache is configured (`DSH_PODMAN_HOST_*_CACHE`, mounted into both
|
|
161
176
|
the build container and the orchestrator), builds reuse downloaded packages. The
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
Podman page reports each cache's size and can clean it — keep the newest version
|
|
178
|
+
of every package, or empty the cache. The builder serializes a cleanup against
|
|
179
|
+
builds with a read/write lock, so a cleanup never deletes a package out from
|
|
180
|
+
under a running build.
|
|
166
181
|
|
|
167
|
-
##
|
|
182
|
+
## Podman page transport
|
|
168
183
|
|
|
169
|
-
The
|
|
170
|
-
API path (`/api/podman/card`), registered on the connection service so
|
|
171
|
-
carrier applies its Host/Origin fence and browser authentication first:
|
|
184
|
+
The Podman page talks to the host over one authenticated fetch route below the
|
|
185
|
+
harness API path (`/api/podman/card`), registered on the connection service so
|
|
186
|
+
the carrier applies its Host/Origin fence and browser authentication first:
|
|
172
187
|
|
|
173
188
|
- `GET` returns the live orchestrator snapshot (containers, images, workspaces,
|
|
174
189
|
volumes, secrets, caches), built fresh on every request.
|
|
@@ -176,8 +191,34 @@ carrier applies its Host/Origin fence and browser authentication first:
|
|
|
176
191
|
`image_rebuild` / `image_rebuild_all` / volume / secret / secret-env / mount
|
|
177
192
|
ops) against the orchestrator and returns the notice to show.
|
|
178
193
|
|
|
179
|
-
The
|
|
180
|
-
image, the
|
|
181
|
-
can render approval text in the session language — see
|
|
182
|
-
[Approval](usage.md#approval)).
|
|
183
|
-
|
|
194
|
+
The plugin's own config therefore holds **only real preferences**: the default
|
|
195
|
+
image, the default environment, and the page's active locale (`uiLocale`, so the
|
|
196
|
+
host can render approval text in the session language — see
|
|
197
|
+
[Approval](usage.md#approval)). They are volatile fields, so an edit applies
|
|
198
|
+
without reloading the plugin. Nothing derived from the orchestrator is
|
|
199
|
+
persisted, and no command round-trips through the config document.
|
|
200
|
+
|
|
201
|
+
## Podman terminal transport
|
|
202
|
+
|
|
203
|
+
The Podman terminal tab talks to guest ptys through its own authenticated routes
|
|
204
|
+
on the same connection service (`/api/podman/terminal`,
|
|
205
|
+
`/api/podman/terminal/shells`, `/api/podman/terminal/retained`). The open route
|
|
206
|
+
streams newline-delimited JSON frames (ready, snapshot, base64 output, title,
|
|
207
|
+
exit, error, detached) and takes control requests (input, resize, rename, close)
|
|
208
|
+
over a POST; the carrier applies the same Host/Origin fence and browser
|
|
209
|
+
authentication as the card route.
|
|
210
|
+
|
|
211
|
+
Each terminal is keyed by `(sessionId, tabId)` and retained by the host: the
|
|
212
|
+
guest pty stays alive while no browser is attached, and every chunk is also fed
|
|
213
|
+
to a headless terminal emulator whose serialized screen is replayed on reattach,
|
|
214
|
+
so a reload keeps the shell and its scrollback. Shell discovery runs inside the
|
|
215
|
+
target container: candidate names are resolved with POSIX `command -v` on the
|
|
216
|
+
container's PATH (including the deployment's PATH additions) and merged with
|
|
217
|
+
`/etc/shells` and `$SHELL`, so only shells the container really provides are
|
|
218
|
+
offered, most capable first — the order mirrors the harness's own candidate
|
|
219
|
+
preference and ends with the minimal POSIX shells, and the first entry is the
|
|
220
|
+
client's default. The tab's workspace is resolved by the host from the session's
|
|
221
|
+
own working directory — never chosen, shown or defaulted in the browser: a
|
|
222
|
+
session outside every workspace is reported, and the client only uses the
|
|
223
|
+
project name and workspace slug it is given. The selected path is re-verified
|
|
224
|
+
before the shell starts.
|
package/docs/architecture.zh.md
CHANGED
|
@@ -26,17 +26,21 @@ token(`DSH_PODMAN_ORCHESTRATOR_TOKEN`)作为 `authorization: bearer` 标头
|
|
|
26
26
|
请求还会在 `x-dsh-podman-plugin-version` 标头中携带插件版本。插件与 orchestrator
|
|
27
27
|
分别部署(前者是 dsh 镜像中的 npm 包,后者是此二进制文件),因此 orchestrator
|
|
28
28
|
会拒绝来自**主版本**不同的插件的控制调用,并指出两者的版本以及哪一方更旧——不同步的部署会明确失败,而不会被误读。缺失或无法解析的版本同样会被拒绝。次版本或修订版本不同是兼容的:调用照常进行,orchestrator
|
|
29
|
-
|
|
29
|
+
会按插件版本记录一次警告,Podman 页面也会显示。`GetVersion`
|
|
30
30
|
不受该检查约束,因此插件始终可以获知 orchestrator 的版本。
|
|
31
31
|
|
|
32
32
|
orchestrator 服务公开以下 gRPC 方法(同时支撑 UI
|
|
33
33
|
和工具):`ListContainers`(仅返回 orchestrator 创建的 guest
|
|
34
|
-
容器——它不拥有的容器永远不会被暴露)、`
|
|
34
|
+
容器——它不拥有的容器永远不会被暴露)、`EnsureContainer{workspace_slug, container}`(唯一的接入路径:返回指定容器,若其缺失、已停止或运行过期的
|
|
35
|
+
guest
|
|
36
|
+
agent,则先重建它)、`StartContainer{workspace_slug, container, image_id, mounts, env, secret_env, paths}`(在工作区的
|
|
35
37
|
pod 中创建或替换容器;空的 `container`
|
|
36
38
|
指向默认容器,其他名称会被校验)、`RecreateContainer{workspace_slug, container, image_id, mounts, env, paths}`(停止、删除并重新创建容器,可选择使用新镜像、项目挂载、环境或
|
|
37
39
|
PATH 附加项;空的 `image_id`
|
|
38
|
-
保留工作区当前的镜像)、`RemoveContainer`、`AddContainerMount`、`RemoveContainerMount`、`SetContainerPaths`、`AddContainerSecret`
|
|
39
|
-
和 `RemoveContainerSecret
|
|
40
|
+
保留工作区当前的镜像)、`RemoveContainer`、`AddContainerMount`、`UpdateContainerMount`、`RemoveContainerMount`、`SetContainerPaths`、`AddContainerSecret`
|
|
41
|
+
和 `RemoveContainerSecret`。该服务还公开支撑 Podman
|
|
42
|
+
页面的工作区、卷、机密、镜像(构建、重建、删除以及拉取基础镜像)和缓存(列出与清理)操作,以及
|
|
43
|
+
`GetVersion`。
|
|
40
44
|
|
|
41
45
|
## 工作区与 pod
|
|
42
46
|
|
|
@@ -47,10 +51,12 @@ UUID),因此其容器共享一个网络命名空间。每个工作区都有
|
|
|
47
51
|
和 `/run` 上是 podman 的可写 tmpfs(`/dev` 和 `/dev/shm`
|
|
48
52
|
保持可写)。所有其他可写状态都存在于项目绑定挂载、命名卷或 tmpfs 挂载中。guest
|
|
49
53
|
文件 API 可以访问 `/tmp`,而 guest agent 会把超出上限的命令输出写入
|
|
50
|
-
`/tmp/dsh-podman`
|
|
54
|
+
`/tmp/dsh-podman` 下的溢出文件。过大的工具结果也会由插件的 `ctx.spillStore`
|
|
55
|
+
溢出写入同一位置下的
|
|
56
|
+
`/tmp/dsh-podman/spill/<session>/`——因此模型会用容器文件工具读回该产物,而不是使用容器无法访问的主机路径。
|
|
51
57
|
|
|
52
58
|
工作区的 Pod 会在其最后一个容器被移除时拆除,也可直接通过
|
|
53
|
-
`RemoveWorkspace
|
|
59
|
+
`RemoveWorkspace`(Podman 页面中的 **移除 Pod**
|
|
54
60
|
操作)拆除:它会停止各容器的守护进程、移除 Pod 及其容器、清理它们的 socket
|
|
55
61
|
目录,并删除存储的工作区记录。卷、机密和项目数据保持不变。
|
|
56
62
|
|
|
@@ -66,6 +72,16 @@ UUID),因此其容器共享一个网络命名空间。每个工作区都有
|
|
|
66
72
|
|
|
67
73
|
包含保留路径的目标会被拒绝,位于其内部的目标同样会被拒绝,因为它会隐藏其下的所有内容。
|
|
68
74
|
|
|
75
|
+
通过 guest exec API 运行的命令,除非调用方明确要求管道,否则它们的 stdin 是
|
|
76
|
+
`/dev/null`,因此读取 stdin 的命令会看到 EOF 而不会阻塞(因此不带路径的裸
|
|
77
|
+
`rg`/`grep` 会搜索工作目录,而不是读取空管道)。`container_glob` 和
|
|
78
|
+
`container_grep` 还会把解析后的路径作为搜索目录传给 ripgrep,而绝不会作为
|
|
79
|
+
`--glob` 模式(包含 `/` 的路径永远无法匹配这种模式),并且 ripgrep 失败(退出码
|
|
80
|
+
2)会被报告为工具错误,而不是返回空结果。ripgrep 会把包含 `/` 的 `--glob`
|
|
81
|
+
模式锚定到进程工作目录,因此当 harness
|
|
82
|
+
启动的目录列举以绝对搜索根目录运行时,它会从该根目录运行:此时 `glob` 的
|
|
83
|
+
`pattern` 锚定到其 `path`,而打印出的路径保持绝对。
|
|
84
|
+
|
|
69
85
|
项目挂载会解析符号链接并被限制在 projects root
|
|
70
86
|
内,因此可写项目中的符号链接不能将绑定挂载重定向到其外部的路径。带有绝对目标的符号链接永远不会被跟随;请直接命名另一个项目。
|
|
71
87
|
|
|
@@ -77,6 +93,12 @@ API,运行并监督后台**守护进程**,并读取和写入文件。每个
|
|
|
77
93
|
容器恰好获得一个绑定挂载到其中的套接字目录,因此 guest 永远不会看到 orchestrator
|
|
78
94
|
的 control socket,也不会看到任何其他工作区的套接字目录。
|
|
79
95
|
|
|
96
|
+
Guest API 不提供监视器,因此插件的 `ctx.fs` 提供者对 `watch` 返回
|
|
97
|
+
`FS_IO_ERROR`(`Filesystem watching is not supported by this provider.`)。harness
|
|
98
|
+
的工作区文件变更流因此返回
|
|
99
|
+
`workspace-file/watch-unsupported`,浏览器的文件树改为按需刷新,而非实时刷新——与
|
|
100
|
+
harness 自带的 SSH 文件系统提供者对远程路径采取的做法一致。
|
|
101
|
+
|
|
80
102
|
命令和守护进程继承 guest agent 的环境,减去保留的 `DSH_PODMAN` 命名空间:agent
|
|
81
103
|
自身的 token 保留在 agent 中,而不会被复制到它所启动的每个东西中。以
|
|
82
104
|
`inheritEnv=false` 启动的守护进程则只接收 `PATH`/`HOME` 基线以及自身的 `env`。
|
|
@@ -113,14 +135,17 @@ API,因此构建不会向 orchestrator 的状态目录写入任何内容。
|
|
|
113
135
|
|
|
114
136
|
`image_rebuild_all` 首先确保每个基础镜像(根据 `DSH_PODMAN_BASE_IMAGE_PREFIX`
|
|
115
137
|
在本地构建或从公共注册表拉取),然后**按依赖顺序**重建存储的自定义镜像——每个父镜像都在从它派生的镜像之前。重建失败的镜像以及依赖它的每个镜像都会被报告在
|
|
116
|
-
`skipped`
|
|
138
|
+
`skipped`
|
|
139
|
+
中,其余镜像继续。本次调用无法构建或拉取的基础镜像也会以短名称报告在那里;`rebuilt`
|
|
140
|
+
只列出自定义镜像,因此不会包含本次调用所确保的基础镜像。
|
|
117
141
|
|
|
118
142
|
配置了主机缓存时(`DSH_PODMAN_HOST_*_CACHE`,同时挂载到构建容器和 orchestrator
|
|
119
|
-
|
|
143
|
+
中),构建会复用已下载的软件包。Podman
|
|
144
|
+
页面会报告每个缓存的大小并可清理它——保留每个软件包的最新版本,或清空缓存。构建器用读写锁将清理与构建串行化,因此清理绝不会在构建进行时删除其正在使用的软件包。
|
|
120
145
|
|
|
121
|
-
##
|
|
146
|
+
## Podman 页面传输
|
|
122
147
|
|
|
123
|
-
|
|
148
|
+
Podman 页面通过 harness API 路径(`/api/podman/card`)之下的一个已认证 fetch
|
|
124
149
|
路由与主机通信;该路由注册在 connection 服务上,因此承载层会先应用其 Host/Origin
|
|
125
150
|
校验和浏览器认证:
|
|
126
151
|
|
|
@@ -130,6 +155,25 @@ API,因此构建不会向 orchestrator 的状态目录写入任何内容。
|
|
|
130
155
|
`image_rebuild` / `image_rebuild_all` / volume / secret / secret-env / mount
|
|
131
156
|
操作),并返回要显示的提示。
|
|
132
157
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
因此插件自身的配置中**只保留真正的偏好**:默认镜像、默认环境变量,以及 Podman
|
|
159
|
+
页面的当前语言(`uiLocale`,以便主机以会话语言呈现审批文本——参见[审批](usage.zh.md#审批))。它们是
|
|
160
|
+
volatile 字段,修改后无需重新加载插件即可生效。orchestrator
|
|
161
|
+
派生的任何内容都不会被持久化,也不会有命令经由配置文档往返。
|
|
162
|
+
|
|
163
|
+
## Podman 终端传输
|
|
164
|
+
|
|
165
|
+
Podman 终端标签页通过自身在同一 connection
|
|
166
|
+
服务上的已认证路由(`/api/podman/terminal`、`/api/podman/terminal/shells`、`/api/podman/terminal/retained`)与
|
|
167
|
+
guest pty 通信。打开路由以换行分隔的 JSON 帧(ready、snapshot、base64
|
|
168
|
+
输出、title、exit、error、detached)进行流式传输,并通过 POST
|
|
169
|
+
接收控制请求(input、resize、rename、close);承载层会像卡片路由一样应用
|
|
170
|
+
Host/Origin 校验和浏览器认证。
|
|
171
|
+
|
|
172
|
+
每个终端以 `(sessionId, tabId)` 为键并由主机保留:没有浏览器接入时 guest pty
|
|
173
|
+
仍然存活,同时每个输出块都会写入一个无头终端模拟器,重新接入时回放其序列化屏幕,因此刷新页面不会丢失
|
|
174
|
+
shell 及其回滚缓冲。shell 探测在目标容器内进行:以 POSIX `command -v` 在该容器的
|
|
175
|
+
PATH(含部署的 PATH 追加项)中解析候选名称,并与 `/etc/shells` 和 `$SHELL`
|
|
176
|
+
合并,因此只提供容器确实拥有的 shell;顺序按能力从强到弱(与 harness
|
|
177
|
+
自身的候选顺序一致,最精简的 POSIX shell
|
|
178
|
+
排在最后),第一项即客户端的默认值。标签页的工作区由主机根据会话自身的工作目录解析,浏览器既不可选择、不会显示,也不会回退:会话不在任何工作区内时会直接提示,客户端只使用主机给出的项目名与工作区
|
|
179
|
+
slug。所选路径在启动前会再次校验。
|