@dsh-workbench/shared 1.1.14-beta.1 → 1.1.14

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.
Files changed (144) hide show
  1. package/README.md +1 -1
  2. package/dist/api/dsh.d.ts +12 -0
  3. package/dist/components/SelectFieldWheel.d.ts +9 -0
  4. package/dist/components/SelectFieldWheel.js +35 -0
  5. package/dist/config/constants.d.ts +5 -1
  6. package/dist/config/constants.js +5 -1
  7. package/dist/contract/unit-declaration.js +15 -1
  8. package/dist/contract/unit.d.ts +3 -0
  9. package/dist/host/__scratch-guard.d.ts +1 -0
  10. package/dist/host/__scratch-guard.js +1 -0
  11. package/dist/host/adapter-set.js +0 -1
  12. package/dist/host/changelog.js +2 -1
  13. package/dist/host/channel-plugin.d.ts +15 -0
  14. package/dist/host/channel-plugin.js +42 -0
  15. package/dist/host/command-name.d.ts +1 -0
  16. package/dist/host/command-name.js +14 -0
  17. package/dist/host/config-sync.d.ts +1 -9
  18. package/dist/host/config-sync.js +3 -78
  19. package/dist/host/env-policy.d.ts +75 -0
  20. package/dist/host/env-policy.js +161 -0
  21. package/dist/host/extension.js +2 -1
  22. package/dist/host/flow/run.js +2 -1
  23. package/dist/host/fs-write.d.ts +62 -0
  24. package/dist/host/fs-write.js +112 -0
  25. package/dist/host/host-kit.d.ts +6 -2
  26. package/dist/host/host-kit.js +3 -1
  27. package/dist/host/http.js +11 -5
  28. package/dist/host/installed-sources.js +4 -5
  29. package/dist/host/long-task.js +3 -3
  30. package/dist/host/opencli-runner.d.ts +26 -0
  31. package/dist/host/opencli-runner.js +50 -0
  32. package/dist/host/package-content.d.ts +2 -2
  33. package/dist/host/package-content.js +20 -6
  34. package/dist/host/package-route.d.ts +3 -3
  35. package/dist/host/package-route.js +7 -6
  36. package/dist/host/panel-state.js +2 -1
  37. package/dist/host/patch-blocks.d.ts +13 -0
  38. package/dist/host/patch-blocks.js +52 -0
  39. package/dist/host/patch.d.ts +3 -8
  40. package/dist/host/patch.js +5 -76
  41. package/dist/host/plugin-source.d.ts +33 -0
  42. package/dist/host/plugin-source.js +91 -0
  43. package/dist/host/port-types.d.ts +9 -9
  44. package/dist/host/profile-registries.js +2 -3
  45. package/dist/host/registry-declaration.d.ts +2 -2
  46. package/dist/host/registry-declaration.js +14 -5
  47. package/dist/host/release-probe.d.ts +4 -4
  48. package/dist/host/release-probe.js +39 -14
  49. package/dist/host/runner.d.ts +26 -0
  50. package/dist/host/runner.js +50 -0
  51. package/dist/host/runtime-links.d.ts +7 -0
  52. package/dist/host/runtime-links.js +25 -0
  53. package/dist/host/seam/denial.d.ts +4 -0
  54. package/dist/host/seam/denial.js +24 -0
  55. package/dist/host/seam/env.d.ts +15 -0
  56. package/dist/host/seam/env.js +17 -0
  57. package/dist/host/seam/exec.d.ts +84 -0
  58. package/dist/host/seam/exec.js +1 -0
  59. package/dist/host/seam/host-env.d.ts +2 -0
  60. package/dist/host/seam/host-env.js +18 -0
  61. package/dist/host/seam/intent.d.ts +66 -0
  62. package/dist/host/seam/intent.js +24 -0
  63. package/dist/host/seam/policy.d.ts +10 -0
  64. package/dist/host/seam/policy.js +85 -0
  65. package/dist/host/seam/process.d.ts +2 -0
  66. package/dist/host/seam/process.js +80 -0
  67. package/dist/host/seam/run.d.ts +44 -0
  68. package/dist/host/seam/run.js +421 -0
  69. package/dist/host/seam/service.d.ts +4 -0
  70. package/dist/host/seam/service.js +4 -0
  71. package/dist/host/seam/unit-seam.d.ts +14 -0
  72. package/dist/host/seam/unit-seam.js +43 -0
  73. package/dist/host/shell.d.ts +12 -0
  74. package/dist/host/shell.js +3 -1
  75. package/dist/host/time.d.ts +10 -0
  76. package/dist/host/time.js +14 -0
  77. package/dist/host/value-handoff.js +3 -2
  78. package/dist/host/workspace-store.js +2 -8
  79. package/dist/host.d.ts +11 -0
  80. package/dist/host.js +11 -0
  81. package/dist/i18n/en.js +3 -3
  82. package/dist/i18n/index.d.ts +4 -4
  83. package/dist/i18n/zh.d.ts +3 -3
  84. package/dist/i18n/zh.js +3 -3
  85. package/dist/lib/version.d.ts +4 -0
  86. package/dist/lib/version.js +21 -0
  87. package/dist/testing/fake-adapter-set.js +0 -1
  88. package/dist/testing/git.d.ts +3 -0
  89. package/dist/testing/git.js +36 -0
  90. package/dist/testing/host-fakes.d.ts +30 -0
  91. package/dist/testing/host-fakes.js +104 -0
  92. package/package.json +5 -2
  93. package/src/api/dsh.ts +8 -1
  94. package/src/config/__tests__/constants.test.ts +10 -0
  95. package/src/config/constants.ts +7 -1
  96. package/src/contract/unit-declaration.ts +20 -1
  97. package/src/contract/unit.ts +3 -0
  98. package/src/host/__tests__/env-policy.test.ts +114 -0
  99. package/src/host/__tests__/fs-write.test.ts +96 -0
  100. package/src/host/__tests__/preflight-route.test.ts +29 -0
  101. package/src/host/__tests__/seam.test.ts +296 -0
  102. package/src/host/__tests__/shell.test.ts +24 -0
  103. package/src/host/__tests__/time.test.ts +20 -0
  104. package/src/host/adapter-set.ts +0 -1
  105. package/src/host/changelog.ts +2 -1
  106. package/src/host/config-sync.ts +3 -90
  107. package/src/host/env-policy.ts +192 -0
  108. package/src/host/extension.ts +2 -1
  109. package/src/host/flow/run.ts +2 -1
  110. package/src/host/fs-write.ts +137 -0
  111. package/src/host/host-kit.ts +7 -2
  112. package/src/host/http.ts +11 -5
  113. package/src/host/installed-sources.ts +4 -5
  114. package/src/host/long-task.ts +3 -3
  115. package/src/host/package-content.ts +23 -13
  116. package/src/host/package-route.ts +10 -9
  117. package/src/host/panel-state.ts +2 -1
  118. package/src/host/patch-blocks.ts +67 -0
  119. package/src/host/patch.ts +5 -89
  120. package/src/host/port-types.ts +9 -9
  121. package/src/host/profile-registries.ts +2 -3
  122. package/src/host/registry-declaration.ts +17 -12
  123. package/src/host/release-probe.ts +47 -34
  124. package/src/host/seam/denial.ts +26 -0
  125. package/src/host/seam/env.ts +23 -0
  126. package/src/host/seam/exec.ts +94 -0
  127. package/src/host/seam/host-env.ts +20 -0
  128. package/src/host/seam/intent.ts +101 -0
  129. package/src/host/seam/policy.ts +89 -0
  130. package/src/host/seam/process.ts +90 -0
  131. package/src/host/seam/run.ts +484 -0
  132. package/src/host/seam/service.ts +8 -0
  133. package/src/host/seam/unit-seam.ts +62 -0
  134. package/src/host/shell.ts +17 -1
  135. package/src/host/time.ts +16 -0
  136. package/src/host/value-handoff.ts +3 -2
  137. package/src/host/workspace-store.ts +2 -8
  138. package/src/host.ts +11 -0
  139. package/src/i18n/en.ts +3 -3
  140. package/src/i18n/zh.ts +3 -3
  141. package/src/testing/fake-adapter-set.ts +0 -1
  142. package/src/testing/host-fakes.ts +126 -0
  143. package/dist/lib/unit-plugin.d.ts +0 -28
  144. package/dist/lib/unit-plugin.js +0 -23
@@ -71,7 +71,9 @@ const TITLE_KEYS = ['zh', 'en'];
71
71
  const TOOL_KEYS = ['name', 'writes', 'key'];
72
72
 
73
73
  const TOOL_KEY_SHAPE = /^[a-z][a-zA-Z0-9]*$/;
74
- const HOST_KEYS = ['inject', 'optional'];
74
+ const HOST_KEYS = ['inject', 'optional', 'writes'];
75
+
76
+ const UNIT_WRITE_SCOPES: readonly string[] = ['unit', 'workspace'];
75
77
 
76
78
  function text(value: unknown): string {
77
79
  return typeof value === 'string' ? value.trim() : '';
@@ -263,6 +265,22 @@ export function declarationProblemsOf(subject: string, raw: unknown): readonly s
263
265
  if (host.optional !== undefined) {
264
266
  problems.push(...stringArrayProblems(`${subject}: dsh.workbench.host.optional`, host.optional));
265
267
  }
268
+ if (host.writes !== undefined) {
269
+ const shape = stringArrayProblems(`${subject}: dsh.workbench.host.writes`, host.writes);
270
+ problems.push(...shape);
271
+ if (shape.length === 0) {
272
+ const outsideScopes = (host.writes as readonly string[]).filter(
273
+ (scope) => !UNIT_WRITE_SCOPES.includes(scope),
274
+ );
275
+ if (outsideScopes.length > 0) {
276
+ problems.push(
277
+ `${subject}: dsh.workbench.host.writes 里有不认的写入域 ${listOf(outsideScopes)} —— ` +
278
+ `只认 "unit"(模块自己的数据目录)与 "workspace"(当前会话工作区)两档;` +
279
+ `模块拿不到产品域与任意路径,别写别的名字(缺省 [] = 不给执行缝门面)`,
280
+ );
281
+ }
282
+ }
283
+ }
266
284
  }
267
285
  }
268
286
 
@@ -308,6 +326,7 @@ export function readDeclaration(subject: string, raw: unknown): DeclarationRead
308
326
  host: {
309
327
  inject: [...(declaration.host.inject ?? [])],
310
328
  ...(declaration.host.optional === undefined ? {} : { optional: [...declaration.host.optional] }),
329
+ writes: [...(declaration.host.writes ?? [])],
311
330
  },
312
331
  ...(declaration.needs === undefined ? {} : { needs: declaration.needs }),
313
332
  },
@@ -45,10 +45,13 @@ export interface UnitTool {
45
45
 
46
46
  /**
47
47
  * 模块要外壳的哪些服务:inject 缺一个就挂不上,optional 缺了照挂。
48
+ * writes 声明模块要哪几档执行缝写入域:unit 是模块自己的数据目录,workspace 是当前会话工作区;
49
+ * 缺省 [] = 不给执行缝门面(deps.seam 是 undefined)。
48
50
  */
49
51
  export interface UnitHostNeeds {
50
52
  readonly inject: readonly string[];
51
53
  readonly optional?: readonly string[];
54
+ readonly writes?: readonly ('unit' | 'workspace')[];
52
55
  }
53
56
 
54
57
  /**
@@ -0,0 +1,114 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, delimiter } from 'node:path';
4
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
5
+ import {
6
+ bootstrapEnvOf,
7
+ envTextOf,
8
+ inheritedEnvOf,
9
+ overlayEnvOf,
10
+ pathWithBinsOf,
11
+ productEnvOf,
12
+ promptlessGitEnvOf,
13
+ promptlessProcessEnvOf,
14
+ unitEnvOf,
15
+ } from '../env-policy.js';
16
+
17
+ const ROOT = mkdtempSync(join(tmpdir(), 'dsh-wb-env-policy-'));
18
+ const REAL_HOME = join(ROOT, 'real-home');
19
+ const RUNTIME = join(ROOT, 'workbench');
20
+ const DSH_HOME = process.env.DSH_HOME;
21
+
22
+ beforeAll(() => {
23
+ mkdirSync(REAL_HOME, { recursive: true });
24
+ writeFileSync(join(REAL_HOME, '.npmrc'), 'registry=https://example.test\n');
25
+ writeFileSync(join(REAL_HOME, '.gitconfig'), '[user]\n');
26
+ process.env.DSH_HOME = join(ROOT, 'dsh');
27
+ });
28
+
29
+ afterEach(() => {
30
+ if (DSH_HOME === undefined) delete process.env.DSH_HOME;
31
+ else process.env.DSH_HOME = DSH_HOME;
32
+ });
33
+
34
+ afterAll(() => {
35
+ rmSync(ROOT, { recursive: true, force: true });
36
+ });
37
+
38
+ describe('受控 env 入口', () => {
39
+ it('product:HOME / 缓存全钉在运行时根,真 npmrc/gitconfig 只读指过去', () => {
40
+ const env = productEnvOf({ runtimeRoot: RUNTIME, realHome: REAL_HOME });
41
+ expect(env['HOME']).toBe(join(RUNTIME, 'home'));
42
+ expect(env['DSH_WORKBENCH_HOME']).toBe(RUNTIME);
43
+ expect(env['DSH_HOME']).toBe(join(ROOT, 'dsh'));
44
+ expect(env['npm_config_cache']).toBe(join(RUNTIME, 'npm-cache'));
45
+ expect(env['NPM_CONFIG_CACHE']).toBe(join(RUNTIME, 'npm-cache'));
46
+ expect(env['npm_config_store_dir']).toBe(join(RUNTIME, 'pnpm-store'));
47
+ expect(env['NPM_CONFIG_USERCONFIG']).toBe(join(REAL_HOME, '.npmrc'));
48
+ expect(env['GIT_CONFIG_GLOBAL']).toBe(join(REAL_HOME, '.gitconfig'));
49
+ expect(env['NO_UPDATE_NOTIFIER']).toBe('1');
50
+ });
51
+
52
+ it('product:没有真配置时不指过去', () => {
53
+ const emptyHome = join(ROOT, 'empty-home');
54
+ mkdirSync(emptyHome, { recursive: true });
55
+ const env = productEnvOf({ runtimeRoot: RUNTIME, realHome: emptyHome });
56
+ expect(env['NPM_CONFIG_USERCONFIG']).toBeUndefined();
57
+ expect(env['GIT_CONFIG_GLOBAL']).toBeUndefined();
58
+ });
59
+
60
+ it('unit:HOME 与 npm/pnpm 目录钉到单元数据目录', () => {
61
+ const unitRoot = join(RUNTIME, 'units', 'demo');
62
+ const env = unitEnvOf({ runtimeRoot: RUNTIME, unitRoot, realHome: REAL_HOME });
63
+ expect(env['HOME']).toBe(join(unitRoot, 'home'));
64
+ expect(env['npm_config_cache']).toBe(join(unitRoot, 'npm-cache'));
65
+ expect(env['npm_config_store_dir']).toBe(join(unitRoot, 'pnpm-store'));
66
+ expect(env['DSH_WORKBENCH_HOME']).toBe(RUNTIME);
67
+ });
68
+
69
+ it('bootstrap:继承父环境、HOME/缓存钉住、PATH 可替换、pwsh 加 USERPROFILE', () => {
70
+ const parent = { PATH: '/usr/bin', KEEP: 'yes' };
71
+ const env = bootstrapEnvOf({
72
+ root: join(RUNTIME, 'opencli'),
73
+ realHome: REAL_HOME,
74
+ shellKind: 'posix',
75
+ parent,
76
+ path: '/desktop/bin' + delimiter + '/usr/bin',
77
+ });
78
+ expect(env['KEEP']).toBe('yes');
79
+ expect(env['PATH']).toBe('/desktop/bin' + delimiter + '/usr/bin');
80
+ expect(env['HOME']).toBe(join(RUNTIME, 'opencli', 'home'));
81
+ expect(env['npm_config_cache']).toBe(join(RUNTIME, 'opencli', 'npm-cache'));
82
+ expect(env['NPM_CONFIG_USERCONFIG']).toBe(join(REAL_HOME, '.npmrc'));
83
+ expect(env['NO_COLOR']).toBe('1');
84
+ expect(env['TERM']).toBe('dumb');
85
+ expect(env['USERPROFILE']).toBeUndefined();
86
+ const pwsh = bootstrapEnvOf({ root: join(RUNTIME, 'opencli'), realHome: REAL_HOME, shellKind: 'pwsh', parent });
87
+ expect(pwsh['USERPROFILE']).toBe(join(RUNTIME, 'opencli', 'home'));
88
+ });
89
+
90
+ it('promptless:posix 关 askpass 指向 false,win32 删掉父进程的 askpass', () => {
91
+ const posix = promptlessGitEnvOf('darwin');
92
+ expect(posix['GIT_TERMINAL_PROMPT']).toBe('0');
93
+ expect(posix['GIT_ASKPASS']).toBe('/usr/bin/false');
94
+ const win = promptlessGitEnvOf('win32');
95
+ expect(win).not.toHaveProperty('GIT_ASKPASS');
96
+ const parent = { GIT_ASKPASS: 'C:/evil.exe', KEEP: '1' };
97
+ const merged = promptlessProcessEnvOf(parent, 'win32');
98
+ expect(merged['GIT_ASKPASS']).toBeUndefined();
99
+ expect(merged['KEEP']).toBe('1');
100
+ expect(merged['GIT_TERMINAL_PROMPT']).toBe('0');
101
+ });
102
+
103
+ it('叠层:undefined 删键,envTextOf 丢掉非字符串', () => {
104
+ const merged = overlayEnvOf({ A: '1', B: '2' }, { B: undefined, C: '3' });
105
+ expect(merged).toEqual({ A: '1', C: '3' });
106
+ expect(inheritedEnvOf({ A: '1' })).toEqual({ A: '1' });
107
+ expect(envTextOf({ A: '1', B: undefined })).toEqual({ A: '1' });
108
+ });
109
+
110
+ it('PATH 前插去重', () => {
111
+ expect(pathWithBinsOf(['/a', '/b'].join(delimiter), ['/a', '/c'])).toBe(['/a', '/c', '/b'].join(delimiter));
112
+ expect(pathWithBinsOf(undefined, ['/x'])).toBe('/x');
113
+ });
114
+ });
@@ -0,0 +1,96 @@
1
+ import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll, describe, expect, it } from 'vitest';
5
+ import {
6
+ ensureDir,
7
+ makeExecutable,
8
+ writeBytesFile,
9
+ writeBytesFileAsync,
10
+ writeJsonFile,
11
+ writeJsonFileAsync,
12
+ writeTextFile,
13
+ writeTextFileAsync,
14
+ } from '../fs-write.js';
15
+
16
+ const ROOT = mkdtempSync(join(tmpdir(), 'dsh-wb-fs-write-'));
17
+
18
+ afterAll(() => {
19
+ rmSync(ROOT, { recursive: true, force: true });
20
+ });
21
+
22
+ describe('宿主文件写入口', () => {
23
+ it('写文本:建父目录、原子落盘、内容正确', () => {
24
+ const file = join(ROOT, 'a', 'b', 'note.txt');
25
+ writeTextFile(file, 'hello');
26
+ expect(readFileSync(file, 'utf8')).toBe('hello');
27
+ expect(readdirSync(join(ROOT, 'a', 'b'))).toEqual(['note.txt']);
28
+ });
29
+
30
+ it('mode 默认保留已有文件的权限位', () => {
31
+ const file = join(ROOT, 'secret.txt');
32
+ writeFileSync(file, 'old', { mode: 0o600 });
33
+ writeTextFile(file, 'new');
34
+ expect(statSync(file).mode & 0o777).toBe(0o600);
35
+ });
36
+
37
+ it('显式 mode 生效,可执行文件 0755', () => {
38
+ const secret = join(ROOT, 'explicit.txt');
39
+ writeTextFile(secret, 's', { mode: 0o600 });
40
+ expect(statSync(secret).mode & 0o777).toBe(0o600);
41
+ const script = join(ROOT, 'bin', 'run.sh');
42
+ writeTextFile(script, '#!/bin/sh\n', { mode: 0o755 });
43
+ expect(statSync(script).mode & 0o777).toBe(0o755);
44
+ const plain = join(ROOT, 'plain.sh');
45
+ writeTextFile(plain, '#!/bin/sh\n');
46
+ makeExecutable(plain);
47
+ expect(statSync(plain).mode & 0o777).toBe(0o755);
48
+ });
49
+
50
+ it('JSON:两空格缩进 + 末尾换行', () => {
51
+ const file = join(ROOT, 'state.json');
52
+ writeJsonFile(file, { a: 1, list: [2] });
53
+ expect(readFileSync(file, 'utf8')).toBe('{\n "a": 1,\n "list": [\n 2\n ]\n}\n');
54
+ });
55
+
56
+ it('atomic:false 直接写,不建父目录', () => {
57
+ const file = join(ROOT, 'direct.txt');
58
+ writeTextFile(file, 'x', { atomic: false });
59
+ expect(readFileSync(file, 'utf8')).toBe('x');
60
+ expect(() => writeTextFile(join(ROOT, 'missing', 'deep.txt'), 'x', { atomic: false, mkdir: false })).toThrow();
61
+ });
62
+
63
+ it('二进制写:字节原样落盘', () => {
64
+ const file = join(ROOT, 'img.bin');
65
+ writeBytesFile(file, new Uint8Array([0, 255, 7]));
66
+ expect([...readFileSync(file)]).toEqual([0, 255, 7]);
67
+ });
68
+
69
+ it('异步版语义一致', async () => {
70
+ const file = join(ROOT, 'async.json');
71
+ await writeJsonFileAsync(file, { ok: true });
72
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ ok: true });
73
+ const text = join(ROOT, 'async.txt');
74
+ await writeTextFileAsync(text, 'a');
75
+ expect(readFileSync(text, 'utf8')).toBe('a');
76
+ const bin = join(ROOT, 'async.bin');
77
+ await writeBytesFileAsync(bin, new Uint8Array([1, 2]));
78
+ expect([...readFileSync(bin)]).toEqual([1, 2]);
79
+ });
80
+
81
+ it('并发写同一文件:结果完整,不残留临时文件', async () => {
82
+ const file = join(ROOT, 'race.txt');
83
+ await Promise.all(Array.from({ length: 12 }, (_, i) => writeTextFileAsync(file, `v${String(i)}`)));
84
+ expect(readFileSync(file, 'utf8')).toMatch(/^v\d+$/);
85
+ expect(readdirSync(ROOT).filter((name) => name.startsWith('race.txt'))).toEqual(['race.txt']);
86
+ });
87
+
88
+ it('ensureDir 幂等', () => {
89
+ const dir = join(ROOT, 'x', 'y');
90
+ ensureDir(dir);
91
+ ensureDir(dir);
92
+ writeFileSync(join(dir, 'f'), '', 'utf8');
93
+ chmodSync(join(dir, 'f'), 0o644);
94
+ expect(readdirSync(dir)).toEqual(['f']);
95
+ });
96
+ });
@@ -30,4 +30,33 @@ describe('preflightHandler(体检那一拍带什么参数)', () => {
30
30
 
31
31
  expect(handler(new URLSearchParams(''))).toEqual({ ok: true, ...SNAPSHOT });
32
32
  });
33
+
34
+ it('★ 先回上一轮的结果再起新一轮:run 一上来就把快照重置成 checking,也不许把它顶掉', () => {
35
+ let current = { phase: 'failed', steps: [], at: 1 };
36
+ const run = vi.fn(async () => {
37
+ current = { phase: 'checking', steps: [], at: 2 };
38
+ return current;
39
+ });
40
+ const handler = preflightHandler({ run, snapshot: () => current });
41
+
42
+ const reply = handler(new URLSearchParams(''));
43
+
44
+ expect(reply).toEqual({ ok: true, phase: 'failed', steps: [], at: 1 });
45
+ expect(run).toHaveBeenCalledTimes(1);
46
+ expect(current.phase).toBe('checking');
47
+ });
48
+
49
+ it('★ 重试(hard)回的是新一轮的进度:别把上一轮的 failed 又顶回画面', () => {
50
+ let current = { phase: 'failed', steps: [], at: 1 };
51
+ const run = vi.fn(async () => {
52
+ current = { phase: 'checking', steps: [], at: 2 };
53
+ return current;
54
+ });
55
+ const handler = preflightHandler({ run, snapshot: () => current });
56
+
57
+ const reply = handler(new URLSearchParams('hard=1'));
58
+
59
+ expect(reply).toEqual({ ok: true, phase: 'checking', steps: [], at: 2 });
60
+ expect(run).toHaveBeenCalledWith({ hard: true });
61
+ });
33
62
  });
@@ -0,0 +1,296 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterAll, describe, expect, it } from 'vitest';
5
+ import { SeamError } from '../seam/intent.js';
6
+ import { createSeam } from '../seam/run.js';
7
+ import { createUnitSeam, unitDataRoot } from '../seam/unit-seam.js';
8
+ import { createFakeProcesses, createFakeShell, createFakeLogger } from '../../testing/host-fakes.js';
9
+
10
+ const ROOT = join(tmpdir(), 'dsh-wb-seam-root');
11
+ const PROJECT = join(tmpdir(), 'dsh-wb-seam-project');
12
+ const SCRATCH = mkdtempSync(join(tmpdir(), 'dsh-wb-seam-'));
13
+ const REAL_HOME = join(SCRATCH, 'home');
14
+
15
+ afterAll(() => {
16
+ rmSync(SCRATCH, { recursive: true, force: true });
17
+ });
18
+
19
+ function policyOf(call: { sandboxPolicy?: unknown } | undefined): unknown {
20
+ return call?.sandboxPolicy;
21
+ }
22
+
23
+ describe('createSeam', () => {
24
+ it('产品域:固定根 + 注入产品环境', async () => {
25
+ const fake = createFakeShell([{}]);
26
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT, realHome: REAL_HOME });
27
+ const outcome = await seam.run({ command: 'npm view left-pad version', intent: { kind: 'product' } });
28
+ expect(outcome.exitCode).toBe(0);
29
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: ROOT });
30
+ const env = fake.calls[0]?.env;
31
+ expect(env?.['HOME']).toBe(join(ROOT, 'home'));
32
+ expect(env?.['npm_config_cache']).toBe(join(ROOT, 'npm-cache'));
33
+ expect(env?.['DSH_WORKBENCH_HOME']).toBe(ROOT);
34
+ expect(env?.['npm_config_state_dir']).toBe(join(ROOT, 'pnpm-state'));
35
+ });
36
+
37
+ it('产品域不跟会话只读,只跟部署上限', async () => {
38
+ const fake = createFakeShell([{}]);
39
+ const seam = createSeam({
40
+ shell: fake.shell,
41
+ runtimeRoot: ROOT,
42
+ ceiling: 'workspace-write',
43
+ grantOf: () => ({ mode: 'read-only', workspaceRoot: PROJECT }),
44
+ });
45
+ await seam.run({ command: 'npm view x version', intent: { kind: 'product' } });
46
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: ROOT });
47
+ });
48
+
49
+ it('工作区域:取会话解析结果;上限更宽时按会话', async () => {
50
+ const fake = createFakeShell([{}]);
51
+ const seam = createSeam({
52
+ shell: fake.shell,
53
+ runtimeRoot: ROOT,
54
+ ceiling: 'danger-full-access',
55
+ grantOf: () => ({ mode: 'danger-full-access', workspaceRoot: PROJECT }),
56
+ });
57
+ await seam.run({ command: 'pnpm install', intent: { kind: 'workspace' }, session: {} });
58
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'danger-full-access', workspaceRoot: PROJECT });
59
+ });
60
+
61
+ it('会话只读:起进程前就拒绝,不起进程', async () => {
62
+ const fake = createFakeShell([{}]);
63
+ const seam = createSeam({
64
+ shell: fake.shell,
65
+ runtimeRoot: ROOT,
66
+ grantOf: () => ({ mode: 'read-only', workspaceRoot: PROJECT }),
67
+ });
68
+ await expect(seam.run({ command: 'pnpm install', intent: { kind: 'workspace' }, session: {} })).rejects.toMatchObject({
69
+ code: 'read-only',
70
+ });
71
+ expect(fake.calls).toHaveLength(0);
72
+ });
73
+
74
+ it('没有会话却要写工作区:起进程前报 workspace-unknown', async () => {
75
+ const fake = createFakeShell([{}]);
76
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
77
+ await expect(seam.run({ command: 'pnpm install', intent: { kind: 'workspace' } })).rejects.toMatchObject({
78
+ code: 'workspace-unknown',
79
+ });
80
+ expect(fake.calls).toHaveLength(0);
81
+ });
82
+
83
+ it('外部路径:同一目录可以,跨目录拒绝', async () => {
84
+ const fake = createFakeShell([{}]);
85
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
86
+ await seam.run({ command: 'png', intent: { kind: 'paths', paths: [join(SCRATCH, 'a.png'), join(SCRATCH, 'b.png')] } });
87
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: SCRATCH });
88
+ await expect(
89
+ seam.run({ command: 'png', intent: { kind: 'paths', paths: [join(SCRATCH, 'a.png'), '/etc/hosts'] } }),
90
+ ).rejects.toMatchObject({ code: 'paths-span-trees' });
91
+ expect(fake.calls).toHaveLength(1);
92
+ });
93
+
94
+ it('安全重试:产品域被拒后按产品域重跑一次并标记 healed', async () => {
95
+ const fake = createFakeShell([
96
+ {
97
+ exitCode: 1,
98
+ stderr: "npm error code EPERM\nnpm error path " + join(ROOT, 'npm-cache', '_cacache') + "\n",
99
+ sandbox: { mode: 'workspace-write', denied: true },
100
+ },
101
+ {},
102
+ ]);
103
+ const seam = createSeam({
104
+ shell: fake.shell,
105
+ runtimeRoot: ROOT,
106
+ grantOf: () => ({ mode: 'workspace-write', workspaceRoot: PROJECT }),
107
+ });
108
+ const outcome = await seam.run({
109
+ command: 'npm publish',
110
+ intent: { kind: 'workspace' },
111
+ session: {},
112
+ retry: 'safe',
113
+ });
114
+ expect(fake.calls).toHaveLength(2);
115
+ expect(policyOf(fake.calls[1])).toEqual({ mode: 'workspace-write', workspaceRoot: ROOT });
116
+ expect(outcome.denial?.healed).toBe(true);
117
+ expect(outcome.denial?.scope).toBe('product');
118
+ });
119
+
120
+ it('写命令默认不自动重试:denial 直接给类型化错误', async () => {
121
+ const fake = createFakeShell([
122
+ {
123
+ exitCode: 1,
124
+ stderr: "EPERM: operation not permitted, mkdir '" + join(ROOT, 'npm-cache') + "'\n",
125
+ sandbox: { mode: 'workspace-write', denied: true },
126
+ },
127
+ ]);
128
+ const seam = createSeam({
129
+ shell: fake.shell,
130
+ runtimeRoot: ROOT,
131
+ grantOf: () => ({ mode: 'workspace-write', workspaceRoot: PROJECT }),
132
+ });
133
+ await expect(
134
+ seam.run({ command: 'git commit', intent: { kind: 'workspace' }, session: {} }),
135
+ ).rejects.toMatchObject({ code: 'sandbox-denied' });
136
+ expect(fake.calls).toHaveLength(1);
137
+ });
138
+
139
+ it('外部路径被拒:bestEffort 返回带 denial 的结果,否则抛类型化错误', async () => {
140
+ const external = join(tmpdir(), 'dsh-wb-seam-external', 'shot.png');
141
+ const denialReply = {
142
+ exitCode: 1,
143
+ stderr: "EPERM: operation not permitted, open '" + external + "'\n",
144
+ sandbox: { mode: 'workspace-write', denied: true },
145
+ };
146
+ const best = createFakeShell([denialReply]);
147
+ const seamBest = createSeam({ shell: best.shell, runtimeRoot: ROOT, logger: createFakeLogger().logger });
148
+ const outcome = await seamBest.run({ command: 'png', intent: { kind: 'product' }, bestEffort: true });
149
+ expect(outcome.denial?.scope).toBe('external');
150
+ expect(outcome.denial?.path).toBe(external);
151
+
152
+ const strict = createFakeShell([denialReply]);
153
+ const seamStrict = createSeam({ shell: strict.shell, runtimeRoot: ROOT });
154
+ await expect(seamStrict.run({ command: 'png', intent: { kind: 'product' } })).rejects.toBeInstanceOf(SeamError);
155
+ });
156
+
157
+ it('每次调用都记账', async () => {
158
+ const fake = createFakeShell([{}, {}]);
159
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
160
+ await seam.run({ command: 'git status', intent: { kind: 'none' } });
161
+ await seam.run({ command: 'npm view x version', intent: { kind: 'product' } });
162
+ expect(seam.facts()).toHaveLength(2);
163
+ expect(seam.facts()[0]).toMatchObject({ command: 'git status', intent: 'none', mode: 'workspace-write' });
164
+ expect(seam.facts()[1]).toMatchObject({ command: 'npm view x version', intent: 'product' });
165
+ });
166
+ });
167
+
168
+ describe('createUnitSeam', () => {
169
+ it('unit 域:根在产品域内,env 落在模块目录', async () => {
170
+ const fake = createFakeShell([{}]);
171
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT, realHome: REAL_HOME });
172
+ const unit = createUnitSeam({ seam, unit: 'demo', runtimeRoot: ROOT, realHome: REAL_HOME });
173
+ await unit.run({ command: 'echo hi', scope: 'unit' });
174
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: ROOT });
175
+ const env = fake.calls[0]?.env;
176
+ expect(env?.['HOME']).toBe(join(unitDataRoot(ROOT, 'demo'), 'home'));
177
+ expect(env?.['npm_config_cache']).toBe(join(unitDataRoot(ROOT, 'demo'), 'npm-cache'));
178
+ });
179
+
180
+ it('workspace 域:清单没声明就拒绝;声明后要求会话', async () => {
181
+ const fake = createFakeShell([{}, {}]);
182
+ const seam = createSeam({
183
+ shell: fake.shell,
184
+ runtimeRoot: ROOT,
185
+ grantOf: () => ({ mode: 'workspace-write', workspaceRoot: PROJECT }),
186
+ });
187
+ const undeclared = createUnitSeam({ seam, unit: 'demo', runtimeRoot: ROOT });
188
+ await expect(undeclared.run({ command: 'touch x', scope: 'workspace', session: {} })).rejects.toMatchObject({
189
+ code: 'scope-not-declared',
190
+ });
191
+ const declared = createUnitSeam({ seam, unit: 'demo', runtimeRoot: ROOT, declared: ['workspace'] });
192
+ await expect(declared.run({ command: 'touch x', scope: 'workspace' })).rejects.toMatchObject({
193
+ code: 'workspace-unknown',
194
+ });
195
+ await declared.run({ command: 'touch x', scope: 'workspace', session: {} });
196
+ expect(policyOf(fake.calls[0])).toEqual({ mode: 'workspace-write', workspaceRoot: PROJECT });
197
+ });
198
+ });
199
+
200
+ describe('宿主域:run / spawn / open', () => {
201
+ it('host 域 run:按 argv 起宿主进程,拿回输出,记 host 事实', async () => {
202
+ const fake = createFakeProcesses([{ stdout: 'ok\n', exitCode: 0 }]);
203
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, allowHost: true, processes: fake.host });
204
+ const outcome = await seam.run({
205
+ argv: ['git', '-C', PROJECT, 'status'],
206
+ intent: { kind: 'host' },
207
+ env: { GIT_TERMINAL_PROMPT: '0' },
208
+ timeoutMs: 5_000,
209
+ });
210
+ expect(outcome.exitCode).toBe(0);
211
+ expect(outcome.stdout).toBe('ok\n');
212
+ expect(fake.calls[0]?.file).toBe('git');
213
+ expect(fake.calls[0]?.args).toEqual(['-C', PROJECT, 'status']);
214
+ expect(fake.calls[0]?.env['GIT_TERMINAL_PROMPT']).toBe('0');
215
+ expect(fake.calls[0]?.detached).toBe(false);
216
+ expect(seam.facts().at(-1)).toMatchObject({ verb: 'run', intent: 'host', mode: 'host' });
217
+ });
218
+
219
+ it('host 域 run:shell line 走 bash -c,超时到点 kill', async () => {
220
+ const fake = createFakeProcesses([{ hang: true }]);
221
+ const seam = createSeam({
222
+ shell: createFakeShell([]).shell,
223
+ runtimeRoot: ROOT,
224
+ allowHost: true,
225
+ processes: fake.host,
226
+ });
227
+ const outcome = await seam.run({ command: 'pnpm install', shellKind: 'posix', intent: { kind: 'host' }, timeoutMs: 10 });
228
+ expect(fake.calls[0]?.file).toBe('bash');
229
+ expect(fake.calls[0]?.args).toEqual(['-c', 'pnpm install']);
230
+ expect(outcome.timedOut).toBe(true);
231
+ expect(fake.children[0]?.killed).toContain('SIGKILL');
232
+ });
233
+
234
+ it('没有宿主能力时 host 域起不来,也不许 spawn/open', async () => {
235
+ const fake = createFakeProcesses([{}]);
236
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, processes: fake.host });
237
+ await expect(seam.run({ argv: ['git', 'status'], intent: { kind: 'host' } })).rejects.toMatchObject({
238
+ code: 'scope-not-declared',
239
+ });
240
+ expect(() =>
241
+ seam.spawn({ target: { argv: ['node', 'x'] }, intent: { kind: 'host' }, lifetime: 'host', logFile: join(SCRATCH, 'x.log') }),
242
+ ).toThrow(SeamError);
243
+ expect(fake.calls).toHaveLength(0);
244
+ });
245
+
246
+ it('spawn:detached + 日志文件 + unref,handle 能拿到 pid 与退出', async () => {
247
+ const fake = createFakeProcesses([{ hang: true }]);
248
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, allowHost: true, processes: fake.host });
249
+ const logFile = join(SCRATCH, 'spawn.log');
250
+ const handle = seam.spawn({
251
+ target: { argv: ['node', 'seed.mjs'] },
252
+ intent: { kind: 'host' },
253
+ lifetime: 'host',
254
+ logFile,
255
+ label: 'seed',
256
+ });
257
+ expect(handle.pid).toBe(4242);
258
+ expect(handle.logFile).toBe(logFile);
259
+ expect(fake.calls[0]?.detached).toBe(true);
260
+ expect(fake.calls[0]?.stdio).toBe('log');
261
+ expect(fake.calls[0]?.logFile).toBe(logFile);
262
+ expect(fake.children[0]?.unrefed).toBe(true);
263
+ expect(seam.facts().at(-1)).toMatchObject({ verb: 'spawn', intent: 'host', mode: 'host' });
264
+ const waiting = handle.exit();
265
+ fake.children[0]?.finish(0);
266
+ await expect(waiting).resolves.toEqual({ code: 0, signal: null });
267
+ });
268
+
269
+ it('open:双向流读写与退出', async () => {
270
+ const fake = createFakeProcesses([{ hang: true }]);
271
+ const seam = createSeam({ shell: createFakeShell([]).shell, runtimeRoot: ROOT, allowHost: true, processes: fake.host });
272
+ const channel = seam.open({
273
+ target: { argv: ['node', 'mcp-server.mjs'] },
274
+ intent: { kind: 'host' },
275
+ env: { MCP_TOKEN: 't' },
276
+ });
277
+ const lines: string[] = [];
278
+ channel.onData((chunk) => lines.push(chunk));
279
+ channel.write('hello\n');
280
+ fake.children[0]?.emitStdout('world\n');
281
+ expect(lines).toEqual(['world\n']);
282
+ expect(fake.children[0]?.writes).toEqual(['hello\n']);
283
+ expect(fake.calls[0]?.env['MCP_TOKEN']).toBe('t');
284
+ const exited = new Promise<unknown>((resolve) => channel.onExit((event) => resolve(event)));
285
+ fake.children[0]?.finish(3);
286
+ await expect(exited).resolves.toEqual({ code: 3, signal: null });
287
+ expect(seam.facts().at(-1)).toMatchObject({ verb: 'open', intent: 'host' });
288
+ });
289
+
290
+ it('沙箱域也能用 argv:内部拼成命令行交给 dsh shell', async () => {
291
+ const fake = createFakeShell([{}]);
292
+ const seam = createSeam({ shell: fake.shell, runtimeRoot: ROOT });
293
+ await seam.run({ argv: ['npm', 'view', 'left-pad', 'version'], intent: { kind: 'product' } });
294
+ expect(fake.calls[0]?.command).toBe("'npm' 'view' 'left-pad' 'version'");
295
+ });
296
+ });
@@ -24,7 +24,31 @@ function replyingShell(exitCode: number, timedOut = false): DshShell {
24
24
  };
25
25
  }
26
26
 
27
+ function sandboxedShell(): DshShell {
28
+ return {
29
+ resolve: (request) => request,
30
+ execute: async () => ({
31
+ result: async () => ({
32
+ exitCode: 3_221_225_794,
33
+ timedOut: false,
34
+ aborted: false,
35
+ stdout: { text: '', truncated: false },
36
+ stderr: { text: 'windows-acl-run: token setup failed\n', truncated: false },
37
+ sandbox: { mode: 'workspace-write', denied: false, runnerFailed: true },
38
+ }),
39
+ }),
40
+ };
41
+ }
42
+
27
43
  describe('runShell', () => {
44
+ it('★ 沙箱事实原样带回来:runnerFailed 不当成"命令自己退出"', async () => {
45
+ const result = await runShell(sandboxedShell(), 'echo hi');
46
+
47
+ expect(result.exitCode).toBe(3_221_225_794);
48
+ expect(result.sandbox?.runnerFailed).toBe(true);
49
+ expect(result.stderr).toContain('windows-acl-run');
50
+ });
51
+
28
52
  it('★ 宿主一直不交结果 → 到点自己收尾,不把调用方挂住', async () => {
29
53
  vi.useFakeTimers();
30
54
  try {
@@ -0,0 +1,20 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { clockOf, epochOf, now, nowIso } from '../time.js';
3
+
4
+ afterEach(() => {
5
+ vi.useRealTimers();
6
+ });
7
+
8
+ describe('宿主时间入口', () => {
9
+ it('now 读的是 Date.now,nowIso 是合法 ISO', () => {
10
+ vi.useFakeTimers();
11
+ vi.setSystemTime(new Date('2026-01-02T03:04:05.000Z'));
12
+ expect(now()).toBe(Date.parse('2026-01-02T03:04:05.000Z'));
13
+ expect(nowIso()).toBe('2026-01-02T03:04:05.000Z');
14
+ });
15
+
16
+ it('格式化助手仍只看传入值', () => {
17
+ expect(clockOf(0)).toBe('00:00:00');
18
+ expect(epochOf('1970-01-01T00:00:01.000Z')).toBe(1000);
19
+ });
20
+ });
@@ -85,7 +85,6 @@ const FULL_METHODS = [
85
85
  ['runtime', 'runners'],
86
86
  ['runtime', 'health'],
87
87
  ['runtime', 'seed'],
88
- ['runtime', 'warnUnwritableRoot'],
89
88
  ['plugins', 'declarationLookup'],
90
89
  ['plugins', 'createUnits'],
91
90
  ['actions', 'configSyncHandler'],
@@ -1,3 +1,4 @@
1
+ import { now as clockNow } from './time.js';
1
2
  import dayjs from 'dayjs';
2
3
 
3
4
  export const CHANGELOG_FILE = 'CHANGELOG.md';
@@ -37,7 +38,7 @@ export function changelogVersion(value: string): string {
37
38
  .replace(/^v/, '');
38
39
  }
39
40
 
40
- export function changelogDate(now: Date = new Date()): string {
41
+ export function changelogDate(now: Date = new Date(clockNow())): string {
41
42
  return dayjs(now).format('YYYY-MM-DD');
42
43
  }
43
44