@dsh-workbench/shared 1.1.14-beta.2 → 1.1.15
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 +1 -1
- package/dist/api/dsh.d.ts +12 -0
- package/dist/components/SelectFieldWheel.d.ts +9 -0
- package/dist/components/SelectFieldWheel.js +35 -0
- package/dist/contract/unit-declaration.js +15 -1
- package/dist/contract/unit.d.ts +3 -0
- package/dist/host/__scratch-guard.d.ts +1 -0
- package/dist/host/__scratch-guard.js +1 -0
- package/dist/host/adapter-set.js +0 -1
- package/dist/host/changelog.js +2 -1
- package/dist/host/channel-plugin.d.ts +15 -0
- package/dist/host/channel-plugin.js +42 -0
- package/dist/host/command-name.d.ts +1 -0
- package/dist/host/command-name.js +14 -0
- package/dist/host/config-sync.d.ts +1 -9
- package/dist/host/config-sync.js +3 -78
- package/dist/host/env-policy.d.ts +75 -0
- package/dist/host/env-policy.js +161 -0
- package/dist/host/extension.js +2 -1
- package/dist/host/flow/run.js +2 -1
- package/dist/host/fs-write.d.ts +68 -0
- package/dist/host/fs-write.js +121 -0
- package/dist/host/host-kit.d.ts +6 -2
- package/dist/host/host-kit.js +3 -1
- package/dist/host/http.js +11 -5
- package/dist/host/installed-sources.js +4 -5
- package/dist/host/long-task.js +3 -3
- package/dist/host/opencli-runner.d.ts +26 -0
- package/dist/host/opencli-runner.js +50 -0
- package/dist/host/package-content.d.ts +2 -2
- package/dist/host/package-content.js +20 -6
- package/dist/host/package-route.d.ts +3 -3
- package/dist/host/package-route.js +7 -6
- package/dist/host/panel-state.js +2 -1
- package/dist/host/patch-blocks.d.ts +13 -0
- package/dist/host/patch-blocks.js +52 -0
- package/dist/host/patch.d.ts +3 -8
- package/dist/host/patch.js +5 -76
- package/dist/host/plugin-source.d.ts +33 -0
- package/dist/host/plugin-source.js +91 -0
- package/dist/host/port-types.d.ts +9 -9
- package/dist/host/profile-registries.js +2 -3
- package/dist/host/registry-declaration.d.ts +2 -2
- package/dist/host/registry-declaration.js +14 -5
- package/dist/host/release-probe.d.ts +4 -4
- package/dist/host/release-probe.js +39 -14
- package/dist/host/runner.d.ts +26 -0
- package/dist/host/runner.js +50 -0
- package/dist/host/runtime-links.d.ts +7 -0
- package/dist/host/runtime-links.js +25 -0
- package/dist/host/seam/denial.d.ts +4 -0
- package/dist/host/seam/denial.js +24 -0
- package/dist/host/seam/env.d.ts +15 -0
- package/dist/host/seam/env.js +17 -0
- package/dist/host/seam/exec.d.ts +84 -0
- package/dist/host/seam/exec.js +1 -0
- package/dist/host/seam/host-env.d.ts +2 -0
- package/dist/host/seam/host-env.js +18 -0
- package/dist/host/seam/intent.d.ts +66 -0
- package/dist/host/seam/intent.js +24 -0
- package/dist/host/seam/policy.d.ts +10 -0
- package/dist/host/seam/policy.js +88 -0
- package/dist/host/seam/process.d.ts +2 -0
- package/dist/host/seam/process.js +80 -0
- package/dist/host/seam/run.d.ts +44 -0
- package/dist/host/seam/run.js +415 -0
- package/dist/host/seam/service.d.ts +4 -0
- package/dist/host/seam/service.js +4 -0
- package/dist/host/seam/unit-seam.d.ts +14 -0
- package/dist/host/seam/unit-seam.js +43 -0
- package/dist/host/shell-render.d.ts +9 -0
- package/dist/host/shell-render.js +18 -0
- package/dist/host/shell.d.ts +12 -0
- package/dist/host/shell.js +3 -1
- package/dist/host/time.d.ts +10 -0
- package/dist/host/time.js +14 -0
- package/dist/host/value-handoff.js +3 -2
- package/dist/host/workspace-store.js +2 -8
- package/dist/host.d.ts +12 -0
- package/dist/host.js +12 -0
- package/dist/i18n/en.js +3 -3
- package/dist/i18n/index.d.ts +4 -4
- package/dist/i18n/zh.d.ts +3 -3
- package/dist/i18n/zh.js +3 -3
- package/dist/lib/version.d.ts +4 -0
- package/dist/lib/version.js +21 -0
- package/dist/testing/fake-adapter-set.js +0 -1
- package/dist/testing/git.d.ts +3 -0
- package/dist/testing/git.js +36 -0
- package/dist/testing/host-fakes.d.ts +30 -0
- package/dist/testing/host-fakes.js +104 -0
- package/package.json +5 -2
- package/src/api/dsh.ts +8 -1
- package/src/contract/unit-declaration.ts +20 -1
- package/src/contract/unit.ts +3 -0
- package/src/host/__tests__/env-policy.test.ts +114 -0
- package/src/host/__tests__/fs-write.test.ts +96 -0
- package/src/host/__tests__/preflight-route.test.ts +29 -0
- package/src/host/__tests__/seam.test.ts +354 -0
- package/src/host/__tests__/shell-render.test.ts +58 -0
- package/src/host/__tests__/shell.test.ts +24 -0
- package/src/host/__tests__/time.test.ts +20 -0
- package/src/host/adapter-set.ts +0 -1
- package/src/host/changelog.ts +2 -1
- package/src/host/config-sync.ts +3 -90
- package/src/host/env-policy.ts +192 -0
- package/src/host/extension.ts +2 -1
- package/src/host/flow/run.ts +2 -1
- package/src/host/fs-write.ts +147 -0
- package/src/host/host-kit.ts +7 -2
- package/src/host/http.ts +11 -5
- package/src/host/installed-sources.ts +4 -5
- package/src/host/long-task.ts +3 -3
- package/src/host/package-content.ts +23 -13
- package/src/host/package-route.ts +10 -9
- package/src/host/panel-state.ts +2 -1
- package/src/host/patch-blocks.ts +67 -0
- package/src/host/patch.ts +5 -89
- package/src/host/port-types.ts +9 -9
- package/src/host/profile-registries.ts +2 -3
- package/src/host/registry-declaration.ts +17 -12
- package/src/host/release-probe.ts +47 -34
- package/src/host/seam/denial.ts +26 -0
- package/src/host/seam/env.ts +23 -0
- package/src/host/seam/exec.ts +94 -0
- package/src/host/seam/host-env.ts +20 -0
- package/src/host/seam/intent.ts +101 -0
- package/src/host/seam/policy.ts +96 -0
- package/src/host/seam/process.ts +90 -0
- package/src/host/seam/run.ts +478 -0
- package/src/host/seam/service.ts +8 -0
- package/src/host/seam/unit-seam.ts +62 -0
- package/src/host/shell-render.ts +28 -0
- package/src/host/shell.ts +17 -1
- package/src/host/time.ts +16 -0
- package/src/host/value-handoff.ts +3 -2
- package/src/host/workspace-store.ts +2 -8
- package/src/host.ts +12 -0
- package/src/i18n/en.ts +3 -3
- package/src/i18n/zh.ts +3 -3
- package/src/testing/fake-adapter-set.ts +0 -1
- package/src/testing/host-fakes.ts +126 -0
- package/dist/lib/unit-plugin.d.ts +0 -28
- package/dist/lib/unit-plugin.js +0 -23
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { now } from '../time.js';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { DshShell } from '../../api/dsh.js';
|
|
5
|
+
import { workbenchRoot } from '../data-root.js';
|
|
6
|
+
import { shellKindOf } from '../shell-text.js';
|
|
7
|
+
import { callOperatorOf, invocationOf, quotedArgvLine, quotedLineOf } from '../shell-render.js';
|
|
8
|
+
import { runShell, type ShellResult } from '../shell.js';
|
|
9
|
+
import { deniedPathOf, sandboxDeniedOf } from './denial.js';
|
|
10
|
+
import { seamProductEnv } from './env.js';
|
|
11
|
+
import { definedEnvOf, hostEnvOf } from './host-env.js';
|
|
12
|
+
import type {
|
|
13
|
+
ChannelHandle,
|
|
14
|
+
ExecIntent,
|
|
15
|
+
OpenRequest,
|
|
16
|
+
ProcessChild,
|
|
17
|
+
ProcessHost,
|
|
18
|
+
SeamShellKind,
|
|
19
|
+
SeamTarget,
|
|
20
|
+
SpawnExit,
|
|
21
|
+
SpawnHandle,
|
|
22
|
+
SpawnRequest,
|
|
23
|
+
} from './exec.js';
|
|
24
|
+
import {
|
|
25
|
+
SeamError,
|
|
26
|
+
type DenialScope,
|
|
27
|
+
type SeamDenial,
|
|
28
|
+
type SeamFact,
|
|
29
|
+
type SeamMode,
|
|
30
|
+
type SeamPolicy,
|
|
31
|
+
type WriteIntent,
|
|
32
|
+
} from './intent.js';
|
|
33
|
+
import { denialScopeOf, narrowerMode, pathsPolicy, policyOf, productPolicy, seamModeOf, workspacePolicy } from './policy.js';
|
|
34
|
+
import { createNodeProcessHost } from './process.js';
|
|
35
|
+
|
|
36
|
+
export interface SeamRequest {
|
|
37
|
+
readonly command?: string | undefined;
|
|
38
|
+
readonly argv?: readonly string[] | undefined;
|
|
39
|
+
readonly shellKind?: SeamShellKind | undefined;
|
|
40
|
+
readonly intent: ExecIntent;
|
|
41
|
+
readonly session?: unknown;
|
|
42
|
+
readonly workdir?: string | undefined;
|
|
43
|
+
readonly timeoutMs?: number | undefined;
|
|
44
|
+
readonly maxBytes?: number | undefined;
|
|
45
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
46
|
+
readonly retry?: 'safe' | 'never' | undefined;
|
|
47
|
+
readonly bestEffort?: true | undefined;
|
|
48
|
+
readonly signal?: AbortSignal | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SeamOutcome extends ShellResult {
|
|
52
|
+
readonly denial?: SeamDenial | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Seam {
|
|
56
|
+
run(request: SeamRequest): Promise<SeamOutcome>;
|
|
57
|
+
spawn(request: SpawnRequest): SpawnHandle;
|
|
58
|
+
open(request: OpenRequest): ChannelHandle;
|
|
59
|
+
facts(): readonly SeamFact[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SeamPorts {
|
|
63
|
+
readonly shell: DshShell;
|
|
64
|
+
readonly runtimeRoot?: string | undefined;
|
|
65
|
+
readonly ceiling?: SeamMode | undefined;
|
|
66
|
+
readonly grantOf?: ((session: unknown) => SeamPolicy | undefined) | undefined;
|
|
67
|
+
readonly realHome?: string | undefined;
|
|
68
|
+
readonly allowHost?: boolean | undefined;
|
|
69
|
+
readonly processes?: ProcessHost | undefined;
|
|
70
|
+
readonly factLimit?: number | undefined;
|
|
71
|
+
readonly logger?: { warn?(message: string): void } | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DENIAL_MARKER = /\[sandbox: file access denied under [a-z-]+ mode\]/i;
|
|
75
|
+
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
76
|
+
|
|
77
|
+
function scopeText(scope: DenialScope): string {
|
|
78
|
+
if (scope === 'product') return '工作台自己的目录';
|
|
79
|
+
if (scope === 'workspace') return '会话工作区';
|
|
80
|
+
if (scope === 'external') return '工作区之外的路径';
|
|
81
|
+
return '没认出来的路径';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function denialMessageOf(scope: DenialScope, path: string | undefined, output: string): string {
|
|
85
|
+
const where = path === undefined ? '' : `:${path}`;
|
|
86
|
+
const marker = DENIAL_MARKER.test(output) ? '' : '(输出里没有沙箱标记,按拒绝处理)';
|
|
87
|
+
const next =
|
|
88
|
+
scope === 'external'
|
|
89
|
+
? '要写工作区外的路径得先让用户点头:把目标改到工作区,或让工具补上审批后再试'
|
|
90
|
+
: '这是工作台自己的声明问题,不是用户的配置问题:把命令、域与路径一起记下来交给维护者';
|
|
91
|
+
return `被沙箱挡住了(${scopeText(scope)}${where})${marker}。${next}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function settle(result: ShellResult, denial?: SeamDenial): SeamOutcome {
|
|
95
|
+
return {
|
|
96
|
+
exitCode: result.exitCode,
|
|
97
|
+
timedOut: result.timedOut,
|
|
98
|
+
aborted: result.aborted,
|
|
99
|
+
stdout: result.stdout,
|
|
100
|
+
stderr: result.stderr,
|
|
101
|
+
truncated: result.truncated,
|
|
102
|
+
stalled: result.stalled,
|
|
103
|
+
...(denial === undefined ? {} : { denial }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function targetOf(request: {
|
|
108
|
+
readonly command?: string | undefined;
|
|
109
|
+
readonly argv?: readonly string[] | undefined;
|
|
110
|
+
}): SeamTarget {
|
|
111
|
+
const hasCommand = request.command !== undefined;
|
|
112
|
+
const hasArgv = request.argv !== undefined;
|
|
113
|
+
if (hasArgv && !hasCommand) {
|
|
114
|
+
const argv = request.argv ?? [];
|
|
115
|
+
if (argv.length === 0) throw new SeamError('scope-not-declared', 'argv 是空的:至少给一个可执行文件');
|
|
116
|
+
return { argv };
|
|
117
|
+
}
|
|
118
|
+
if (hasCommand && !hasArgv) return { line: request.command ?? '' };
|
|
119
|
+
throw new SeamError('scope-not-declared', '这条执行请求要么给 argv,要么给一条命令行,不能两个都给或都不给');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function displayOf(target: SeamTarget): string {
|
|
123
|
+
return 'argv' in target ? quotedArgvLine(target.argv) : target.line;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function targetInvocationOf(target: SeamTarget, shellKind: SeamShellKind | undefined): { file: string; args: readonly string[] } {
|
|
127
|
+
if ('argv' in target) {
|
|
128
|
+
const [file, ...args] = target.argv;
|
|
129
|
+
if (file === undefined) throw new SeamError('scope-not-declared', 'argv 是空的:至少给一个可执行文件');
|
|
130
|
+
return { file, args };
|
|
131
|
+
}
|
|
132
|
+
return invocationOf(shellKind ?? shellKindOf(), target.line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sandboxedLineOf(target: SeamTarget, shellKind: SeamShellKind | undefined): string {
|
|
136
|
+
const line = 'argv' in target ? quotedArgvLine(target.argv) : target.line;
|
|
137
|
+
if (shellKind === undefined) return callOperatorOf(shellKindOf(), line);
|
|
138
|
+
return callOperatorOf(shellKindOf(), quotedLineOf(invocationOf(shellKind, line)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createSeam(ports: SeamPorts): Seam {
|
|
142
|
+
const runtimeRoot = ports.runtimeRoot ?? workbenchRoot();
|
|
143
|
+
const ceiling = ports.ceiling ?? seamModeOf(process.env['DSH_PERMISSION_MODE']);
|
|
144
|
+
const allowHost = ports.allowHost === true;
|
|
145
|
+
const processes = ports.processes ?? createNodeProcessHost();
|
|
146
|
+
const limit = ports.factLimit ?? 80;
|
|
147
|
+
const facts: SeamFact[] = [];
|
|
148
|
+
|
|
149
|
+
function record(fact: SeamFact): void {
|
|
150
|
+
facts.push(fact);
|
|
151
|
+
if (facts.length > limit) facts.splice(0, facts.length - limit);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function grantFor(session: unknown): SeamPolicy | undefined {
|
|
155
|
+
if (ports.grantOf === undefined || session === undefined || session === null) return undefined;
|
|
156
|
+
try {
|
|
157
|
+
return ports.grantOf(session);
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requireHost(intent: ExecIntent): void {
|
|
164
|
+
if (intent.kind !== 'host') {
|
|
165
|
+
throw new SeamError('scope-not-declared', '这条调用要走宿主进程执行,但写入域不是 host');
|
|
166
|
+
}
|
|
167
|
+
if (!allowHost) throw new SeamError('scope-not-declared', '宿主域只给宿主侧接线开出来;模块与普通调用点拿不到');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function attempt(request: SeamRequest, policy: SeamPolicy, line: string): Promise<ShellResult> {
|
|
171
|
+
return runShell(ports.shell, line, {
|
|
172
|
+
...(request.workdir === undefined ? {} : { workdir: request.workdir }),
|
|
173
|
+
...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
|
|
174
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
175
|
+
env: { ...seamProductEnv({ runtimeRoot, realHome: ports.realHome }), ...definedEnvOf(request.env) },
|
|
176
|
+
sandboxPolicy: policy,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function healPolicyOf(intent: WriteIntent, scope: DenialScope, granted: SeamPolicy | undefined): SeamPolicy | undefined {
|
|
181
|
+
if (scope === 'product' && intent.kind !== 'product') return productPolicy(ceiling, runtimeRoot);
|
|
182
|
+
if (scope === 'workspace' && intent.kind === 'workspace') {
|
|
183
|
+
if (granted === undefined) return undefined;
|
|
184
|
+
try {
|
|
185
|
+
return workspacePolicy(ceiling, granted);
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function runHost(request: SeamRequest, target: SeamTarget): Promise<ShellResult> {
|
|
194
|
+
const invocation = targetInvocationOf(target, request.shellKind);
|
|
195
|
+
const child = processes.spawn({
|
|
196
|
+
file: invocation.file,
|
|
197
|
+
args: invocation.args,
|
|
198
|
+
env: hostEnvOf(request.env),
|
|
199
|
+
...(request.workdir === undefined ? {} : { cwd: request.workdir }),
|
|
200
|
+
detached: false,
|
|
201
|
+
stdio: 'pipe',
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
});
|
|
204
|
+
const state = { stdout: '', stderr: '', truncated: false };
|
|
205
|
+
const append = (which: 'stdout' | 'stderr', chunk: string): void => {
|
|
206
|
+
const total = state.stdout.length + state.stderr.length;
|
|
207
|
+
const room = (request.maxBytes ?? DEFAULT_MAX_BYTES) - total;
|
|
208
|
+
if (room <= 0) {
|
|
209
|
+
state.truncated = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const text = chunk.length > room ? chunk.slice(0, room) : chunk;
|
|
213
|
+
state[which] += text;
|
|
214
|
+
if (chunk.length > room) state.truncated = true;
|
|
215
|
+
};
|
|
216
|
+
child.stdout?.onData((chunk) => append('stdout', chunk));
|
|
217
|
+
child.stderr?.onData((chunk) => append('stderr', chunk));
|
|
218
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
219
|
+
let timedOut = false;
|
|
220
|
+
if (request.timeoutMs !== undefined) {
|
|
221
|
+
timer = setTimeout(() => {
|
|
222
|
+
timedOut = true;
|
|
223
|
+
child.kill('SIGKILL');
|
|
224
|
+
}, request.timeoutMs);
|
|
225
|
+
}
|
|
226
|
+
const exit = await new Promise<SpawnExit>((resolve) => {
|
|
227
|
+
child.onExit((event) => resolve(event));
|
|
228
|
+
child.onError((error) => {
|
|
229
|
+
state.stderr = `${state.stderr}${String(error)}`;
|
|
230
|
+
resolve({ code: null, signal: null });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
234
|
+
return {
|
|
235
|
+
exitCode: exit.code,
|
|
236
|
+
timedOut,
|
|
237
|
+
aborted: false,
|
|
238
|
+
stdout: state.stdout,
|
|
239
|
+
stderr: state.stderr,
|
|
240
|
+
truncated: state.truncated,
|
|
241
|
+
stalled: false,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function spawnHost(request: SpawnRequest): SpawnHandle {
|
|
246
|
+
const invocation = targetInvocationOf(request.target, undefined);
|
|
247
|
+
const logFile =
|
|
248
|
+
request.logFile ?? join(runtimeRoot, 'logs', `${request.label ?? 'spawn'}-${String(now())}.log`);
|
|
249
|
+
mkdirSync(dirname(logFile), { recursive: true });
|
|
250
|
+
const child = processes.spawn({
|
|
251
|
+
file: invocation.file,
|
|
252
|
+
args: invocation.args,
|
|
253
|
+
env: hostEnvOf(request.env),
|
|
254
|
+
...(request.cwd === undefined ? {} : { cwd: request.cwd }),
|
|
255
|
+
detached: true,
|
|
256
|
+
stdio: 'log',
|
|
257
|
+
logFile,
|
|
258
|
+
windowsHide: true,
|
|
259
|
+
});
|
|
260
|
+
child.unref();
|
|
261
|
+
record({
|
|
262
|
+
at: now(),
|
|
263
|
+
verb: 'spawn',
|
|
264
|
+
command: displayOf(request.target),
|
|
265
|
+
intent: 'host',
|
|
266
|
+
mode: 'host',
|
|
267
|
+
workspaceRoot: '',
|
|
268
|
+
exitCode: null,
|
|
269
|
+
timedOut: false,
|
|
270
|
+
stalled: false,
|
|
271
|
+
});
|
|
272
|
+
const display = displayOf(request.target);
|
|
273
|
+
const errorListeners = new Set<(error: Error) => void>();
|
|
274
|
+
const exitWaiters: Array<(event: SpawnExit) => void> = [];
|
|
275
|
+
let settled: SpawnExit | undefined;
|
|
276
|
+
let failure: Error | undefined;
|
|
277
|
+
const settleExit = (event: SpawnExit): void => {
|
|
278
|
+
if (settled !== undefined) return;
|
|
279
|
+
settled = event;
|
|
280
|
+
for (const waiter of exitWaiters.splice(0)) waiter(event);
|
|
281
|
+
};
|
|
282
|
+
child.onExit((event) => settleExit(event));
|
|
283
|
+
child.onError((error) => {
|
|
284
|
+
failure = error;
|
|
285
|
+
settleExit({ code: null, signal: null });
|
|
286
|
+
if (errorListeners.size === 0) {
|
|
287
|
+
ports.logger?.warn?.(`[dsh-workbench] 起进程没跑起来(${display}):${String(error)}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const listener of errorListeners) listener(error);
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
pid: child.pid,
|
|
294
|
+
logFile,
|
|
295
|
+
exit: () =>
|
|
296
|
+
settled === undefined
|
|
297
|
+
? new Promise<SpawnExit>((resolve) => exitWaiters.push(resolve))
|
|
298
|
+
: Promise.resolve(settled),
|
|
299
|
+
onError(listener) {
|
|
300
|
+
if (failure !== undefined) {
|
|
301
|
+
listener(failure);
|
|
302
|
+
return () => {};
|
|
303
|
+
}
|
|
304
|
+
errorListeners.add(listener);
|
|
305
|
+
return () => errorListeners.delete(listener);
|
|
306
|
+
},
|
|
307
|
+
kill: (signal) => child.kill(signal ?? 'SIGTERM'),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function openHost(request: OpenRequest): ChannelHandle {
|
|
312
|
+
const invocation = targetInvocationOf(request.target, undefined);
|
|
313
|
+
const child: ProcessChild = processes.spawn({
|
|
314
|
+
file: invocation.file,
|
|
315
|
+
args: invocation.args,
|
|
316
|
+
env: hostEnvOf(request.env),
|
|
317
|
+
...(request.cwd === undefined ? {} : { cwd: request.cwd }),
|
|
318
|
+
detached: false,
|
|
319
|
+
stdio: 'pipe',
|
|
320
|
+
windowsHide: true,
|
|
321
|
+
});
|
|
322
|
+
record({
|
|
323
|
+
at: now(),
|
|
324
|
+
verb: 'open',
|
|
325
|
+
command: displayOf(request.target),
|
|
326
|
+
intent: 'host',
|
|
327
|
+
mode: 'host',
|
|
328
|
+
workspaceRoot: '',
|
|
329
|
+
exitCode: null,
|
|
330
|
+
timedOut: false,
|
|
331
|
+
stalled: false,
|
|
332
|
+
});
|
|
333
|
+
const display = displayOf(request.target);
|
|
334
|
+
const errorListeners = new Set<(error: Error) => void>();
|
|
335
|
+
const exitListeners = new Set<(event: SpawnExit) => void>();
|
|
336
|
+
let failure: Error | undefined;
|
|
337
|
+
let settled: SpawnExit | undefined;
|
|
338
|
+
const settleExit = (event: SpawnExit): void => {
|
|
339
|
+
if (settled !== undefined) return;
|
|
340
|
+
settled = event;
|
|
341
|
+
for (const listener of exitListeners) listener(event);
|
|
342
|
+
};
|
|
343
|
+
child.onExit((event) => settleExit(event));
|
|
344
|
+
child.onError((error) => {
|
|
345
|
+
failure = error;
|
|
346
|
+
settleExit({ code: null, signal: null });
|
|
347
|
+
if (errorListeners.size === 0) {
|
|
348
|
+
ports.logger?.warn?.(`[dsh-workbench] 双向通道起不来(${display}):${String(error)}`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
for (const listener of errorListeners) listener(error);
|
|
352
|
+
});
|
|
353
|
+
return {
|
|
354
|
+
pid: child.pid,
|
|
355
|
+
write: (chunk) => child.stdin?.write(chunk),
|
|
356
|
+
onData(listener) {
|
|
357
|
+
const offOut = child.stdout?.onData(listener) ?? (() => {});
|
|
358
|
+
const offErr = child.stderr?.onData(listener) ?? (() => {});
|
|
359
|
+
return () => {
|
|
360
|
+
offOut();
|
|
361
|
+
offErr();
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
onExit(listener) {
|
|
365
|
+
if (settled !== undefined) {
|
|
366
|
+
listener(settled);
|
|
367
|
+
return () => {};
|
|
368
|
+
}
|
|
369
|
+
exitListeners.add(listener);
|
|
370
|
+
return () => exitListeners.delete(listener);
|
|
371
|
+
},
|
|
372
|
+
onError(listener) {
|
|
373
|
+
if (failure !== undefined) {
|
|
374
|
+
listener(failure);
|
|
375
|
+
return () => {};
|
|
376
|
+
}
|
|
377
|
+
errorListeners.add(listener);
|
|
378
|
+
return () => errorListeners.delete(listener);
|
|
379
|
+
},
|
|
380
|
+
kill: (signal) => child.kill(signal ?? 'SIGTERM'),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
async run(request: SeamRequest): Promise<SeamOutcome> {
|
|
386
|
+
const target = targetOf(request);
|
|
387
|
+
if (request.intent.kind === 'host') {
|
|
388
|
+
requireHost(request.intent);
|
|
389
|
+
const started = now();
|
|
390
|
+
const result = await runHost(request, target);
|
|
391
|
+
record({
|
|
392
|
+
at: started,
|
|
393
|
+
verb: 'run',
|
|
394
|
+
command: displayOf(target),
|
|
395
|
+
intent: 'host',
|
|
396
|
+
mode: 'host',
|
|
397
|
+
workspaceRoot: '',
|
|
398
|
+
exitCode: result.exitCode,
|
|
399
|
+
timedOut: result.timedOut,
|
|
400
|
+
stalled: result.stalled,
|
|
401
|
+
});
|
|
402
|
+
return settle(result);
|
|
403
|
+
}
|
|
404
|
+
const granted = grantFor(request.session);
|
|
405
|
+
const intent: WriteIntent = request.intent;
|
|
406
|
+
const policy = policyOf(intent, ceiling, runtimeRoot, granted);
|
|
407
|
+
const line = sandboxedLineOf(target, request.shellKind);
|
|
408
|
+
let result = await attempt(request, policy, line);
|
|
409
|
+
if (!sandboxDeniedOf(result.sandbox)) {
|
|
410
|
+
record({
|
|
411
|
+
at: now(),
|
|
412
|
+
verb: 'run',
|
|
413
|
+
command: line,
|
|
414
|
+
intent: intent.kind,
|
|
415
|
+
mode: policy.mode,
|
|
416
|
+
workspaceRoot: policy.workspaceRoot,
|
|
417
|
+
exitCode: result.exitCode,
|
|
418
|
+
timedOut: result.timedOut,
|
|
419
|
+
stalled: result.stalled,
|
|
420
|
+
});
|
|
421
|
+
return settle(result);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const path = deniedPathOf(result.stderr, result.stdout);
|
|
425
|
+
const scope = denialScopeOf(path, policy, runtimeRoot, granted);
|
|
426
|
+
const healedPolicy = request.retry === 'safe' ? healPolicyOf(intent, scope, granted) : undefined;
|
|
427
|
+
let healed = false;
|
|
428
|
+
if (healedPolicy !== undefined) {
|
|
429
|
+
const retried = await attempt(request, healedPolicy, line).catch(() => undefined);
|
|
430
|
+
if (retried !== undefined && !sandboxDeniedOf(retried.sandbox)) {
|
|
431
|
+
result = retried;
|
|
432
|
+
healed = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const denial: SeamDenial = {
|
|
436
|
+
...(path === undefined ? {} : { path }),
|
|
437
|
+
scope,
|
|
438
|
+
...(healed ? { healed: true } : {}),
|
|
439
|
+
};
|
|
440
|
+
record({
|
|
441
|
+
at: now(),
|
|
442
|
+
verb: 'run',
|
|
443
|
+
command: line,
|
|
444
|
+
intent: intent.kind,
|
|
445
|
+
mode: policy.mode,
|
|
446
|
+
workspaceRoot: policy.workspaceRoot,
|
|
447
|
+
exitCode: result.exitCode,
|
|
448
|
+
timedOut: result.timedOut,
|
|
449
|
+
stalled: result.stalled,
|
|
450
|
+
denial,
|
|
451
|
+
});
|
|
452
|
+
if (healed) return settle(result, denial);
|
|
453
|
+
if (request.bestEffort === true) {
|
|
454
|
+
ports.logger?.warn?.(`[dsh-workbench] ${denialMessageOf(scope, path, `${result.stderr}\n${result.stdout}`)}`);
|
|
455
|
+
return settle(result, denial);
|
|
456
|
+
}
|
|
457
|
+
throw new SeamError('sandbox-denied', denialMessageOf(scope, path, `${result.stderr}\n${result.stdout}`), {
|
|
458
|
+
...(path === undefined ? {} : { path }),
|
|
459
|
+
scope,
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
spawn(request: SpawnRequest): SpawnHandle {
|
|
463
|
+
requireHost(request.intent);
|
|
464
|
+
return spawnHost(request);
|
|
465
|
+
},
|
|
466
|
+
open(request: OpenRequest): ChannelHandle {
|
|
467
|
+
requireHost(request.intent);
|
|
468
|
+
return openHost(request);
|
|
469
|
+
},
|
|
470
|
+
facts: () => facts,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function sealedPolicyOf(policy: SeamPolicy, ceiling: SeamMode): SeamPolicy {
|
|
475
|
+
return { mode: narrowerMode(ceiling, policy.mode), workspaceRoot: policy.workspaceRoot };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export { pathsPolicy, policyOf, productPolicy, workspacePolicy };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { seamUnitEnv } from './env.js';
|
|
4
|
+
import { SeamError, type UnitSeamRequest, type UnitWriteScope } from './intent.js';
|
|
5
|
+
import type { Seam, SeamOutcome } from './run.js';
|
|
6
|
+
|
|
7
|
+
export interface UnitSeamPorts {
|
|
8
|
+
readonly seam: Seam;
|
|
9
|
+
readonly unit: string;
|
|
10
|
+
readonly runtimeRoot: string;
|
|
11
|
+
readonly declared?: readonly UnitWriteScope[] | undefined;
|
|
12
|
+
readonly realHome?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UnitSeam {
|
|
16
|
+
run(request: UnitSeamRequest): Promise<SeamOutcome>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unitDataRoot(runtimeRoot: string, unit: string): string {
|
|
20
|
+
return join(runtimeRoot, 'units', unit);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createUnitSeam(ports: UnitSeamPorts): UnitSeam {
|
|
24
|
+
const unitRoot = unitDataRoot(ports.runtimeRoot, ports.unit);
|
|
25
|
+
mkdirSync(unitRoot, { recursive: true });
|
|
26
|
+
const declared = new Set<UnitWriteScope>(ports.declared ?? []);
|
|
27
|
+
const env = seamUnitEnv({ runtimeRoot: ports.runtimeRoot, unitRoot, realHome: ports.realHome });
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async run(request: UnitSeamRequest): Promise<SeamOutcome> {
|
|
31
|
+
const common = {
|
|
32
|
+
command: request.command,
|
|
33
|
+
...(request.workdir === undefined ? {} : { workdir: request.workdir }),
|
|
34
|
+
...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
|
|
35
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
36
|
+
...(request.retry === undefined ? {} : { retry: request.retry }),
|
|
37
|
+
};
|
|
38
|
+
if (request.scope === 'unit') {
|
|
39
|
+
return ports.seam.run({
|
|
40
|
+
...common,
|
|
41
|
+
intent: { kind: 'product' },
|
|
42
|
+
env: { ...env, ...(request.env ?? {}) },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (!declared.has('workspace')) {
|
|
46
|
+
throw new SeamError(
|
|
47
|
+
'scope-not-declared',
|
|
48
|
+
`${ports.unit} 的清单没有声明 host.writes 里的 workspace:要写当前工作区,先在清单里声明`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (request.session === undefined || request.session === null) {
|
|
52
|
+
throw new SeamError('workspace-unknown', `${ports.unit} 请求写工作区,但这次调用没有会话`);
|
|
53
|
+
}
|
|
54
|
+
return ports.seam.run({
|
|
55
|
+
...common,
|
|
56
|
+
intent: { kind: 'workspace' },
|
|
57
|
+
session: request.session,
|
|
58
|
+
env: { ...(request.env ?? {}) },
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ShellKind } from './shell-text.js';
|
|
2
|
+
import { quoteShellArgFor } from './shell-text.js';
|
|
3
|
+
|
|
4
|
+
export interface ShellInvocation {
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const QUOTED_HEAD = /^\s*['"]/;
|
|
10
|
+
|
|
11
|
+
export function callOperatorOf(kind: ShellKind, script: string): string {
|
|
12
|
+
if (kind !== 'pwsh' || !QUOTED_HEAD.test(script)) return script;
|
|
13
|
+
return `& ${script}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function invocationOf(kind: ShellKind, script: string): ShellInvocation {
|
|
17
|
+
return kind === 'pwsh'
|
|
18
|
+
? { file: 'pwsh', args: ['-NoProfile', '-NonInteractive', '-Command', callOperatorOf('pwsh', script)] }
|
|
19
|
+
: { file: 'bash', args: ['-c', script] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function quotedArgvLine(argv: readonly string[], platform: NodeJS.Platform = process.platform): string {
|
|
23
|
+
return argv.map((one) => quoteShellArgFor(one, platform)).join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function quotedLineOf(invocation: ShellInvocation, platform: NodeJS.Platform = process.platform): string {
|
|
27
|
+
return quotedArgvLine([invocation.file, ...invocation.args], platform);
|
|
28
|
+
}
|
package/src/host/shell.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { DshShell } from '../api/dsh.js';
|
|
2
2
|
import { GIT_TIMEOUT_MS } from '../config/constants.js';
|
|
3
3
|
|
|
4
|
+
export interface ShellSandboxFacts {
|
|
5
|
+
readonly mode?: string | undefined;
|
|
6
|
+
readonly denied?: boolean | undefined;
|
|
7
|
+
readonly enforcement?: string | undefined;
|
|
8
|
+
readonly runnerFailed?: boolean | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
export interface ShellResult {
|
|
5
12
|
readonly exitCode: number | null;
|
|
6
13
|
readonly timedOut: boolean;
|
|
@@ -9,6 +16,7 @@ export interface ShellResult {
|
|
|
9
16
|
readonly stderr: string;
|
|
10
17
|
readonly truncated: boolean;
|
|
11
18
|
readonly stalled: boolean;
|
|
19
|
+
readonly sandbox?: ShellSandboxFacts | undefined;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -39,6 +47,11 @@ async function settleWithin<T>(work: Promise<T>, limitMs: number): Promise<T | n
|
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
export interface DshShellSandboxPolicy {
|
|
51
|
+
readonly mode: 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
52
|
+
readonly workspaceRoot: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
export async function runShell(
|
|
43
56
|
shell: DshShell,
|
|
44
57
|
command: string,
|
|
@@ -47,9 +60,10 @@ export async function runShell(
|
|
|
47
60
|
timeoutMs?: number;
|
|
48
61
|
signal?: AbortSignal;
|
|
49
62
|
env?: Readonly<Record<string, string>>;
|
|
63
|
+
sandboxPolicy?: DshShellSandboxPolicy | undefined;
|
|
50
64
|
} = {},
|
|
51
65
|
): Promise<ShellResult> {
|
|
52
|
-
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal, env } = options;
|
|
66
|
+
const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal, env, sandboxPolicy } = options;
|
|
53
67
|
|
|
54
68
|
const spec = shell.resolve({
|
|
55
69
|
command,
|
|
@@ -59,6 +73,7 @@ export async function runShell(
|
|
|
59
73
|
stdoutMaxBytes: DEFAULT_MAX_BYTES,
|
|
60
74
|
...(signal === undefined ? {} : { signal }),
|
|
61
75
|
...(env === undefined ? {} : { env }),
|
|
76
|
+
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
62
77
|
});
|
|
63
78
|
|
|
64
79
|
const settled = await settleWithin(
|
|
@@ -73,6 +88,7 @@ export async function runShell(
|
|
|
73
88
|
stderr: result.stderr.text,
|
|
74
89
|
truncated: result.stdout.truncated || result.stderr.truncated,
|
|
75
90
|
stalled: false,
|
|
91
|
+
...(result.sandbox === undefined ? {} : { sandbox: result.sandbox }),
|
|
76
92
|
};
|
|
77
93
|
})(),
|
|
78
94
|
timeoutMs + GUARD_SLACK_MS,
|
package/src/host/time.ts
CHANGED
|
@@ -20,3 +20,19 @@ export function clockOf(value: string | number | Date): string {
|
|
|
20
20
|
export function epochOf(value: string | number | Date): number {
|
|
21
21
|
return dayjs(value).valueOf();
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 当前时间(毫秒)。宿主里要「现在几点」只走这里,测试可以点住 `Date.now`。
|
|
26
|
+
* @returns 毫秒时间戳。
|
|
27
|
+
*/
|
|
28
|
+
export function now(): number {
|
|
29
|
+
return Date.now();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 当前时间的 ISO 字符串。
|
|
34
|
+
* @returns `new Date().toISOString()` 的等价物。
|
|
35
|
+
*/
|
|
36
|
+
export function nowIso(): string {
|
|
37
|
+
return new Date(now()).toISOString();
|
|
38
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { now as clockNow } from './time.js';
|
|
1
2
|
import { HANDOFF_TTL_MS } from '../config/constants.js';
|
|
2
3
|
|
|
3
4
|
export interface HandoffEntry {
|
|
@@ -24,10 +25,10 @@ export function createValueHandoff(options: { ttlMs?: number } = {}): ValueHando
|
|
|
24
25
|
const held = new Map<string, HandoffValue>();
|
|
25
26
|
|
|
26
27
|
return {
|
|
27
|
-
hand(entry, value, now =
|
|
28
|
+
hand(entry, value, now: number = clockNow()) {
|
|
28
29
|
held.set(handoffId(entry), { value, at: now });
|
|
29
30
|
},
|
|
30
|
-
take(entry, now =
|
|
31
|
+
take(entry, now: number = clockNow()) {
|
|
31
32
|
const id = handoffId(entry);
|
|
32
33
|
const hit = held.get(id);
|
|
33
34
|
if (hit === undefined) return undefined;
|