agent-gear 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +74 -3
  2. package/dist/cloud-host/supervisor.d.ts +92 -0
  3. package/dist/cloud-host/supervisor.d.ts.map +1 -0
  4. package/dist/cloud-host/supervisor.js +216 -0
  5. package/dist/cloud-host/supervisor.js.map +1 -0
  6. package/dist/cloud-host/supervisor.test.d.ts +2 -0
  7. package/dist/cloud-host/supervisor.test.d.ts.map +1 -0
  8. package/dist/cloud-host/supervisor.test.js +296 -0
  9. package/dist/cloud-host/supervisor.test.js.map +1 -0
  10. package/dist/commands/harness.d.ts +17 -0
  11. package/dist/commands/harness.d.ts.map +1 -0
  12. package/dist/commands/harness.js +35 -0
  13. package/dist/commands/harness.js.map +1 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +10 -3
  16. package/dist/config.js.map +1 -1
  17. package/dist/harness/env.d.ts +36 -0
  18. package/dist/harness/env.d.ts.map +1 -0
  19. package/dist/harness/env.js +78 -0
  20. package/dist/harness/env.js.map +1 -0
  21. package/dist/harness/env.test.d.ts +2 -0
  22. package/dist/harness/env.test.d.ts.map +1 -0
  23. package/dist/harness/env.test.js +70 -0
  24. package/dist/harness/env.test.js.map +1 -0
  25. package/dist/harness/opencode-cli.d.ts +24 -0
  26. package/dist/harness/opencode-cli.d.ts.map +1 -0
  27. package/dist/harness/opencode-cli.js +74 -0
  28. package/dist/harness/opencode-cli.js.map +1 -0
  29. package/dist/harness/opencode-cli.test.d.ts +2 -0
  30. package/dist/harness/opencode-cli.test.d.ts.map +1 -0
  31. package/dist/harness/opencode-cli.test.js +62 -0
  32. package/dist/harness/opencode-cli.test.js.map +1 -0
  33. package/dist/harness/opencode-config.atomic-failure.test.d.ts +2 -0
  34. package/dist/harness/opencode-config.atomic-failure.test.d.ts.map +1 -0
  35. package/dist/harness/opencode-config.atomic-failure.test.js +35 -0
  36. package/dist/harness/opencode-config.atomic-failure.test.js.map +1 -0
  37. package/dist/harness/opencode-config.d.ts +54 -0
  38. package/dist/harness/opencode-config.d.ts.map +1 -0
  39. package/dist/harness/opencode-config.js +130 -0
  40. package/dist/harness/opencode-config.js.map +1 -0
  41. package/dist/harness/opencode-config.test.d.ts +2 -0
  42. package/dist/harness/opencode-config.test.d.ts.map +1 -0
  43. package/dist/harness/opencode-config.test.js +183 -0
  44. package/dist/harness/opencode-config.test.js.map +1 -0
  45. package/dist/harness/prepare.d.ts +34 -0
  46. package/dist/harness/prepare.d.ts.map +1 -0
  47. package/dist/harness/prepare.js +46 -0
  48. package/dist/harness/prepare.js.map +1 -0
  49. package/dist/harness/prepare.test.d.ts +2 -0
  50. package/dist/harness/prepare.test.d.ts.map +1 -0
  51. package/dist/harness/prepare.test.js +96 -0
  52. package/dist/harness/prepare.test.js.map +1 -0
  53. package/dist/heartbeat.d.ts.map +1 -1
  54. package/dist/heartbeat.js +2 -0
  55. package/dist/heartbeat.js.map +1 -1
  56. package/dist/index.js +21 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/socketio-client.d.ts +62 -3
  59. package/dist/socketio-client.d.ts.map +1 -1
  60. package/dist/socketio-client.js +155 -16
  61. package/dist/socketio-client.js.map +1 -1
  62. package/dist/socketio-client.test.d.ts +2 -0
  63. package/dist/socketio-client.test.d.ts.map +1 -0
  64. package/dist/socketio-client.test.js +132 -0
  65. package/dist/socketio-client.test.js.map +1 -0
  66. package/dist/workload-executor.d.ts +31 -2
  67. package/dist/workload-executor.d.ts.map +1 -1
  68. package/dist/workload-executor.js +89 -16
  69. package/dist/workload-executor.js.map +1 -1
  70. package/dist/workload-executor.test.js +180 -1
  71. package/dist/workload-executor.test.js.map +1 -1
  72. package/package.json +2 -1
  73. package/src/cloud-host/supervisor.test.ts +367 -0
  74. package/src/cloud-host/supervisor.ts +289 -0
  75. package/src/commands/harness.ts +44 -0
  76. package/src/config.ts +9 -3
  77. package/src/harness/env.test.ts +79 -0
  78. package/src/harness/env.ts +89 -0
  79. package/src/harness/opencode-cli.test.ts +82 -0
  80. package/src/harness/opencode-cli.ts +99 -0
  81. package/src/harness/opencode-config.atomic-failure.test.ts +51 -0
  82. package/src/harness/opencode-config.test.ts +238 -0
  83. package/src/harness/opencode-config.ts +176 -0
  84. package/src/harness/prepare.test.ts +117 -0
  85. package/src/harness/prepare.ts +69 -0
  86. package/src/heartbeat.ts +2 -0
  87. package/src/index.ts +22 -0
  88. package/src/socketio-client.test.ts +155 -0
  89. package/src/socketio-client.ts +204 -18
  90. package/src/workload-executor.test.ts +237 -0
  91. package/src/workload-executor.ts +115 -17
  92. package/tests/commands/harness.test.ts +90 -0
  93. package/tests/socketio-client.test.ts +140 -2
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'jsonc-parser';
5
+ import {
6
+ OpencodeConfigError,
7
+ ZAG_PROVIDER_NPM,
8
+ buildZagProvider,
9
+ resolveOpencodeConfigPath,
10
+ writeOpencodeConfig,
11
+ } from './opencode-config.js';
12
+
13
+ const TMP_BASE = '/tmp/opencode';
14
+
15
+ const ENV = {
16
+ modelBaseUrl: 'http://admin-api:8002/v1',
17
+ modelApiKey: 'zag_sk_super_secret',
18
+ modelIds: ['model-a', 'model-b'],
19
+ };
20
+
21
+ async function makeDir(): Promise<string> {
22
+ await mkdir(TMP_BASE, { recursive: true });
23
+ return mkdtemp(join(TMP_BASE, 'opencode-config-'));
24
+ }
25
+
26
+ async function fileMode(path: string): Promise<number> {
27
+ return (await stat(path)).mode & 0o777;
28
+ }
29
+
30
+ describe('buildZagProvider (T-AG9/T-AG11)', () => {
31
+ it('builds an OpenAI-compatible provider with baseURL/apiKey/models', () => {
32
+ expect(buildZagProvider(ENV)).toEqual({
33
+ npm: '@ai-sdk/openai-compatible',
34
+ name: 'ZAG',
35
+ options: {
36
+ baseURL: 'http://admin-api:8002/v1',
37
+ apiKey: 'zag_sk_super_secret',
38
+ },
39
+ models: {
40
+ 'model-a': { name: 'model-a' },
41
+ 'model-b': { name: 'model-b' },
42
+ },
43
+ });
44
+ expect(ZAG_PROVIDER_NPM).toBe('@ai-sdk/openai-compatible');
45
+ });
46
+
47
+ it('writes models:{} for an empty model list', () => {
48
+ expect(buildZagProvider({ ...ENV, modelIds: [] }).models).toEqual({});
49
+ });
50
+ });
51
+
52
+ describe('writeOpencodeConfig (T-AG9..T-AG12)', () => {
53
+ const dirs: string[] = [];
54
+
55
+ afterEach(async () => {
56
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
57
+ });
58
+
59
+ it('T-AG9: creates opencode.jsonc with provider.zag and 0600 permissions', async () => {
60
+ const dir = await makeDir();
61
+ dirs.push(dir);
62
+
63
+ const result = await writeOpencodeConfig({ env: ENV, configDir: dir });
64
+
65
+ expect(result.path).toBe(join(dir, 'opencode.jsonc'));
66
+ expect(result.created).toBe(true);
67
+ expect(result.modelCount).toBe(2);
68
+ expect(await fileMode(result.path)).toBe(0o600);
69
+
70
+ const config = parse(await readFile(result.path, 'utf-8'));
71
+ expect(config.provider.zag.options.baseURL).toBe('http://admin-api:8002/v1');
72
+ expect(config.provider.zag.options.apiKey).toBe('zag_sk_super_secret');
73
+ expect(config.provider.zag.models).toEqual({
74
+ 'model-a': { name: 'model-a' },
75
+ 'model-b': { name: 'model-b' },
76
+ });
77
+ });
78
+
79
+ it('T-AG10: merges an existing config, preserving comments and other providers and replacing provider.zag', async () => {
80
+ const dir = await makeDir();
81
+ dirs.push(dir);
82
+ const target = join(dir, 'opencode.jsonc');
83
+ await writeFile(
84
+ target,
85
+ `{
86
+ // keep this comment
87
+ "$schema": "https://opencode.ai/config.json",
88
+ "provider": {
89
+ // existing provider must survive
90
+ "other": {
91
+ "npm": "@example/other",
92
+ "name": "Other",
93
+ "options": { "baseURL": "http://other" },
94
+ "models": { "x": { "name": "x" } }
95
+ },
96
+ "zag": {
97
+ "npm": "old-npm",
98
+ "name": "Old ZAG",
99
+ "options": { "baseURL": "http://old", "apiKey": "old-key" },
100
+ "models": {}
101
+ }
102
+ }
103
+ }
104
+ `,
105
+ 'utf-8',
106
+ );
107
+
108
+ await writeOpencodeConfig({ env: ENV, configDir: dir });
109
+
110
+ const text = await readFile(target, 'utf-8');
111
+ expect(text).toContain('// keep this comment');
112
+ expect(text).toContain('// existing provider must survive');
113
+
114
+ const config = parse(text);
115
+ expect(config.$schema).toBe('https://opencode.ai/config.json');
116
+ expect(config.provider.other).toMatchObject({ npm: '@example/other', name: 'Other' });
117
+ expect(config.provider.zag).toMatchObject({
118
+ npm: ZAG_PROVIDER_NPM,
119
+ name: 'ZAG',
120
+ options: { baseURL: 'http://admin-api:8002/v1', apiKey: 'zag_sk_super_secret' },
121
+ });
122
+ // 旧 apiKey/baseURL 已被替换
123
+ expect(text).not.toContain('old-key');
124
+ expect(text).not.toContain('http://old');
125
+ });
126
+
127
+ it('T-AG11: writes models:{} when ZAG_MODEL_IDS is an empty array', async () => {
128
+ const dir = await makeDir();
129
+ dirs.push(dir);
130
+
131
+ await writeOpencodeConfig({ env: { ...ENV, modelIds: [] }, configDir: dir });
132
+
133
+ const config = parse(await readFile(join(dir, 'opencode.jsonc'), 'utf-8'));
134
+ expect(config.provider.zag.models).toEqual({});
135
+ });
136
+
137
+ it('T-AG12: merges an existing opencode.json instead of creating a new .jsonc', async () => {
138
+ const dir = await makeDir();
139
+ dirs.push(dir);
140
+ const jsonPath = join(dir, 'opencode.json');
141
+ await writeFile(jsonPath, '{\n "theme": "dark"\n}\n', 'utf-8');
142
+
143
+ const result = await writeOpencodeConfig({ env: ENV, configDir: dir });
144
+
145
+ expect(result.path).toBe(jsonPath);
146
+ expect(result.created).toBe(false);
147
+ expect(await fileMode(jsonPath)).toBe(0o600);
148
+
149
+ const config = parse(await readFile(jsonPath, 'utf-8'));
150
+ expect(config.theme).toBe('dark');
151
+ expect(config.provider.zag.name).toBe('ZAG');
152
+
153
+ // 不创建并存的 .jsonc
154
+ await expect(stat(join(dir, 'opencode.jsonc'))).rejects.toThrow();
155
+ });
156
+
157
+ it('T-AG9b: tightens permissions of an existing config to 0600', async () => {
158
+ const dir = await makeDir();
159
+ dirs.push(dir);
160
+ const target = join(dir, 'opencode.jsonc');
161
+ await writeFile(target, '{}\n', 'utf-8');
162
+ await chmod(target, 0o644);
163
+
164
+ await writeOpencodeConfig({ env: ENV, configDir: dir });
165
+
166
+ expect(await fileMode(target)).toBe(0o600);
167
+ });
168
+
169
+ it('C-7: tightens an existing config directory to 0700', async () => {
170
+ const dir = await makeDir();
171
+ dirs.push(dir);
172
+ await chmod(dir, 0o755);
173
+
174
+ await writeOpencodeConfig({ env: ENV, configDir: dir });
175
+
176
+ expect(await fileMode(dir)).toBe(0o700);
177
+ });
178
+
179
+ it('C-7: writes atomically via a temp file and leaves no partial/temp files', async () => {
180
+ const dir = await makeDir();
181
+ dirs.push(dir);
182
+ const target = join(dir, 'opencode.jsonc');
183
+ await writeFile(target, '{\n "theme": "dark"\n}\n', 'utf-8');
184
+
185
+ await writeOpencodeConfig({ env: ENV, configDir: dir });
186
+
187
+ // 同目录只应有目标文件(临时文件已 rename 掉)
188
+ expect(await readdir(dir)).toEqual(['opencode.jsonc']);
189
+ expect(await fileMode(target)).toBe(0o600);
190
+ const config = parse(await readFile(target, 'utf-8'));
191
+ expect(config.theme).toBe('dark');
192
+ expect(config.provider.zag.name).toBe('ZAG');
193
+ });
194
+
195
+ it('C-7: keeps the original file intact when the write path fails', async () => {
196
+ const dir = await makeDir();
197
+ dirs.push(dir);
198
+ const target = join(dir, 'opencode.jsonc');
199
+ const original = '{\n "theme": "dark"\n}\n';
200
+ await writeFile(target, original, 'utf-8');
201
+ // 目标文件不可读(解析阶段失败):不得产生临时文件,原文件保持原样。
202
+ await chmod(target, 0o200);
203
+
204
+ await expect(writeOpencodeConfig({ env: ENV, configDir: dir })).rejects.toBeInstanceOf(
205
+ OpencodeConfigError,
206
+ );
207
+
208
+ expect(await readdir(dir)).toEqual(['opencode.jsonc']);
209
+ await chmod(target, 0o600);
210
+ expect(await readFile(target, 'utf-8')).toBe(original);
211
+ });
212
+
213
+ it('rejects invalid JSONC and leaves the file untouched', async () => {
214
+ const dir = await makeDir();
215
+ dirs.push(dir);
216
+ const target = join(dir, 'opencode.jsonc');
217
+ const original = '{ "provider": ';
218
+ await writeFile(target, original, 'utf-8');
219
+
220
+ await expect(writeOpencodeConfig({ env: ENV, configDir: dir })).rejects.toBeInstanceOf(
221
+ OpencodeConfigError,
222
+ );
223
+ expect(await readFile(target, 'utf-8')).toBe(original);
224
+ });
225
+
226
+ it('T-AG12b: resolves the target file by precedence', async () => {
227
+ const dir = await makeDir();
228
+ dirs.push(dir);
229
+
230
+ expect(await resolveOpencodeConfigPath(dir)).toBe(join(dir, 'opencode.jsonc'));
231
+
232
+ await writeFile(join(dir, 'opencode.json'), '{}\n', 'utf-8');
233
+ expect(await resolveOpencodeConfigPath(dir)).toBe(join(dir, 'opencode.json'));
234
+
235
+ await writeFile(join(dir, 'opencode.jsonc'), '{}\n', 'utf-8');
236
+ expect(await resolveOpencodeConfigPath(dir)).toBe(join(dir, 'opencode.jsonc'));
237
+ });
238
+ });
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import {
6
+ applyEdits,
7
+ modify,
8
+ parse,
9
+ printParseErrorCode,
10
+ type FormattingOptions,
11
+ type ParseError,
12
+ } from 'jsonc-parser';
13
+ import type { HarnessEnv } from './env.js';
14
+
15
+ /** OpenCode 配置中 `provider.zag` 的结构(OpenAI-compatible 自定义 provider)。 */
16
+ export interface ZagProviderConfig {
17
+ npm: string;
18
+ name: string;
19
+ options: {
20
+ baseURL: string;
21
+ apiKey: string;
22
+ };
23
+ models: Record<string, { name: string }>;
24
+ }
25
+
26
+ /** 写入 OpenCode 配置失败(消息只含路径/变量名,不含 key 等 secret)。 */
27
+ export class OpencodeConfigError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'OpencodeConfigError';
31
+ }
32
+ }
33
+
34
+ /** `provider.zag` 固定使用 OpenAI compatible provider 包。 */
35
+ export const ZAG_PROVIDER_NPM = '@ai-sdk/openai-compatible';
36
+
37
+ /** 由 harness env 构造 `provider.zag`(模型列表为空时 `models: {}`)。 */
38
+ export function buildZagProvider(
39
+ env: Pick<HarnessEnv, 'modelBaseUrl' | 'modelApiKey' | 'modelIds'>,
40
+ ): ZagProviderConfig {
41
+ const models: Record<string, { name: string }> = {};
42
+ for (const id of env.modelIds) {
43
+ if (!id) continue;
44
+ models[id] = { name: id };
45
+ }
46
+ return {
47
+ npm: ZAG_PROVIDER_NPM,
48
+ name: 'ZAG',
49
+ options: {
50
+ baseURL: env.modelBaseUrl,
51
+ apiKey: env.modelApiKey,
52
+ },
53
+ models,
54
+ };
55
+ }
56
+
57
+ /** 默认配置目录:`~/.config/opencode`。 */
58
+ export function defaultConfigDir(): string {
59
+ return join(homedir(), '.config', 'opencode');
60
+ }
61
+
62
+ async function exists(path: string): Promise<boolean> {
63
+ try {
64
+ await stat(path);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 选择配置目标文件(A13):
73
+ *
74
+ * 1. 同目录存在 `opencode.jsonc` → 用它;
75
+ * 2. 否则同目录只存在 `opencode.json` → 合并该文件(不另建 `.jsonc`);
76
+ * 3. 都不存在 → 新建 `opencode.jsonc`。
77
+ */
78
+ export async function resolveOpencodeConfigPath(configDir = defaultConfigDir()): Promise<string> {
79
+ const jsoncPath = join(configDir, 'opencode.jsonc');
80
+ if (await exists(jsoncPath)) return jsoncPath;
81
+ const jsonPath = join(configDir, 'opencode.json');
82
+ if (await exists(jsonPath)) return jsonPath;
83
+ return jsoncPath;
84
+ }
85
+
86
+ export interface WriteOpencodeConfigOptions {
87
+ env: Pick<HarnessEnv, 'modelBaseUrl' | 'modelApiKey' | 'modelIds'>;
88
+ /** 覆盖配置目录(测试/定制场景;缺省 `~/.config/opencode`)。 */
89
+ configDir?: string;
90
+ }
91
+
92
+ export interface WriteOpencodeConfigResult {
93
+ /** 实际写入的配置文件路径。 */
94
+ path: string;
95
+ /** 是否为新建文件。 */
96
+ created: boolean;
97
+ /** 写入的模型数量。 */
98
+ modelCount: number;
99
+ }
100
+
101
+ /**
102
+ * 写入/合并 OpenCode 配置:
103
+ *
104
+ * - 用 `jsonc-parser` 的 `modify` 仅替换 `provider.zag`,保留注释与既有 provider;
105
+ * - 文件权限固定 `0600`(含已存在文件),目录权限收紧为 `0700`(含已存在目录);
106
+ * - 同目录临时文件 + `rename` 原子替换(避免半写文件,C-7);
107
+ * - 非法 JSONC 直接报错(不覆盖用户配置,避免静默丢数据)。
108
+ */
109
+ export async function writeOpencodeConfig(
110
+ options: WriteOpencodeConfigOptions,
111
+ ): Promise<WriteOpencodeConfigResult> {
112
+ const dir = options.configDir ?? defaultConfigDir();
113
+ await mkdir(dir, { recursive: true, mode: 0o700 });
114
+ // `mkdir` 的 mode 只对新建目录生效:已存在目录显式收紧到 0700(可能含模型 Key)。
115
+ try {
116
+ await chmod(dir, 0o700);
117
+ } catch (error) {
118
+ throw new OpencodeConfigError(
119
+ `cannot set permissions on OpenCode config directory ${dir}: ${(error as Error).message}`,
120
+ );
121
+ }
122
+
123
+ const target = await resolveOpencodeConfigPath(dir);
124
+ const created = !(await exists(target));
125
+
126
+ let text = '';
127
+ if (!created) {
128
+ try {
129
+ text = await readFile(target, 'utf-8');
130
+ } catch (error) {
131
+ throw new OpencodeConfigError(`cannot read OpenCode config at ${target}: ${(error as Error).message}`);
132
+ }
133
+ }
134
+ if (text.trim().length === 0) {
135
+ text = '{}\n';
136
+ }
137
+
138
+ const parseErrors: ParseError[] = [];
139
+ const parsed = parse(text, parseErrors, { allowTrailingComma: true });
140
+ if (parseErrors.length > 0) {
141
+ const first = parseErrors[0];
142
+ throw new OpencodeConfigError(
143
+ `cannot parse OpenCode config at ${target}: ${printParseErrorCode(first.error)} at offset ${first.offset}`,
144
+ );
145
+ }
146
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
147
+ throw new OpencodeConfigError(`invalid OpenCode config at ${target}: expected a JSON object`);
148
+ }
149
+
150
+ const formattingOptions: FormattingOptions = {
151
+ insertSpaces: true,
152
+ tabSize: 2,
153
+ eol: text.includes('\r\n') ? '\r\n' : '\n',
154
+ };
155
+ const provider = buildZagProvider(options.env);
156
+ const edits = modify(text, ['provider', 'zag'], provider, { formattingOptions });
157
+ let content = applyEdits(text, edits);
158
+ if (!content.endsWith('\n')) content += '\n';
159
+
160
+ const tempPath = join(dir, `.opencode-config.${process.pid}.${randomUUID()}.tmp`);
161
+ try {
162
+ // 先写同目录临时文件(0600)再 rename:失败时原配置保持完整,不产生半写文件。
163
+ await writeFile(tempPath, content, { encoding: 'utf-8', mode: 0o600 });
164
+ await chmod(tempPath, 0o600);
165
+ await rename(tempPath, target);
166
+ } catch (error) {
167
+ await rm(tempPath, { force: true }).catch(() => {});
168
+ throw new OpencodeConfigError(`cannot write OpenCode config at ${target}: ${(error as Error).message}`);
169
+ }
170
+
171
+ return {
172
+ path: target,
173
+ created,
174
+ modelCount: Object.keys(provider.models).length,
175
+ };
176
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { parse } from 'jsonc-parser';
5
+ import { formatPrepareSummary, prepareHarness } from './prepare.js';
6
+ import { HarnessEnvError } from './env.js';
7
+ import { OpencodeCliError } from './opencode-cli.js';
8
+ import type { CommandRunner } from './opencode-cli.js';
9
+
10
+ const TMP_BASE = '/tmp/opencode';
11
+
12
+ const VALID_ENV: NodeJS.ProcessEnv = {
13
+ ZAG_MODEL_BASE_URL: 'http://admin-api:8002/v1',
14
+ ZAG_MODEL_API_KEY: 'zag_sk_super_secret',
15
+ ZAG_MODEL_IDS: '["model-a","model-a"," model-b ",""]',
16
+ ZAG_DEFAULT_WORKSPACE: '/root/workspace',
17
+ };
18
+
19
+ async function makeDir(): Promise<string> {
20
+ await mkdir(TMP_BASE, { recursive: true });
21
+ return mkdtemp(join(TMP_BASE, 'prepare-'));
22
+ }
23
+
24
+ describe('prepareHarness (T-AG7..T-AG12)', () => {
25
+ const dirs: string[] = [];
26
+
27
+ afterEach(async () => {
28
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
29
+ });
30
+
31
+ it('validates env, verifies OpenCode, creates the workspace and writes the config', async () => {
32
+ const dir = await makeDir();
33
+ dirs.push(dir);
34
+ const workspace = join(dir, 'workspace');
35
+ const configDir = join(dir, 'config');
36
+ const runner = vi.fn<CommandRunner>(async () => ({ stdout: '1.2.3\n', stderr: '' }));
37
+
38
+ const result = await prepareHarness({
39
+ env: { ...VALID_ENV, ZAG_DEFAULT_WORKSPACE: workspace },
40
+ runner,
41
+ configDir,
42
+ makeDir: async (path: string) => {
43
+ await mkdir(path, { recursive: true });
44
+ },
45
+ });
46
+
47
+ expect(runner).toHaveBeenCalledWith('opencode', ['--version']);
48
+ expect(result.opencodeVersion).toBe('1.2.3');
49
+ expect(result.env).toMatchObject({
50
+ modelBaseUrl: 'http://admin-api:8002/v1',
51
+ defaultWorkspace: workspace,
52
+ modelCount: 2,
53
+ });
54
+ expect((await stat(workspace)).isDirectory()).toBe(true);
55
+
56
+ const config = parse(await readFile(join(configDir, 'opencode.jsonc'), 'utf-8'));
57
+ expect(Object.keys(config.provider.zag.models)).toEqual(['model-a', 'model-b']);
58
+ expect(config.provider.zag.options.apiKey).toBe('zag_sk_super_secret');
59
+ });
60
+
61
+ it('fails on missing env before touching OpenCode or the filesystem', async () => {
62
+ const runner = vi.fn<CommandRunner>(async () => ({ stdout: '1.2.3', stderr: '' }));
63
+ const makeDirSpy = vi.fn(async () => {});
64
+
65
+ await expect(
66
+ prepareHarness({
67
+ env: { ...VALID_ENV, ZAG_MODEL_IDS: '' },
68
+ runner,
69
+ makeDir: makeDirSpy,
70
+ }),
71
+ ).rejects.toBeInstanceOf(HarnessEnvError);
72
+
73
+ expect(runner).not.toHaveBeenCalled();
74
+ expect(makeDirSpy).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('fails with a version-check error before writing config when OpenCode is unavailable', async () => {
78
+ const dir = await makeDir();
79
+ dirs.push(dir);
80
+ const runner = vi.fn<CommandRunner>(async () => {
81
+ throw new Error('spawn opencode ENOENT');
82
+ });
83
+ const configDir = join(dir, 'config');
84
+
85
+ await expect(
86
+ prepareHarness({
87
+ env: VALID_ENV,
88
+ runner,
89
+ configDir,
90
+ makeDir: async (path: string) => {
91
+ await mkdir(path, { recursive: true });
92
+ },
93
+ }),
94
+ ).rejects.toBeInstanceOf(OpencodeCliError);
95
+
96
+ await expect(stat(configDir)).rejects.toThrow();
97
+ });
98
+
99
+ it('formats a summary without any secret', async () => {
100
+ const dir = await makeDir();
101
+ dirs.push(dir);
102
+ const result = await prepareHarness({
103
+ env: VALID_ENV,
104
+ runner: async () => ({ stdout: '1.2.3', stderr: '' }),
105
+ configDir: join(dir, 'config'),
106
+ makeDir: async () => {},
107
+ });
108
+
109
+ const summary = formatPrepareSummary(result);
110
+ expect(summary).toContain('OpenCode harness prepared');
111
+ expect(summary).toContain('version=1.2.3');
112
+ expect(summary).toContain('models=2');
113
+ expect(summary).toContain('workspace=/root/workspace');
114
+ expect(summary).not.toContain('zag_sk_super_secret');
115
+ expect(summary).not.toContain('admin-api');
116
+ });
117
+ });
@@ -0,0 +1,69 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { readHarnessEnv, type HarnessEnv } from './env.js';
3
+ import { verifyOpencodeCli, type CommandRunner } from './opencode-cli.js';
4
+ import { writeOpencodeConfig, type WriteOpencodeConfigResult } from './opencode-config.js';
5
+
6
+ /** `harness prepare` 结果摘要(不含任何 secret)。 */
7
+ export interface HarnessPrepareResult {
8
+ opencodeVersion: string;
9
+ env: Pick<HarnessEnv, 'modelBaseUrl' | 'defaultWorkspace'> & { modelCount: number };
10
+ config: WriteOpencodeConfigResult;
11
+ }
12
+
13
+ export interface PrepareHarnessOptions {
14
+ env?: NodeJS.ProcessEnv;
15
+ /** 命令执行器(测试注入)。 */
16
+ runner?: CommandRunner;
17
+ /** 覆盖 OpenCode 配置目录(测试/定制场景)。 */
18
+ configDir?: string;
19
+ /** 创建工作区的实现(测试注入)。 */
20
+ makeDir?: (path: string) => Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * 准备 OpenCode harness 环境(契约/计划 Phase 6):
25
+ *
26
+ * 1. 校验 harness env(缺失项明确报错);
27
+ * 2. `opencode --version` 硬校验(不通过即失败);
28
+ * 3. `mkdir -p ZAG_DEFAULT_WORKSPACE`(幂等);
29
+ * 4. 写入/合并 `opencode.jsonc` 的 `provider.zag`(权限 0600)。
30
+ *
31
+ * secret 只写入配置文件,不进入日志/错误/返回值。
32
+ */
33
+ export async function prepareHarness(
34
+ options: PrepareHarnessOptions = {},
35
+ ): Promise<HarnessPrepareResult> {
36
+ const env = readHarnessEnv(options.env ?? process.env);
37
+ // init 脚本 export OPENCODE_CLI_VERSION:agent-gear 侧做二次硬校验(C-5,缺省不启用)。
38
+ const expectedOpencodeVersion = (options.env ?? process.env).OPENCODE_CLI_VERSION;
39
+ const opencodeVersion = await verifyOpencodeCli(options.runner, expectedOpencodeVersion);
40
+
41
+ const makeDir = options.makeDir ?? ((path: string) => mkdir(path, { recursive: true }).then(() => undefined));
42
+ await makeDir(env.defaultWorkspace);
43
+
44
+ const config = await writeOpencodeConfig({
45
+ env,
46
+ configDir: options.configDir,
47
+ });
48
+
49
+ return {
50
+ opencodeVersion,
51
+ env: {
52
+ modelBaseUrl: env.modelBaseUrl,
53
+ defaultWorkspace: env.defaultWorkspace,
54
+ modelCount: env.modelIds.length,
55
+ },
56
+ config,
57
+ };
58
+ }
59
+
60
+ /** 输出一行摘要(不含 `ZAG_MODEL_API_KEY` / provider apiKey)。 */
61
+ export function formatPrepareSummary(result: HarnessPrepareResult): string {
62
+ const created = result.config.created ? 'created' : 'updated';
63
+ return [
64
+ `OpenCode harness prepared: version=${result.opencodeVersion}`,
65
+ `models=${result.env.modelCount}`,
66
+ `workspace=${result.env.defaultWorkspace}`,
67
+ `config=${result.config.path} (${created})`,
68
+ ].join(' ');
69
+ }
package/src/heartbeat.ts CHANGED
@@ -4,6 +4,8 @@ export class Heartbeat {
4
4
  private intervalId: ReturnType<typeof setInterval> | null = null;
5
5
 
6
6
  start(sendFn: () => void): void {
7
+ // 重连/重复 registered 时不得遗留旧 interval(否则每轮多一个心跳定时器)。
8
+ this.stop();
7
9
  this.intervalId = setInterval(() => {
8
10
  sendFn();
9
11
  }, HEARTBEAT_INTERVAL_MS);
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { connectToServer } from './socketio-client.js';
7
7
  import { writePid, removePid, isRunning } from './daemon.js';
8
8
  import { startMcpRegistration } from './commands/mcp-proxy.js';
9
9
  import { startMcpProxy } from './commands/mcp.js';
10
+ import { registerHarnessCommands } from './commands/harness.js';
11
+ import { isCloudHostMode, startCloudHostSupervisor } from './cloud-host/supervisor.js';
10
12
  import { processRegistry, isProcessAlive } from './acp/process-registry.js';
11
13
 
12
14
  // 版本号唯一事实源为 package.json(`--version` 与 npm 发布版本保持一致,避免手工同步漂移)。
@@ -29,6 +31,23 @@ program
29
31
  process.exit(1);
30
32
  }
31
33
 
34
+ // 云电脑模式(`ZAG_HOST_ENDPOINT` + `ZAG_HOST_KEY` + `ZAG_CONTROLLER_KEY` 同时存在):
35
+ // 双连接编排(/worker 初始化 → controller-api 标准连接),不读取/写入本地 key 配置。
36
+ if (isCloudHostMode()) {
37
+ writePid();
38
+ process.on('exit', () => {
39
+ removePid();
40
+ });
41
+ try {
42
+ await startCloudHostSupervisor();
43
+ } catch (error) {
44
+ console.error(`Cloud host supervisor failed: ${(error as Error).message}`);
45
+ removePid();
46
+ process.exit(1);
47
+ }
48
+ return;
49
+ }
50
+
32
51
  const key = resolveKey(options.key);
33
52
  if (!key) {
34
53
  console.error('No key provided. Use --key, ZAG_CONTROLLER_KEY env, or configure in ~/.zag/config.toml');
@@ -115,4 +134,7 @@ program
115
134
  });
116
135
  });
117
136
 
137
+ // 云电脑 harness 初始化子命令:`agent-gear harness prepare`(init 脚本调用)。
138
+ registerHarnessCommands(program);
139
+
118
140
  program.parse();