agent-gear 2.4.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.
- package/AGENTS.md +64 -5
- package/dist/cloud-host/supervisor.d.ts +92 -0
- package/dist/cloud-host/supervisor.d.ts.map +1 -0
- package/dist/cloud-host/supervisor.js +216 -0
- package/dist/cloud-host/supervisor.js.map +1 -0
- package/dist/cloud-host/supervisor.test.d.ts +2 -0
- package/dist/cloud-host/supervisor.test.d.ts.map +1 -0
- package/dist/cloud-host/supervisor.test.js +296 -0
- package/dist/cloud-host/supervisor.test.js.map +1 -0
- package/dist/commands/harness.d.ts +17 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +35 -0
- package/dist/commands/harness.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/harness/env.d.ts +36 -0
- package/dist/harness/env.d.ts.map +1 -0
- package/dist/harness/env.js +78 -0
- package/dist/harness/env.js.map +1 -0
- package/dist/harness/env.test.d.ts +2 -0
- package/dist/harness/env.test.d.ts.map +1 -0
- package/dist/harness/env.test.js +70 -0
- package/dist/harness/env.test.js.map +1 -0
- package/dist/harness/opencode-cli.d.ts +24 -0
- package/dist/harness/opencode-cli.d.ts.map +1 -0
- package/dist/harness/opencode-cli.js +74 -0
- package/dist/harness/opencode-cli.js.map +1 -0
- package/dist/harness/opencode-cli.test.d.ts +2 -0
- package/dist/harness/opencode-cli.test.d.ts.map +1 -0
- package/dist/harness/opencode-cli.test.js +62 -0
- package/dist/harness/opencode-cli.test.js.map +1 -0
- package/dist/harness/opencode-config.atomic-failure.test.d.ts +2 -0
- package/dist/harness/opencode-config.atomic-failure.test.d.ts.map +1 -0
- package/dist/harness/opencode-config.atomic-failure.test.js +35 -0
- package/dist/harness/opencode-config.atomic-failure.test.js.map +1 -0
- package/dist/harness/opencode-config.d.ts +54 -0
- package/dist/harness/opencode-config.d.ts.map +1 -0
- package/dist/harness/opencode-config.js +130 -0
- package/dist/harness/opencode-config.js.map +1 -0
- package/dist/harness/opencode-config.test.d.ts +2 -0
- package/dist/harness/opencode-config.test.d.ts.map +1 -0
- package/dist/harness/opencode-config.test.js +183 -0
- package/dist/harness/opencode-config.test.js.map +1 -0
- package/dist/harness/prepare.d.ts +34 -0
- package/dist/harness/prepare.d.ts.map +1 -0
- package/dist/harness/prepare.js +46 -0
- package/dist/harness/prepare.js.map +1 -0
- package/dist/harness/prepare.test.d.ts +2 -0
- package/dist/harness/prepare.test.d.ts.map +1 -0
- package/dist/harness/prepare.test.js +96 -0
- package/dist/harness/prepare.test.js.map +1 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +2 -0
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/socketio-client.d.ts +62 -3
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +155 -16
- package/dist/socketio-client.js.map +1 -1
- package/dist/socketio-client.test.d.ts +2 -0
- package/dist/socketio-client.test.d.ts.map +1 -0
- package/dist/socketio-client.test.js +132 -0
- package/dist/socketio-client.test.js.map +1 -0
- package/dist/workload-executor.d.ts +24 -1
- package/dist/workload-executor.d.ts.map +1 -1
- package/dist/workload-executor.js +78 -16
- package/dist/workload-executor.js.map +1 -1
- package/dist/workload-executor.test.js +99 -1
- package/dist/workload-executor.test.js.map +1 -1
- package/package.json +2 -1
- package/src/cloud-host/supervisor.test.ts +367 -0
- package/src/cloud-host/supervisor.ts +289 -0
- package/src/commands/harness.ts +44 -0
- package/src/config.ts +9 -3
- package/src/harness/env.test.ts +79 -0
- package/src/harness/env.ts +89 -0
- package/src/harness/opencode-cli.test.ts +82 -0
- package/src/harness/opencode-cli.ts +99 -0
- package/src/harness/opencode-config.atomic-failure.test.ts +51 -0
- package/src/harness/opencode-config.test.ts +238 -0
- package/src/harness/opencode-config.ts +176 -0
- package/src/harness/prepare.test.ts +117 -0
- package/src/harness/prepare.ts +69 -0
- package/src/heartbeat.ts +2 -0
- package/src/index.ts +22 -0
- package/src/socketio-client.test.ts +155 -0
- package/src/socketio-client.ts +204 -18
- package/src/workload-executor.test.ts +120 -0
- package/src/workload-executor.ts +100 -17
- package/tests/commands/harness.test.ts +90 -0
- package/tests/socketio-client.test.ts +140 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { collectSecretValues, redactSecrets } from '../workload-executor.js';
|
|
3
|
+
import { formatPrepareSummary, prepareHarness, type PrepareHarnessOptions } from '../harness/prepare.js';
|
|
4
|
+
|
|
5
|
+
export interface RegisterHarnessCommandsDeps {
|
|
6
|
+
prepare?: (options?: PrepareHarnessOptions) => Promise<Awaited<ReturnType<typeof prepareHarness>>>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 注册 `agent-gear harness` 命令组。
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* agent-gear harness prepare # 校验 env → opencode --version → 写 opencode.jsonc
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* 失败以退出码 1 结束(供 harness 工作负载 `required=true` 触发容器强制注销);
|
|
17
|
+
* 输出/错误均对 `ZAG_MODEL_API_KEY` 等 secret 脱敏。
|
|
18
|
+
*/
|
|
19
|
+
export function registerHarnessCommands(
|
|
20
|
+
program: Command,
|
|
21
|
+
deps: RegisterHarnessCommandsDeps = {},
|
|
22
|
+
): Command {
|
|
23
|
+
const harness = program
|
|
24
|
+
.command('harness')
|
|
25
|
+
.description('Prepare harness runtime (OpenCode) for cloud-host containers');
|
|
26
|
+
|
|
27
|
+
harness
|
|
28
|
+
.command('prepare')
|
|
29
|
+
.description('Validate harness env, verify OpenCode CLI and write provider.zag into opencode.jsonc')
|
|
30
|
+
.action(async () => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await (deps.prepare ?? prepareHarness)();
|
|
33
|
+
console.log(formatPrepareSummary(result));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const secrets = collectSecretValues(process.env);
|
|
36
|
+
const message = redactSecrets((error as Error).message, secrets);
|
|
37
|
+
console.error(`harness prepare failed: ${message}`);
|
|
38
|
+
// 非零退出:不直接 process.exit,便于测试断言 exitCode 与已刷新的日志。
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return harness;
|
|
44
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { parse, stringify } from 'smol-toml';
|
|
@@ -23,9 +23,15 @@ export function readConfig(): ConfigData {
|
|
|
23
23
|
|
|
24
24
|
export function writeConfig(data: ConfigData): void {
|
|
25
25
|
if (!existsSync(CONFIG_DIR)) {
|
|
26
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
// 配置含 Key/resumeToken:仅属主可读写(避免 world-readable 凭据落盘)。
|
|
29
|
+
writeFileSync(CONFIG_FILE, stringify(data), { encoding: 'utf-8', mode: 0o600 });
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
32
|
+
} catch {
|
|
33
|
+
// 某些平台/文件系统不支持 chmod:不阻断配置写入(已在 Windows 等平台降级)
|
|
27
34
|
}
|
|
28
|
-
writeFileSync(CONFIG_FILE, stringify(data), 'utf-8');
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export function clearConfigKey(): void {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { HarnessEnvError, parseModelIds, readHarnessEnv } from './env.js';
|
|
3
|
+
|
|
4
|
+
const VALID_ENV: NodeJS.ProcessEnv = {
|
|
5
|
+
ZAG_MODEL_BASE_URL: 'http://admin-api:8002/v1',
|
|
6
|
+
ZAG_MODEL_API_KEY: 'zag_sk_super_secret',
|
|
7
|
+
ZAG_MODEL_IDS: '["model-a","model-b"]',
|
|
8
|
+
ZAG_DEFAULT_WORKSPACE: '/root/workspace',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('readHarnessEnv (T-AG7/T-AG11)', () => {
|
|
12
|
+
it('reads a valid harness env', () => {
|
|
13
|
+
expect(readHarnessEnv(VALID_ENV)).toEqual({
|
|
14
|
+
modelBaseUrl: 'http://admin-api:8002/v1',
|
|
15
|
+
modelApiKey: 'zag_sk_super_secret',
|
|
16
|
+
modelIds: ['model-a', 'model-b'],
|
|
17
|
+
defaultWorkspace: '/root/workspace',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('lists all missing variables by name and never leaks secret values', () => {
|
|
22
|
+
let error: unknown;
|
|
23
|
+
try {
|
|
24
|
+
readHarnessEnv({ ZAG_MODEL_API_KEY: 'zag_sk_super_secret' });
|
|
25
|
+
} catch (caught) {
|
|
26
|
+
error = caught;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(error).toBeInstanceOf(HarnessEnvError);
|
|
30
|
+
const message = (error as Error).message;
|
|
31
|
+
expect(message).toContain('ZAG_MODEL_BASE_URL');
|
|
32
|
+
expect(message).toContain('ZAG_MODEL_IDS');
|
|
33
|
+
expect(message).toContain('ZAG_DEFAULT_WORKSPACE');
|
|
34
|
+
expect(message).not.toContain('ZAG_MODEL_API_KEY');
|
|
35
|
+
expect(message).not.toContain('zag_sk_super_secret');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('trims values', () => {
|
|
39
|
+
const env = readHarnessEnv({
|
|
40
|
+
...VALID_ENV,
|
|
41
|
+
ZAG_MODEL_BASE_URL: ' http://admin-api:8002/v1 ',
|
|
42
|
+
} as NodeJS.ProcessEnv);
|
|
43
|
+
expect(env.modelBaseUrl).toBe('http://admin-api:8002/v1');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects a missing default workspace', () => {
|
|
47
|
+
expect(() => readHarnessEnv({ ...VALID_ENV, ZAG_DEFAULT_WORKSPACE: '' })).toThrowError(
|
|
48
|
+
/ZAG_DEFAULT_WORKSPACE/,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('parseModelIds (T-AG11)', () => {
|
|
54
|
+
it('dedupes and drops empty/whitespace entries while preserving order', () => {
|
|
55
|
+
expect(parseModelIds('["b"," a ","b",""," ","c","a"]')).toEqual(['b', 'a', 'c']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts an empty array', () => {
|
|
59
|
+
expect(parseModelIds('[]')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects invalid JSON without echoing the raw value', () => {
|
|
63
|
+
let error: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parseModelIds('not-json');
|
|
66
|
+
} catch (caught) {
|
|
67
|
+
error = caught;
|
|
68
|
+
}
|
|
69
|
+
expect(error).toBeInstanceOf(HarnessEnvError);
|
|
70
|
+
expect((error as Error).message).toContain('ZAG_MODEL_IDS');
|
|
71
|
+
expect((error as Error).message).not.toContain('not-json');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects non-array and non-string elements', () => {
|
|
75
|
+
expect(() => parseModelIds('{"a":1}')).toThrowError(/JSON array of strings/);
|
|
76
|
+
expect(() => parseModelIds('["a",1]')).toThrowError(/non-string element/);
|
|
77
|
+
expect(() => parseModelIds('null')).toThrowError(/JSON array of strings/);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* harness(OpenCode 云电脑)初始化环境校验。
|
|
3
|
+
*
|
|
4
|
+
* 只读取 `harness prepare` 需要的变量;缺失/非法时一次性列出**变量名**,
|
|
5
|
+
* 绝不回显其它 env 的值(尤其 `ZAG_MODEL_API_KEY` 等 secret)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** harness prepare 需要的环境(已归一)。 */
|
|
9
|
+
export interface HarnessEnv {
|
|
10
|
+
/** 模型网关地址(`ZAG_MODEL_BASE_URL`)。 */
|
|
11
|
+
modelBaseUrl: string;
|
|
12
|
+
/** 运行专用模型 Key(`ZAG_MODEL_API_KEY`,禁止打印)。 */
|
|
13
|
+
modelApiKey: string;
|
|
14
|
+
/** 可用模型 ID 列表(`ZAG_MODEL_IDS`,JSON 数组字符串,去空去重)。 */
|
|
15
|
+
modelIds: string[];
|
|
16
|
+
/** 默认工作区(`ZAG_DEFAULT_WORKSPACE`)。 */
|
|
17
|
+
defaultWorkspace: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** env 校验失败(消息不含任何变量值)。 */
|
|
21
|
+
export class HarnessEnvError extends Error {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'HarnessEnvError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nonEmpty(value: string | undefined): string {
|
|
29
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析 `ZAG_MODEL_IDS`:必须是字符串 JSON 数组;元素 trim 后去空、去重(保持原顺序)。
|
|
34
|
+
*
|
|
35
|
+
* - `[]` 合法(T-AG11:空数组仍写 `models:{}`);
|
|
36
|
+
* - 非数组/非法 JSON/含非字符串元素 → 明确报错(不输出原始值)。
|
|
37
|
+
*/
|
|
38
|
+
export function parseModelIds(raw: string): string[] {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new HarnessEnvError('ZAG_MODEL_IDS must be a JSON array of strings (invalid JSON)');
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(parsed)) {
|
|
46
|
+
throw new HarnessEnvError('ZAG_MODEL_IDS must be a JSON array of strings');
|
|
47
|
+
}
|
|
48
|
+
if (!parsed.every((item) => typeof item === 'string')) {
|
|
49
|
+
throw new HarnessEnvError('ZAG_MODEL_IDS must be a JSON array of strings (non-string element)');
|
|
50
|
+
}
|
|
51
|
+
const unique: string[] = [];
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
for (const item of parsed as string[]) {
|
|
54
|
+
const value = item.trim();
|
|
55
|
+
if (!value || seen.has(value)) continue;
|
|
56
|
+
seen.add(value);
|
|
57
|
+
unique.push(value);
|
|
58
|
+
}
|
|
59
|
+
return unique;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取并校验 harness env。
|
|
64
|
+
*
|
|
65
|
+
* 必需:`ZAG_MODEL_BASE_URL` / `ZAG_MODEL_API_KEY` / `ZAG_MODEL_IDS` / `ZAG_DEFAULT_WORKSPACE`
|
|
66
|
+
* (云电脑 Job env 与 `workload:run.env` 均由 agent-api 注入,缺失说明编排不正确)。
|
|
67
|
+
*/
|
|
68
|
+
export function readHarnessEnv(env: NodeJS.ProcessEnv = process.env): HarnessEnv {
|
|
69
|
+
const missing: string[] = [];
|
|
70
|
+
const modelBaseUrl = nonEmpty(env.ZAG_MODEL_BASE_URL);
|
|
71
|
+
if (!modelBaseUrl) missing.push('ZAG_MODEL_BASE_URL');
|
|
72
|
+
const modelApiKey = nonEmpty(env.ZAG_MODEL_API_KEY);
|
|
73
|
+
if (!modelApiKey) missing.push('ZAG_MODEL_API_KEY');
|
|
74
|
+
const rawModelIds = nonEmpty(env.ZAG_MODEL_IDS);
|
|
75
|
+
if (!rawModelIds) missing.push('ZAG_MODEL_IDS');
|
|
76
|
+
const defaultWorkspace = nonEmpty(env.ZAG_DEFAULT_WORKSPACE);
|
|
77
|
+
if (!defaultWorkspace) missing.push('ZAG_DEFAULT_WORKSPACE');
|
|
78
|
+
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
throw new HarnessEnvError(`missing required environment variable(s): ${missing.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
modelBaseUrl,
|
|
85
|
+
modelApiKey,
|
|
86
|
+
modelIds: parseModelIds(rawModelIds),
|
|
87
|
+
defaultWorkspace,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
OpencodeCliError,
|
|
4
|
+
extractVersion,
|
|
5
|
+
verifyOpencodeCli,
|
|
6
|
+
type CommandRunner,
|
|
7
|
+
} from './opencode-cli.js';
|
|
8
|
+
|
|
9
|
+
describe('verifyOpencodeCli (T-AG8)', () => {
|
|
10
|
+
it('runs `opencode --version` and returns the version', async () => {
|
|
11
|
+
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '1.2.3\n', stderr: '' }));
|
|
12
|
+
|
|
13
|
+
await expect(verifyOpencodeCli(runner)).resolves.toBe('1.2.3');
|
|
14
|
+
expect(runner).toHaveBeenCalledWith('opencode', ['--version']);
|
|
15
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to `opencode -v` for legacy CLIs', async () => {
|
|
19
|
+
const runner = vi.fn<CommandRunner>(async (_command, args) => {
|
|
20
|
+
if (args[0] === '--version') throw new Error('unknown option');
|
|
21
|
+
return { stdout: 'v0.9.1\n', stderr: '' };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await expect(verifyOpencodeCli(runner)).resolves.toBe('0.9.1');
|
|
25
|
+
expect(runner).toHaveBeenNthCalledWith(1, 'opencode', ['--version']);
|
|
26
|
+
expect(runner).toHaveBeenNthCalledWith(2, 'opencode', ['-v']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('falls back to `-v` when `--version` prints no version', async () => {
|
|
30
|
+
const runner = vi.fn<CommandRunner>(async (_command, args) =>
|
|
31
|
+
args[0] === '--version'
|
|
32
|
+
? { stdout: ' \n', stderr: '' }
|
|
33
|
+
: { stdout: '2.0.0\n', stderr: '' },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
await expect(verifyOpencodeCli(runner)).resolves.toBe('2.0.0');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws a version-check error when both commands fail', async () => {
|
|
40
|
+
const runner = vi.fn<CommandRunner>(async () => {
|
|
41
|
+
throw new Error('spawn opencode ENOENT');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let error: unknown;
|
|
45
|
+
try {
|
|
46
|
+
await verifyOpencodeCli(runner);
|
|
47
|
+
} catch (caught) {
|
|
48
|
+
error = caught;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(error).toBeInstanceOf(OpencodeCliError);
|
|
52
|
+
const message = (error as Error).message;
|
|
53
|
+
expect(message).toContain('version check failed');
|
|
54
|
+
expect(message).toContain('opencode --version');
|
|
55
|
+
expect(message).toContain('opencode -v');
|
|
56
|
+
expect(message).toContain('ENOENT');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('C-5: validates the expected pinned version (tolerates v prefix and command output prefix)', async () => {
|
|
60
|
+
const runner = vi.fn<CommandRunner>(async () => ({ stdout: 'v2.1.1\n', stderr: '' }));
|
|
61
|
+
await expect(verifyOpencodeCli(runner, '2.1.1')).resolves.toBe('2.1.1');
|
|
62
|
+
|
|
63
|
+
const prefixed = vi.fn<CommandRunner>(async () => ({ stdout: 'opencode 2.1.1\n', stderr: '' }));
|
|
64
|
+
await expect(verifyOpencodeCli(prefixed, '2.1.1')).resolves.toBe('opencode 2.1.1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('C-5: throws immediately on a pinned version mismatch', async () => {
|
|
68
|
+
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '2.0.0\n', stderr: '' }));
|
|
69
|
+
|
|
70
|
+
await expect(verifyOpencodeCli(runner, '2.1.1')).rejects.toThrow(
|
|
71
|
+
/version mismatch: expected 2\.1\.1, got 2\.0\.0/,
|
|
72
|
+
);
|
|
73
|
+
// 不匹配即失败,不再退回 `-v`
|
|
74
|
+
expect(runner).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reads the version from stderr when stdout is empty', () => {
|
|
78
|
+
expect(extractVersion('', 'opencode 1.0.0\n')).toBe('opencode 1.0.0');
|
|
79
|
+
expect(extractVersion('\n\n3.2.1\n')).toBe('3.2.1');
|
|
80
|
+
expect(extractVersion(' ')).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** 一次外部命令执行结果(stdout/stderr 均为完整文本)。 */
|
|
4
|
+
export interface CommandResult {
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 可注入的命令执行器(测试使用;生产走 `execFile`)。 */
|
|
10
|
+
export type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>;
|
|
11
|
+
|
|
12
|
+
/** `opencode --version` 硬校验失败。 */
|
|
13
|
+
export class OpencodeCliError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'OpencodeCliError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
const defaultRunner: CommandRunner = (command, args) =>
|
|
23
|
+
new Promise<CommandResult>((resolve, reject) => {
|
|
24
|
+
execFile(
|
|
25
|
+
command,
|
|
26
|
+
args,
|
|
27
|
+
{ timeout: DEFAULT_TIMEOUT_MS, maxBuffer: 1024 * 1024, encoding: 'utf8' },
|
|
28
|
+
(error, stdout, stderr) => {
|
|
29
|
+
if (error) {
|
|
30
|
+
reject(error);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** 从版本输出中取第一行非空文本(去掉前后空白与可能的 `v` 前缀)。 */
|
|
39
|
+
export function extractVersion(stdout: string, stderr = ''): string | undefined {
|
|
40
|
+
const line = `${stdout}\n${stderr}`
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((value) => value.trim())
|
|
43
|
+
.find((value) => value.length > 0);
|
|
44
|
+
if (!line) return undefined;
|
|
45
|
+
return line.replace(/^v/i, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `opencode --version` 硬校验(`opencode -v` 仅作旧版 CLI 的兼容探测)。
|
|
50
|
+
*
|
|
51
|
+
* `expectedVersion` 非空时要求实际版本严格一致(去掉 `v` 前缀后比较),不一致立即失败,
|
|
52
|
+
* 用于 init 脚本已固定安装版本的二次校验(C-5)。
|
|
53
|
+
*
|
|
54
|
+
* 失败时抛 {@link OpencodeCliError},消息包含「版本校验」提示与命令级错误摘要
|
|
55
|
+
* (不包含任何 env/secret)。调用方(init 脚本 / `harness prepare`)必须以非零退出。
|
|
56
|
+
*/
|
|
57
|
+
export async function verifyOpencodeCli(
|
|
58
|
+
runner: CommandRunner = defaultRunner,
|
|
59
|
+
expectedVersion?: string,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
const failures: string[] = [];
|
|
62
|
+
|
|
63
|
+
const normalizeVersion = (version: string): string =>
|
|
64
|
+
(version.trim().split(/\s+/).pop() ?? '').replace(/^v/i, '');
|
|
65
|
+
|
|
66
|
+
const ensureExpected = (version: string): string => {
|
|
67
|
+
if (expectedVersion && normalizeVersion(version) !== normalizeVersion(expectedVersion)) {
|
|
68
|
+
throw new OpencodeCliError(
|
|
69
|
+
`OpenCode CLI version mismatch: expected ${expectedVersion}, got ${version}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return version;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await runner('opencode', ['--version']);
|
|
77
|
+
const version = extractVersion(result.stdout, result.stderr);
|
|
78
|
+
if (version) return ensureExpected(version);
|
|
79
|
+
failures.push('`opencode --version` returned no version output');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof OpencodeCliError) throw error;
|
|
82
|
+
failures.push(`\`opencode --version\` failed: ${(error as Error).message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 兼容旧版 CLI:仅当 `--version` 不可用时探测 `-v`。
|
|
86
|
+
try {
|
|
87
|
+
const result = await runner('opencode', ['-v']);
|
|
88
|
+
const version = extractVersion(result.stdout, result.stderr);
|
|
89
|
+
if (version) return ensureExpected(version);
|
|
90
|
+
failures.push('`opencode -v` returned no version output');
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof OpencodeCliError) throw error;
|
|
93
|
+
failures.push(`\`opencode -v\` failed: ${(error as Error).message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new OpencodeCliError(
|
|
97
|
+
`OpenCode CLI version check failed; OpenCode is required by the harness workload (${failures.join('; ')})`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const fs = vi.hoisted(() => ({
|
|
4
|
+
chmod: vi.fn(),
|
|
5
|
+
mkdir: vi.fn(),
|
|
6
|
+
readFile: vi.fn(),
|
|
7
|
+
rename: vi.fn(),
|
|
8
|
+
rm: vi.fn(),
|
|
9
|
+
stat: vi.fn(),
|
|
10
|
+
writeFile: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('node:fs/promises', () => fs);
|
|
14
|
+
|
|
15
|
+
import { OpencodeConfigError, writeOpencodeConfig } from './opencode-config.js';
|
|
16
|
+
|
|
17
|
+
const ENV = {
|
|
18
|
+
modelBaseUrl: 'http://admin-api:8002/v1',
|
|
19
|
+
modelApiKey: 'zag_sk_super_secret',
|
|
20
|
+
modelIds: ['model-a'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('R2-16 opencode config atomic write failure cleanup', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
fs.mkdir.mockResolvedValue(undefined);
|
|
27
|
+
fs.chmod.mockResolvedValue(undefined);
|
|
28
|
+
fs.writeFile.mockResolvedValue(undefined);
|
|
29
|
+
fs.rm.mockResolvedValue(undefined);
|
|
30
|
+
// 目标文件不存在 → 走新建分支
|
|
31
|
+
fs.stat.mockRejectedValue(new Error('ENOENT'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('removes the temp file when rename fails and preserves the original error surface', async () => {
|
|
35
|
+
fs.rename.mockRejectedValue(new Error('rename failed'));
|
|
36
|
+
|
|
37
|
+
await expect(
|
|
38
|
+
writeOpencodeConfig({ env: ENV, configDir: '/tmp/opencode-r2-16' }),
|
|
39
|
+
).rejects.toBeInstanceOf(OpencodeConfigError);
|
|
40
|
+
|
|
41
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
42
|
+
expect.stringMatching(/\/tmp\/opencode-r2-16\/\.opencode-config\..*\.tmp$/),
|
|
43
|
+
expect.any(String),
|
|
44
|
+
{ encoding: 'utf-8', mode: 0o600 },
|
|
45
|
+
);
|
|
46
|
+
expect(fs.rm).toHaveBeenCalledWith(
|
|
47
|
+
expect.stringMatching(/\/tmp\/opencode-r2-16\/\.opencode-config\..*\.tmp$/),
|
|
48
|
+
{ force: true },
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|