@exagone313/dsh-podman 0.2.0-rc.3 → 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 +14 -2
- 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 +154 -42
- package/dist/card-route.test.js +85 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +92 -50
- package/dist/client/card-protocol.d.ts +6 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +16 -18
- package/dist/client/container-card-controller.js +92 -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 +13141 -2036
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +106 -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.d.ts +1 -0
- package/dist/dsh-version.js +46 -0
- 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 +11 -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 +38 -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/version-compat.d.ts +4 -0
- package/dist/version-compat.js +29 -0
- package/dist/version-compat.test.d.ts +1 -0
- package/dist/version-compat.test.js +27 -0
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +19 -10
- package/dist/workspace-binding.js +252 -78
- package/dist/workspace-binding.test.js +202 -27
- package/docs/architecture.md +71 -20
- package/docs/architecture.zh.md +62 -12
- 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 +184 -29
- package/docs/development.zh.md +165 -26
- package/docs/install-dsh-and-dsh-podman.md +24 -21
- package/docs/install-dsh-and-dsh-podman.zh.md +20 -21
- 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
|
@@ -4,6 +4,7 @@ export interface WorkspaceBinding {
|
|
|
4
4
|
token: string;
|
|
5
5
|
socket: string;
|
|
6
6
|
defaultCwd?: string;
|
|
7
|
+
refresh?: () => Promise<WorkspaceBinding>;
|
|
7
8
|
}
|
|
8
9
|
export interface BindingConfig {
|
|
9
10
|
socketsRoot: string;
|
|
@@ -11,30 +12,38 @@ export interface BindingConfig {
|
|
|
11
12
|
projectsRoot: string;
|
|
12
13
|
controlToken: string;
|
|
13
14
|
readyTimeoutMs?: number;
|
|
15
|
+
containerEnv?: Record<string, string>;
|
|
14
16
|
}
|
|
15
17
|
export declare class WorkspaceResolver {
|
|
16
18
|
private readonly config;
|
|
17
19
|
private readonly registry;
|
|
18
20
|
private readonly bindings;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
private readonly containerBindings;
|
|
22
|
+
constructor(config: BindingConfig | (() => BindingConfig), registry: any);
|
|
21
23
|
getConfig(): Readonly<BindingConfig>;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
dispose(): void;
|
|
25
|
+
forgetContainer(slug: string, container: string): void;
|
|
26
|
+
forgetWorkspace(slug: string): void;
|
|
27
|
+
resolve(cwd: unknown, signal?: AbortSignal): Promise<WorkspaceBinding>;
|
|
28
|
+
resolveForPath(path: string, cwd: unknown, signal?: AbortSignal): Promise<WorkspaceBinding>;
|
|
24
29
|
private containingWorkspace;
|
|
25
30
|
private workspaceForCwd;
|
|
26
|
-
resolveSlug(key: string): Promise<WorkspaceBinding>;
|
|
27
|
-
containerBinding(cwd: unknown, container: string): Promise<WorkspaceBinding>;
|
|
31
|
+
resolveSlug(key: string, signal?: AbortSignal): Promise<WorkspaceBinding>;
|
|
32
|
+
containerBinding(cwd: unknown, container: string, signal?: AbortSignal): Promise<WorkspaceBinding>;
|
|
33
|
+
private resolveContainerBinding;
|
|
34
|
+
private buildContainerBinding;
|
|
28
35
|
private ready;
|
|
29
36
|
private readyTimeoutMs;
|
|
30
|
-
private
|
|
37
|
+
private bindingFor;
|
|
38
|
+
private ensureAgentReady;
|
|
31
39
|
private recoverContainer;
|
|
32
40
|
private resolveBinding;
|
|
33
41
|
private create;
|
|
34
|
-
|
|
42
|
+
private ensureContainer;
|
|
43
|
+
control<T>(method: string, request: unknown, signal?: AbortSignal): Promise<T>;
|
|
44
|
+
private invalidateAfter;
|
|
35
45
|
}
|
|
36
46
|
export declare function containerNotFound(container: string, slug?: string): Error;
|
|
37
47
|
export declare function normalizeToolError(error: unknown): Error;
|
|
38
48
|
export declare function workspaceSlug(id: unknown): string;
|
|
39
|
-
export declare function
|
|
40
|
-
export declare function metadata(token: string): grpc.Metadata;
|
|
49
|
+
export declare function metadata(token: string, pluginVersion?: string): grpc.Metadata;
|
|
@@ -1,31 +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";
|
|
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
|
+
};
|
|
6
23
|
export class WorkspaceResolver {
|
|
7
24
|
config;
|
|
8
25
|
registry;
|
|
9
26
|
bindings = new Map();
|
|
10
|
-
|
|
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) {
|
|
11
35
|
this.config = config;
|
|
12
36
|
this.registry = registry;
|
|
13
37
|
}
|
|
14
|
-
setConfig(patch) {
|
|
15
|
-
Object.assign(this.config, patch);
|
|
16
|
-
}
|
|
17
38
|
getConfig() {
|
|
18
|
-
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
|
+
}
|
|
19
61
|
}
|
|
20
|
-
async resolve(cwd) {
|
|
62
|
+
async resolve(cwd, signal) {
|
|
21
63
|
const workspace = await this.workspaceForCwd(cwd);
|
|
22
|
-
const relativePath = String(workspace.path).replace(`${this.config.projectsRoot}/`, "");
|
|
23
64
|
const key = workspaceSlug(workspace.id);
|
|
24
|
-
const
|
|
65
|
+
const projectName = projectNameForPath(this.getConfig().projectsRoot, workspace.path);
|
|
66
|
+
const binding = await this.ready(key, projectName, signal);
|
|
25
67
|
// The default container always keeps its project mount, so the session
|
|
26
68
|
// directory is always mounted in it.
|
|
27
69
|
const session = defaultCwdOf(cwd);
|
|
28
|
-
return session === undefined
|
|
70
|
+
return session === undefined
|
|
71
|
+
? binding
|
|
72
|
+
: { ...binding, defaultCwd: session };
|
|
29
73
|
}
|
|
30
74
|
// Resolve the workspace binding for a filesystem path when the caller did not
|
|
31
75
|
// supply a session working directory. The 0.1.5 `fs.resolve` contract allows
|
|
@@ -33,13 +77,14 @@ export class WorkspaceResolver {
|
|
|
33
77
|
// sensible owner, so match it by longest canonical-path prefix. A path
|
|
34
78
|
// outside every workspace is not in any container's filesystem, so it is
|
|
35
79
|
// reported as missing rather than failing the caller.
|
|
36
|
-
async resolveForPath(path, cwd) {
|
|
80
|
+
async resolveForPath(path, cwd, signal) {
|
|
37
81
|
if (typeof cwd === "string" && cwd !== "")
|
|
38
|
-
return this.resolve(cwd);
|
|
82
|
+
return this.resolve(cwd, signal);
|
|
39
83
|
if (isAbsolute(path)) {
|
|
40
84
|
const workspace = this.containingWorkspace(path);
|
|
41
|
-
if (workspace !== undefined)
|
|
42
|
-
return this.resolve(String(workspace.path));
|
|
85
|
+
if (workspace !== undefined) {
|
|
86
|
+
return this.resolve(String(workspace.path), signal);
|
|
87
|
+
}
|
|
43
88
|
throw notFoundError(`no DH workspace contains path ${JSON.stringify(path)}`);
|
|
44
89
|
}
|
|
45
90
|
throw new Error(`cannot resolve a DH workspace for path ${JSON.stringify(path)}; pass a session working directory`);
|
|
@@ -69,89 +114,141 @@ export class WorkspaceResolver {
|
|
|
69
114
|
}
|
|
70
115
|
return workspace;
|
|
71
116
|
}
|
|
72
|
-
resolveSlug(key) {
|
|
73
|
-
return this.ready(key, key);
|
|
117
|
+
resolveSlug(key, signal) {
|
|
118
|
+
return this.ready(key, key, signal);
|
|
74
119
|
}
|
|
75
|
-
async containerBinding(cwd, container) {
|
|
120
|
+
async containerBinding(cwd, container, signal) {
|
|
76
121
|
const workspace = await this.workspaceForCwd(cwd);
|
|
77
122
|
const slug = workspaceSlug(workspace.id);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
};
|
|
82
139
|
}
|
|
83
|
-
const socket = row.agentSocketPath;
|
|
84
|
-
const binding = {
|
|
85
|
-
guest: guestClient(socket),
|
|
86
|
-
token: row.agentToken,
|
|
87
|
-
socket,
|
|
88
|
-
};
|
|
89
140
|
const session = defaultCwdOf(cwd);
|
|
90
141
|
if (session !== undefined &&
|
|
91
|
-
projectMountCovers(this.
|
|
92
|
-
binding
|
|
142
|
+
projectMountCovers(this.getConfig().projectsRoot, entry.mounts, session)) {
|
|
143
|
+
return { ...entry.binding, defaultCwd: session };
|
|
93
144
|
}
|
|
94
|
-
|
|
95
|
-
|
|
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 ?? [] };
|
|
96
174
|
}
|
|
97
175
|
// ready resolves a workspace's default container and waits for its guest
|
|
98
176
|
// agent, recreating the container once when it is reported running but its
|
|
99
177
|
// agent never answers (a crashed agent or a lost socket).
|
|
100
|
-
async ready(key, projectName) {
|
|
101
|
-
let binding = await this.resolveBinding(key, projectName);
|
|
178
|
+
async ready(key, projectName, signal) {
|
|
179
|
+
let binding = await this.resolveBinding(key, projectName, signal);
|
|
102
180
|
try {
|
|
103
|
-
await
|
|
181
|
+
return await this.ensureAgentReady(binding);
|
|
104
182
|
}
|
|
105
183
|
catch {
|
|
106
184
|
this.bindings.delete(key);
|
|
107
|
-
binding = await this.resolveBinding(key, projectName);
|
|
185
|
+
binding = await this.resolveBinding(key, projectName, signal);
|
|
108
186
|
try {
|
|
109
|
-
await
|
|
187
|
+
return await this.ensureAgentReady(binding);
|
|
110
188
|
}
|
|
111
189
|
catch {
|
|
112
|
-
await this.recoverContainer(key, "default");
|
|
190
|
+
await this.recoverContainer(key, "default", signal);
|
|
113
191
|
this.bindings.delete(key);
|
|
114
|
-
binding = await this.resolveBinding(key, projectName);
|
|
115
|
-
await
|
|
192
|
+
binding = await this.resolveBinding(key, projectName, signal);
|
|
193
|
+
return await this.ensureAgentReady(binding);
|
|
116
194
|
}
|
|
117
195
|
}
|
|
118
|
-
return binding;
|
|
119
196
|
}
|
|
120
197
|
readyTimeoutMs() {
|
|
121
|
-
const configured = Number(this.
|
|
198
|
+
const configured = Number(this.getConfig().readyTimeoutMs);
|
|
122
199
|
return Number.isFinite(configured) && configured > 0 ? configured : 15000;
|
|
123
200
|
}
|
|
124
|
-
//
|
|
125
|
-
// once
|
|
126
|
-
|
|
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());
|
|
127
219
|
try {
|
|
128
|
-
await
|
|
129
|
-
return;
|
|
220
|
+
await pingAgent(binding);
|
|
221
|
+
return binding;
|
|
130
222
|
}
|
|
131
|
-
catch {
|
|
132
|
-
|
|
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;
|
|
133
232
|
}
|
|
134
|
-
await this.recoverContainer(slug, container);
|
|
135
|
-
await waitForReady(binding.guest, this.readyTimeoutMs());
|
|
136
233
|
}
|
|
137
234
|
// recoverContainer best-effort recreates a container whose agent is
|
|
138
235
|
// unreachable. A failure here is not fatal: the caller's retry surfaces the
|
|
139
236
|
// real readiness failure.
|
|
140
|
-
async recoverContainer(slug, container) {
|
|
237
|
+
async recoverContainer(slug, container, signal) {
|
|
141
238
|
try {
|
|
142
239
|
await this.control("recreateContainer", {
|
|
143
240
|
workspaceSlug: slug,
|
|
144
241
|
container,
|
|
145
|
-
});
|
|
242
|
+
}, signal);
|
|
146
243
|
}
|
|
147
244
|
catch {
|
|
148
245
|
// Best-effort.
|
|
149
246
|
}
|
|
150
247
|
}
|
|
151
|
-
resolveBinding(key, projectName) {
|
|
248
|
+
resolveBinding(key, projectName, signal) {
|
|
152
249
|
let binding = this.bindings.get(key);
|
|
153
250
|
if (binding === undefined) {
|
|
154
|
-
binding = this.create(key, projectName).catch((error) => {
|
|
251
|
+
binding = this.create(key, projectName, signal).catch((error) => {
|
|
155
252
|
if (this.bindings.get(key) === binding)
|
|
156
253
|
this.bindings.delete(key);
|
|
157
254
|
throw error;
|
|
@@ -160,34 +257,79 @@ export class WorkspaceResolver {
|
|
|
160
257
|
}
|
|
161
258
|
return binding;
|
|
162
259
|
}
|
|
163
|
-
async create(slug, projectName) {
|
|
164
|
-
const control = controlClient(join(this.
|
|
165
|
-
const controlMetadata = metadata(this.
|
|
166
|
-
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);
|
|
167
263
|
try {
|
|
168
|
-
|
|
264
|
+
await unary(control, "describeWorkspace", {
|
|
169
265
|
workspaceSlug: slug,
|
|
170
|
-
}, controlMetadata);
|
|
266
|
+
}, controlMetadata, signal);
|
|
171
267
|
}
|
|
172
268
|
catch (error) {
|
|
173
269
|
if (error.code !== grpc.status.NOT_FOUND)
|
|
174
270
|
throw error;
|
|
175
|
-
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", {
|
|
176
275
|
workspaceSlug: slug,
|
|
177
276
|
projectName,
|
|
178
|
-
imageId: this.
|
|
277
|
+
imageId: this.getConfig().defaultImage,
|
|
179
278
|
mounts: [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }],
|
|
180
|
-
|
|
279
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
280
|
+
}, controlMetadata, signal);
|
|
281
|
+
}
|
|
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
|
+
});
|
|
287
|
+
}
|
|
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);
|
|
181
303
|
}
|
|
182
|
-
const socket = workspace.agentSocketPath;
|
|
183
|
-
return {
|
|
184
|
-
guest: guestClient(socket),
|
|
185
|
-
token: workspace.agentToken,
|
|
186
|
-
socket,
|
|
187
|
-
};
|
|
188
304
|
}
|
|
189
|
-
async control(method, request) {
|
|
190
|
-
|
|
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);
|
|
191
333
|
}
|
|
192
334
|
}
|
|
193
335
|
// notFoundError builds the harness's missing-path error (`FsErrorCode`
|
|
@@ -213,10 +355,15 @@ export function containerNotFound(container, slug) {
|
|
|
213
355
|
// when the error carried one) so callers can branch without parsing text.
|
|
214
356
|
export function normalizeToolError(error) {
|
|
215
357
|
const raw = error instanceof Error ? error.message : String(error);
|
|
216
|
-
const message = raw.replace(/^\d+\s+[A-Z_]+:\s*/, "");
|
|
217
358
|
const code = error.code;
|
|
218
|
-
const
|
|
219
|
-
|
|
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]
|
|
220
367
|
: typeof code === "string"
|
|
221
368
|
? code
|
|
222
369
|
: undefined;
|
|
@@ -230,6 +377,15 @@ function waitForReady(agent, timeoutMs = 15000) {
|
|
|
230
377
|
agent.waitForReady(Date.now() + timeoutMs, (error) => error ? reject(error) : resolve());
|
|
231
378
|
});
|
|
232
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
|
+
}
|
|
233
389
|
// workspaceSlug validates the workspace id that names this workspace's pod
|
|
234
390
|
// (dsh-podman-<slug>) and containers (dsh-podman-<slug>-<logical>). It must be
|
|
235
391
|
// a UUID so a container name can never collide with a pod name.
|
|
@@ -241,14 +397,17 @@ export function workspaceSlug(id) {
|
|
|
241
397
|
return value.toLowerCase();
|
|
242
398
|
}
|
|
243
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;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
export function metadata(token) {
|
|
400
|
+
// metadata builds the call metadata. The plugin version is sent on control
|
|
401
|
+
// calls so the orchestrator can refuse an incompatible plugin; guest calls omit
|
|
402
|
+
// it, since the guest agent does not check it.
|
|
403
|
+
export function metadata(token, pluginVersion) {
|
|
248
404
|
const result = new grpc.Metadata();
|
|
249
405
|
if (token !== "") {
|
|
250
406
|
result.set("authorization", `bearer ${token}`);
|
|
251
407
|
}
|
|
408
|
+
if (pluginVersion !== undefined && pluginVersion !== "") {
|
|
409
|
+
result.set("x-dsh-podman-plugin-version", pluginVersion);
|
|
410
|
+
}
|
|
252
411
|
return result;
|
|
253
412
|
}
|
|
254
413
|
// defaultCwdOf returns the session working directory as a usable container
|
|
@@ -256,6 +415,21 @@ export function metadata(token) {
|
|
|
256
415
|
function defaultCwdOf(cwd) {
|
|
257
416
|
return typeof cwd === "string" && cwd !== "" ? cwd : undefined;
|
|
258
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
|
+
}
|
|
259
433
|
// projectMountCovers reports whether a project mount of a container makes the
|
|
260
434
|
// given container path reachable: the mount destination itself or a path below
|
|
261
435
|
// it.
|