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