@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
|
@@ -1,32 +1,75 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
|
-
import {
|
|
4
|
+
import { closeClients, controlClient, grpc, guestClient, unary, } from "./grpc/runtime-client.js";
|
|
5
|
+
import { mergeDefaultEnv } from "./container-env.js";
|
|
5
6
|
import { isAbsolute, join } from "node:path";
|
|
6
7
|
import { VERSION } from "./generated/version.js";
|
|
8
|
+
// The control calls that change a container's mount set or recreate it. A
|
|
9
|
+
// successful call invalidates the cached binding for that container (or for
|
|
10
|
+
// every container of a removed workspace), because the cached mounts decide
|
|
11
|
+
// whether the session directory is exposed as the container's default cwd.
|
|
12
|
+
const CONTAINER_MUTATIONS = {
|
|
13
|
+
startContainer: "container",
|
|
14
|
+
recreateContainer: "container",
|
|
15
|
+
removeContainer: "container",
|
|
16
|
+
addContainerMount: "container",
|
|
17
|
+
removeContainerMount: "container",
|
|
18
|
+
updateContainerMount: "container",
|
|
19
|
+
addContainerSecret: "container",
|
|
20
|
+
removeContainerSecret: "container",
|
|
21
|
+
removeWorkspace: "workspace",
|
|
22
|
+
};
|
|
7
23
|
export class WorkspaceResolver {
|
|
8
24
|
config;
|
|
9
25
|
registry;
|
|
10
26
|
bindings = new Map();
|
|
11
|
-
|
|
27
|
+
// Named containers get their own cache, keyed by "<slug>:<logical name>", so
|
|
28
|
+
// resolving one does not re-run ensureContainer, rebuild a gRPC channel, and
|
|
29
|
+
// wait for readiness on every tool call.
|
|
30
|
+
containerBindings = new Map();
|
|
31
|
+
constructor(
|
|
32
|
+
// A reader rather than a snapshot: the plugin's preferences are volatile
|
|
33
|
+
// config fields, so every call must observe their current values.
|
|
34
|
+
config, registry) {
|
|
12
35
|
this.config = config;
|
|
13
36
|
this.registry = registry;
|
|
14
37
|
}
|
|
15
|
-
setConfig(patch) {
|
|
16
|
-
Object.assign(this.config, patch);
|
|
17
|
-
}
|
|
18
38
|
getConfig() {
|
|
19
|
-
return this.config;
|
|
39
|
+
return typeof this.config === "function" ? this.config() : this.config;
|
|
40
|
+
}
|
|
41
|
+
// dispose closes every cached gRPC channel and drops the cached bindings.
|
|
42
|
+
// Called when the plugin is disposed so a reload does not leak unix sockets.
|
|
43
|
+
dispose() {
|
|
44
|
+
this.bindings.clear();
|
|
45
|
+
this.containerBindings.clear();
|
|
46
|
+
closeClients();
|
|
47
|
+
}
|
|
48
|
+
// forgetContainer drops the cached binding for one named container, so the
|
|
49
|
+
// next call re-resolves its row and picks up a changed mount set.
|
|
50
|
+
forgetContainer(slug, container) {
|
|
51
|
+
this.containerBindings.delete(`${slug}:${container}`);
|
|
52
|
+
}
|
|
53
|
+
// forgetWorkspace drops every cached container binding of one workspace, so
|
|
54
|
+
// a removed (or recreated) workspace cannot serve stale rows.
|
|
55
|
+
forgetWorkspace(slug) {
|
|
56
|
+
const prefix = `${slug}:`;
|
|
57
|
+
for (const key of [...this.containerBindings.keys()]) {
|
|
58
|
+
if (key.startsWith(prefix))
|
|
59
|
+
this.containerBindings.delete(key);
|
|
60
|
+
}
|
|
20
61
|
}
|
|
21
|
-
async resolve(cwd) {
|
|
62
|
+
async resolve(cwd, signal) {
|
|
22
63
|
const workspace = await this.workspaceForCwd(cwd);
|
|
23
|
-
const relativePath = String(workspace.path).replace(`${this.config.projectsRoot}/`, "");
|
|
24
64
|
const key = workspaceSlug(workspace.id);
|
|
25
|
-
const
|
|
65
|
+
const projectName = projectNameForPath(this.getConfig().projectsRoot, workspace.path);
|
|
66
|
+
const binding = await this.ready(key, projectName, signal);
|
|
26
67
|
// The default container always keeps its project mount, so the session
|
|
27
68
|
// directory is always mounted in it.
|
|
28
69
|
const session = defaultCwdOf(cwd);
|
|
29
|
-
return session === undefined
|
|
70
|
+
return session === undefined
|
|
71
|
+
? binding
|
|
72
|
+
: { ...binding, defaultCwd: session };
|
|
30
73
|
}
|
|
31
74
|
// Resolve the workspace binding for a filesystem path when the caller did not
|
|
32
75
|
// supply a session working directory. The 0.1.5 `fs.resolve` contract allows
|
|
@@ -34,13 +77,14 @@ export class WorkspaceResolver {
|
|
|
34
77
|
// sensible owner, so match it by longest canonical-path prefix. A path
|
|
35
78
|
// outside every workspace is not in any container's filesystem, so it is
|
|
36
79
|
// reported as missing rather than failing the caller.
|
|
37
|
-
async resolveForPath(path, cwd) {
|
|
80
|
+
async resolveForPath(path, cwd, signal) {
|
|
38
81
|
if (typeof cwd === "string" && cwd !== "")
|
|
39
|
-
return this.resolve(cwd);
|
|
82
|
+
return this.resolve(cwd, signal);
|
|
40
83
|
if (isAbsolute(path)) {
|
|
41
84
|
const workspace = this.containingWorkspace(path);
|
|
42
|
-
if (workspace !== undefined)
|
|
43
|
-
return this.resolve(String(workspace.path));
|
|
85
|
+
if (workspace !== undefined) {
|
|
86
|
+
return this.resolve(String(workspace.path), signal);
|
|
87
|
+
}
|
|
44
88
|
throw notFoundError(`no DH workspace contains path ${JSON.stringify(path)}`);
|
|
45
89
|
}
|
|
46
90
|
throw new Error(`cannot resolve a DH workspace for path ${JSON.stringify(path)}; pass a session working directory`);
|
|
@@ -70,89 +114,141 @@ export class WorkspaceResolver {
|
|
|
70
114
|
}
|
|
71
115
|
return workspace;
|
|
72
116
|
}
|
|
73
|
-
resolveSlug(key) {
|
|
74
|
-
return this.ready(key, key);
|
|
117
|
+
resolveSlug(key, signal) {
|
|
118
|
+
return this.ready(key, key, signal);
|
|
75
119
|
}
|
|
76
|
-
async containerBinding(cwd, container) {
|
|
120
|
+
async containerBinding(cwd, container, signal) {
|
|
77
121
|
const workspace = await this.workspaceForCwd(cwd);
|
|
78
122
|
const slug = workspaceSlug(workspace.id);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
123
|
+
const key = `${slug}:${container}`;
|
|
124
|
+
let entry = await this.resolveContainerBinding(slug, container, key, signal);
|
|
125
|
+
try {
|
|
126
|
+
entry = {
|
|
127
|
+
binding: await this.ensureAgentReady(entry.binding),
|
|
128
|
+
mounts: entry.mounts,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
this.containerBindings.delete(key);
|
|
133
|
+
await this.recoverContainer(slug, container, signal);
|
|
134
|
+
entry = await this.resolveContainerBinding(slug, container, key, signal);
|
|
135
|
+
entry = {
|
|
136
|
+
binding: await this.ensureAgentReady(entry.binding),
|
|
137
|
+
mounts: entry.mounts,
|
|
138
|
+
};
|
|
83
139
|
}
|
|
84
|
-
const socket = row.agentSocketPath;
|
|
85
|
-
const binding = {
|
|
86
|
-
guest: guestClient(socket),
|
|
87
|
-
token: row.agentToken,
|
|
88
|
-
socket,
|
|
89
|
-
};
|
|
90
140
|
const session = defaultCwdOf(cwd);
|
|
91
141
|
if (session !== undefined &&
|
|
92
|
-
projectMountCovers(this.
|
|
93
|
-
binding
|
|
142
|
+
projectMountCovers(this.getConfig().projectsRoot, entry.mounts, session)) {
|
|
143
|
+
return { ...entry.binding, defaultCwd: session };
|
|
94
144
|
}
|
|
95
|
-
|
|
96
|
-
|
|
145
|
+
return entry.binding;
|
|
146
|
+
}
|
|
147
|
+
// resolveContainerBinding returns the cached promise for a named container,
|
|
148
|
+
// creating it once. It deliberately stops at an "ensureContainer + build a
|
|
149
|
+
// binding" step: readiness is per-call, so a credential refresh can never
|
|
150
|
+
// recurse through this path.
|
|
151
|
+
resolveContainerBinding(slug, container, key, signal) {
|
|
152
|
+
let entry = this.containerBindings.get(key);
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
entry = this.buildContainerBinding(slug, container, key).catch((error) => {
|
|
155
|
+
if (this.containerBindings.get(key) === entry) {
|
|
156
|
+
this.containerBindings.delete(key);
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
});
|
|
160
|
+
this.containerBindings.set(key, entry);
|
|
161
|
+
}
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
async buildContainerBinding(slug, container, key) {
|
|
165
|
+
const row = await this.ensureContainer(slug, container);
|
|
166
|
+
// refresh re-resolves the container row and swaps the cache entry, so a
|
|
167
|
+
// binding whose token rotated is not re-served from the stale cache.
|
|
168
|
+
const refresh = () => {
|
|
169
|
+
const next = this.buildContainerBinding(slug, container, key);
|
|
170
|
+
this.containerBindings.set(key, next);
|
|
171
|
+
return next.then((fresh) => fresh.binding);
|
|
172
|
+
};
|
|
173
|
+
return { binding: this.bindingFor(row, refresh), mounts: row.mounts ?? [] };
|
|
97
174
|
}
|
|
98
175
|
// ready resolves a workspace's default container and waits for its guest
|
|
99
176
|
// agent, recreating the container once when it is reported running but its
|
|
100
177
|
// agent never answers (a crashed agent or a lost socket).
|
|
101
|
-
async ready(key, projectName) {
|
|
102
|
-
let binding = await this.resolveBinding(key, projectName);
|
|
178
|
+
async ready(key, projectName, signal) {
|
|
179
|
+
let binding = await this.resolveBinding(key, projectName, signal);
|
|
103
180
|
try {
|
|
104
|
-
await
|
|
181
|
+
return await this.ensureAgentReady(binding);
|
|
105
182
|
}
|
|
106
183
|
catch {
|
|
107
184
|
this.bindings.delete(key);
|
|
108
|
-
binding = await this.resolveBinding(key, projectName);
|
|
185
|
+
binding = await this.resolveBinding(key, projectName, signal);
|
|
109
186
|
try {
|
|
110
|
-
await
|
|
187
|
+
return await this.ensureAgentReady(binding);
|
|
111
188
|
}
|
|
112
189
|
catch {
|
|
113
|
-
await this.recoverContainer(key, "default");
|
|
190
|
+
await this.recoverContainer(key, "default", signal);
|
|
114
191
|
this.bindings.delete(key);
|
|
115
|
-
binding = await this.resolveBinding(key, projectName);
|
|
116
|
-
await
|
|
192
|
+
binding = await this.resolveBinding(key, projectName, signal);
|
|
193
|
+
return await this.ensureAgentReady(binding);
|
|
117
194
|
}
|
|
118
195
|
}
|
|
119
|
-
return binding;
|
|
120
196
|
}
|
|
121
197
|
readyTimeoutMs() {
|
|
122
|
-
const configured = Number(this.
|
|
198
|
+
const configured = Number(this.getConfig().readyTimeoutMs);
|
|
123
199
|
return Number.isFinite(configured) && configured > 0 ? configured : 15000;
|
|
124
200
|
}
|
|
125
|
-
//
|
|
126
|
-
// once
|
|
127
|
-
|
|
201
|
+
// bindingFor builds a binding from an orchestrator container row. refresh
|
|
202
|
+
// re-resolves it, so a caller can retry once after the agent rejects a token
|
|
203
|
+
// that went stale (the plugin caches bindings for its lifetime).
|
|
204
|
+
bindingFor(row, refresh) {
|
|
205
|
+
const socket = row.agentSocketPath;
|
|
206
|
+
return {
|
|
207
|
+
guest: guestClient(socket),
|
|
208
|
+
token: row.agentToken,
|
|
209
|
+
socket,
|
|
210
|
+
refresh,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// ensureAgentReady waits for the agent and verifies the credential with a
|
|
214
|
+
// Ping. A rejected credential is refreshed once (re-resolving the binding,
|
|
215
|
+
// which re-runs the orchestrator's ensure) and re-verified, so a token that
|
|
216
|
+
// rotated after the binding was cached does not fail the caller.
|
|
217
|
+
async ensureAgentReady(binding) {
|
|
218
|
+
await waitForReady(binding.guest, this.readyTimeoutMs());
|
|
128
219
|
try {
|
|
129
|
-
await
|
|
130
|
-
return;
|
|
220
|
+
await pingAgent(binding);
|
|
221
|
+
return binding;
|
|
131
222
|
}
|
|
132
|
-
catch {
|
|
133
|
-
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (error?.code !== grpc.status.UNAUTHENTICATED ||
|
|
225
|
+
binding.refresh === undefined) {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
const refreshed = await binding.refresh();
|
|
229
|
+
await waitForReady(refreshed.guest, this.readyTimeoutMs());
|
|
230
|
+
await pingAgent(refreshed);
|
|
231
|
+
return refreshed;
|
|
134
232
|
}
|
|
135
|
-
await this.recoverContainer(slug, container);
|
|
136
|
-
await waitForReady(binding.guest, this.readyTimeoutMs());
|
|
137
233
|
}
|
|
138
234
|
// recoverContainer best-effort recreates a container whose agent is
|
|
139
235
|
// unreachable. A failure here is not fatal: the caller's retry surfaces the
|
|
140
236
|
// real readiness failure.
|
|
141
|
-
async recoverContainer(slug, container) {
|
|
237
|
+
async recoverContainer(slug, container, signal) {
|
|
142
238
|
try {
|
|
143
239
|
await this.control("recreateContainer", {
|
|
144
240
|
workspaceSlug: slug,
|
|
145
241
|
container,
|
|
146
|
-
});
|
|
242
|
+
}, signal);
|
|
147
243
|
}
|
|
148
244
|
catch {
|
|
149
245
|
// Best-effort.
|
|
150
246
|
}
|
|
151
247
|
}
|
|
152
|
-
resolveBinding(key, projectName) {
|
|
248
|
+
resolveBinding(key, projectName, signal) {
|
|
153
249
|
let binding = this.bindings.get(key);
|
|
154
250
|
if (binding === undefined) {
|
|
155
|
-
binding = this.create(key, projectName).catch((error) => {
|
|
251
|
+
binding = this.create(key, projectName, signal).catch((error) => {
|
|
156
252
|
if (this.bindings.get(key) === binding)
|
|
157
253
|
this.bindings.delete(key);
|
|
158
254
|
throw error;
|
|
@@ -161,34 +257,79 @@ export class WorkspaceResolver {
|
|
|
161
257
|
}
|
|
162
258
|
return binding;
|
|
163
259
|
}
|
|
164
|
-
async create(slug, projectName) {
|
|
165
|
-
const control = controlClient(join(this.
|
|
166
|
-
const controlMetadata = metadata(this.
|
|
167
|
-
let workspace;
|
|
260
|
+
async create(slug, projectName, signal) {
|
|
261
|
+
const control = controlClient(join(this.getConfig().socketsRoot, "orchestrator.sock"));
|
|
262
|
+
const controlMetadata = metadata(this.getConfig().controlToken, VERSION);
|
|
168
263
|
try {
|
|
169
|
-
|
|
264
|
+
await unary(control, "describeWorkspace", {
|
|
170
265
|
workspaceSlug: slug,
|
|
171
|
-
}, controlMetadata);
|
|
266
|
+
}, controlMetadata, signal);
|
|
172
267
|
}
|
|
173
268
|
catch (error) {
|
|
174
269
|
if (error.code !== grpc.status.NOT_FOUND)
|
|
175
270
|
throw error;
|
|
176
|
-
workspace
|
|
271
|
+
// A new workspace's default container is a creation: seed the default
|
|
272
|
+
// environment. An existing one keeps its stored env instead.
|
|
273
|
+
const env = mergeDefaultEnv(this.getConfig().containerEnv, undefined);
|
|
274
|
+
await unary(control, "createWorkspace", {
|
|
177
275
|
workspaceSlug: slug,
|
|
178
276
|
projectName,
|
|
179
|
-
imageId: this.
|
|
277
|
+
imageId: this.getConfig().defaultImage,
|
|
180
278
|
mounts: [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }],
|
|
181
|
-
|
|
279
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
280
|
+
}, controlMetadata, signal);
|
|
182
281
|
}
|
|
183
|
-
const
|
|
184
|
-
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
};
|
|
282
|
+
const row = await this.ensureContainer(slug, "default", signal);
|
|
283
|
+
return this.bindingFor(row, () => {
|
|
284
|
+
this.bindings.delete(slug);
|
|
285
|
+
return this.resolveBinding(slug, projectName);
|
|
286
|
+
});
|
|
189
287
|
}
|
|
190
|
-
|
|
191
|
-
|
|
288
|
+
// ensureContainer asks the orchestrator for a usable container, recreating it
|
|
289
|
+
// when it is missing, stopped, or runs a guest agent from an outdated image,
|
|
290
|
+
// and returns its row. A container the orchestrator does not know is reported
|
|
291
|
+
// as the caller's missing container.
|
|
292
|
+
async ensureContainer(slug, container, signal) {
|
|
293
|
+
try {
|
|
294
|
+
return await this.control("ensureContainer", {
|
|
295
|
+
workspaceSlug: slug,
|
|
296
|
+
container,
|
|
297
|
+
}, signal);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error.code !== grpc.status.NOT_FOUND)
|
|
301
|
+
throw error;
|
|
302
|
+
throw containerNotFound(container, slug);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async control(method, request, signal) {
|
|
306
|
+
const result = await unary(controlClient(join(this.getConfig().socketsRoot, "orchestrator.sock")), method, request, metadata(this.getConfig().controlToken, VERSION), signal);
|
|
307
|
+
this.invalidateAfter(method, request);
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
// invalidateAfter drops the cached bindings a successful mutation invalidated.
|
|
311
|
+
// Only reachable after the call resolved, so a failed mutation keeps the
|
|
312
|
+
// warm entry it did not change.
|
|
313
|
+
invalidateAfter(method, request) {
|
|
314
|
+
const scope = CONTAINER_MUTATIONS[method];
|
|
315
|
+
if (scope === undefined)
|
|
316
|
+
return;
|
|
317
|
+
const fields = (typeof request === "object" && request !== null ? request : {});
|
|
318
|
+
const slug = typeof fields.workspaceSlug === "string"
|
|
319
|
+
? fields.workspaceSlug
|
|
320
|
+
: "";
|
|
321
|
+
if (slug === "")
|
|
322
|
+
return;
|
|
323
|
+
if (scope === "workspace") {
|
|
324
|
+
this.forgetWorkspace(slug);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const container = typeof fields.container === "string"
|
|
328
|
+
? fields.container
|
|
329
|
+
: "";
|
|
330
|
+
if (container === "")
|
|
331
|
+
return;
|
|
332
|
+
this.forgetContainer(slug, container);
|
|
192
333
|
}
|
|
193
334
|
}
|
|
194
335
|
// notFoundError builds the harness's missing-path error (`FsErrorCode`
|
|
@@ -214,10 +355,15 @@ export function containerNotFound(container, slug) {
|
|
|
214
355
|
// when the error carried one) so callers can branch without parsing text.
|
|
215
356
|
export function normalizeToolError(error) {
|
|
216
357
|
const raw = error instanceof Error ? error.message : String(error);
|
|
217
|
-
const message = raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
|
|
218
358
|
const code = error.code;
|
|
219
|
-
const
|
|
220
|
-
|
|
359
|
+
const numericCode = typeof code === "number" ? code : undefined;
|
|
360
|
+
// Only a real gRPC status renders the "<code> <NAME>: " prefix; a plain
|
|
361
|
+
// message that merely looks like one must stay intact.
|
|
362
|
+
const message = numericCode === undefined
|
|
363
|
+
? raw
|
|
364
|
+
: raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
|
|
365
|
+
const name = numericCode !== undefined
|
|
366
|
+
? grpc.status[numericCode]
|
|
221
367
|
: typeof code === "string"
|
|
222
368
|
? code
|
|
223
369
|
: undefined;
|
|
@@ -231,6 +377,15 @@ function waitForReady(agent, timeoutMs = 15000) {
|
|
|
231
377
|
agent.waitForReady(Date.now() + timeoutMs, (error) => error ? reject(error) : resolve());
|
|
232
378
|
});
|
|
233
379
|
}
|
|
380
|
+
// pingAgent makes the guest readiness call, which also exercises the credential
|
|
381
|
+
// (the agent's auth interceptor guards every call, Ping included). Unlike
|
|
382
|
+
// waitForReady, which only proves connectivity, it fails when the token is
|
|
383
|
+
// rejected — which is exactly what a rotated credential looks like.
|
|
384
|
+
function pingAgent(binding) {
|
|
385
|
+
return new Promise((resolveDone, reject) => {
|
|
386
|
+
binding.guest.ping({}, metadata(binding.token), (error) => (error ? reject(error) : resolveDone()));
|
|
387
|
+
});
|
|
388
|
+
}
|
|
234
389
|
// workspaceSlug validates the workspace id that names this workspace's pod
|
|
235
390
|
// (dsh-podman-<slug>) and containers (dsh-podman-<slug>-<logical>). It must be
|
|
236
391
|
// a UUID so a container name can never collide with a pod name.
|
|
@@ -242,9 +397,6 @@ export function workspaceSlug(id) {
|
|
|
242
397
|
return value.toLowerCase();
|
|
243
398
|
}
|
|
244
399
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
245
|
-
export function containerRowFor(containers, slug, container) {
|
|
246
|
-
return containers.find((row) => row.workspaceSlug === slug && row.containerName === container);
|
|
247
|
-
}
|
|
248
400
|
// metadata builds the call metadata. The plugin version is sent on control
|
|
249
401
|
// calls so the orchestrator can refuse an incompatible plugin; guest calls omit
|
|
250
402
|
// it, since the guest agent does not check it.
|
|
@@ -263,6 +415,21 @@ export function metadata(token, pluginVersion) {
|
|
|
263
415
|
function defaultCwdOf(cwd) {
|
|
264
416
|
return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
|
|
265
417
|
}
|
|
418
|
+
// projectNameForPath maps a dsh workspace's host path to the project path a
|
|
419
|
+
// mount stores: the path relative to the projects root. It is a prefix test,
|
|
420
|
+
// not a String.replace, so a root that appears later in the path cannot corrupt
|
|
421
|
+
// the name, and a workspace outside the root fails with a clear error instead
|
|
422
|
+
// of silently producing an absolute "project name".
|
|
423
|
+
function projectNameForPath(projectsRoot, path) {
|
|
424
|
+
const root = String(projectsRoot ?? "").replace(/\/+$/, "");
|
|
425
|
+
const value = String(path ?? "").replace(/\/+$/, "");
|
|
426
|
+
if (root === "" || value === root)
|
|
427
|
+
return "";
|
|
428
|
+
if (!value.startsWith(`${root}/`)) {
|
|
429
|
+
throw new Error(`workspace path ${JSON.stringify(value)} is not under the projects root ${JSON.stringify(root)}`);
|
|
430
|
+
}
|
|
431
|
+
return value.slice(root.length + 1);
|
|
432
|
+
}
|
|
266
433
|
// projectMountCovers reports whether a project mount of a container makes the
|
|
267
434
|
// given container path reachable: the mount destination itself or a path below
|
|
268
435
|
// it.
|