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
package/src/workload-executor.ts
CHANGED
|
@@ -39,6 +39,52 @@ export interface WorkloadRunPayload {
|
|
|
39
39
|
items: WorkloadItem[];
|
|
40
40
|
/** `post` 批次共享总预算(毫秒);`init` 不携带。 */
|
|
41
41
|
budgetMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* 容器级环境变量(仅 `purpose=harness` 下发,如 `ZAG_MODEL_*` / `ZAG_HOST_*`)。
|
|
44
|
+
* 对批次内所有 item 生效;`item.params` 派生的 `WORKLOAD_PARAM_*` 同名时优先。
|
|
45
|
+
*/
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 需要在日志/错误/脚本输出中脱敏的环境变量名(契约 §2.9「云电脑初始化 env」)。 */
|
|
50
|
+
export const SECRET_ENV_KEYS = ['ZAG_MODEL_API_KEY', 'ZAG_HOST_KEY', 'ZAG_CONTROLLER_KEY'] as const;
|
|
51
|
+
|
|
52
|
+
/** 脱敏占位符。 */
|
|
53
|
+
export const REDACTED = '***';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 收集需要脱敏的 secret 值(仅非空值;调用方可叠加批次 `env` 与 `process.env`)。
|
|
57
|
+
*/
|
|
58
|
+
export function collectSecretValues(...sources: Array<Record<string, string | undefined> | undefined>): string[] {
|
|
59
|
+
const values = new Set<string>();
|
|
60
|
+
for (const source of sources) {
|
|
61
|
+
if (!source) continue;
|
|
62
|
+
for (const key of SECRET_ENV_KEYS) {
|
|
63
|
+
const value = source[key];
|
|
64
|
+
if (typeof value === 'string' && value.length > 0) values.add(value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 长值优先替换,避免短 secret 是长 secret 的子串时留下残片。
|
|
68
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 将文本中出现的 secret 值替换为 `***`(日志、错误与脚本输出统一口径)。 */
|
|
72
|
+
export function redactSecrets(text: string, secrets: Iterable<string>): string {
|
|
73
|
+
let result = text;
|
|
74
|
+
for (const secret of secrets) {
|
|
75
|
+
if (!secret) continue;
|
|
76
|
+
result = result.split(secret).join(REDACTED);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface WorkloadBatchOutcome {
|
|
82
|
+
/** 批次是否被新批次/销毁流程打断(打断时不得视为初始化完成)。 */
|
|
83
|
+
cancelled: boolean;
|
|
84
|
+
/** 已上报的失败/超时条目数(含 required 短路前的当前条)。 */
|
|
85
|
+
failedCount: number;
|
|
86
|
+
/** 本批条目总数。 */
|
|
87
|
+
total: number;
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
export type WorkloadStatus = 'success' | 'failed' | 'timeout';
|
|
@@ -112,6 +158,20 @@ function nowIso(): string {
|
|
|
112
158
|
return new Date().toISOString();
|
|
113
159
|
}
|
|
114
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 归一容器级 `env`:只接受字符串键值(服务端报文里 `ZAG_*` 均为字符串),
|
|
163
|
+
* 非对象/数组一律视为未下发(普通容器行为不变)。
|
|
164
|
+
*/
|
|
165
|
+
function normalizeContainerEnv(env: Record<string, string> | undefined): Record<string, string> {
|
|
166
|
+
if (!env || typeof env !== 'object' || Array.isArray(env)) return {};
|
|
167
|
+
const normalized: Record<string, string> = {};
|
|
168
|
+
for (const [key, value] of Object.entries(env)) {
|
|
169
|
+
if (!key || typeof value !== 'string') continue;
|
|
170
|
+
normalized[key] = value;
|
|
171
|
+
}
|
|
172
|
+
return normalized;
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
/** 单个参数值 → 环境变量字符串(契约 §2.9「参数注入约定」)。 */
|
|
116
176
|
function encodeParamValue(value: unknown): string {
|
|
117
177
|
if (typeof value === 'string') return value;
|
|
@@ -123,17 +183,23 @@ function encodeParamValue(value: unknown): string {
|
|
|
123
183
|
}
|
|
124
184
|
|
|
125
185
|
/**
|
|
126
|
-
* 由 `process.env` 派生单条 item 的执行环境:
|
|
186
|
+
* 由 `process.env` + 容器级 `env` 派生单条 item 的执行环境:
|
|
187
|
+
* - 容器级 `env`(harness 下发的 `ZAG_*`)先合并,对批次所有 item 生效;
|
|
127
188
|
* - `WORKLOAD_PARAMS`:完整参数 JSON 字符串(缺省 `{}`);
|
|
128
|
-
* - `WORKLOAD_PARAM_<KEY_UPPER>`:单值编码(字符串原样、布尔 `"true"`/`"false"`、多选 JSON
|
|
189
|
+
* - `WORKLOAD_PARAM_<KEY_UPPER>`:单值编码(字符串原样、布尔 `"true"`/`"false"`、多选 JSON 数组字符串),
|
|
190
|
+
* 同名时**覆盖**容器 `env`(item 参数优先)。
|
|
129
191
|
*
|
|
130
|
-
* 每次 spawn
|
|
192
|
+
* 每次 spawn 都重新派生,参数 env 仅对当前 item 生效、不污染后续脚本;
|
|
131
193
|
* 不修改脚本文本、不做字符串替换(避免注入)。
|
|
132
194
|
*/
|
|
133
|
-
function buildWorkloadEnv(
|
|
195
|
+
function buildWorkloadEnv(
|
|
196
|
+
params: Record<string, unknown> | undefined,
|
|
197
|
+
containerEnv: Record<string, string> | undefined,
|
|
198
|
+
): NodeJS.ProcessEnv {
|
|
134
199
|
const normalized = params && typeof params === 'object' && !Array.isArray(params) ? params : {};
|
|
135
200
|
const env: NodeJS.ProcessEnv = {
|
|
136
201
|
...process.env,
|
|
202
|
+
...normalizeContainerEnv(containerEnv),
|
|
137
203
|
WORKLOAD_PARAMS: JSON.stringify(normalized),
|
|
138
204
|
};
|
|
139
205
|
for (const [key, value] of Object.entries(normalized)) {
|
|
@@ -158,7 +224,7 @@ function buildWorkloadEnv(params: Record<string, unknown> | undefined): NodeJS.P
|
|
|
158
224
|
*/
|
|
159
225
|
export class WorkloadExecutor {
|
|
160
226
|
private active: ActiveRun | null = null;
|
|
161
|
-
private activePromise: Promise<
|
|
227
|
+
private activePromise: Promise<WorkloadBatchOutcome> | null = null;
|
|
162
228
|
private pendingResults: WorkloadResult[] = [];
|
|
163
229
|
|
|
164
230
|
/** 当前是否有批次正在执行。 */
|
|
@@ -207,7 +273,7 @@ export class WorkloadExecutor {
|
|
|
207
273
|
/**
|
|
208
274
|
* 执行一个批次。若已有批次在跑,先终止其进程组并停止推进,再执行新批次。
|
|
209
275
|
*/
|
|
210
|
-
runBatch(payload: WorkloadRunPayload, onResult: WorkloadResultSink): Promise<
|
|
276
|
+
runBatch(payload: WorkloadRunPayload, onResult: WorkloadResultSink): Promise<WorkloadBatchOutcome> {
|
|
211
277
|
this.interruptActive();
|
|
212
278
|
|
|
213
279
|
const run: ActiveRun = { cancelled: false, child: null };
|
|
@@ -227,22 +293,33 @@ export class WorkloadExecutor {
|
|
|
227
293
|
payload: WorkloadRunPayload,
|
|
228
294
|
onResult: WorkloadResultSink,
|
|
229
295
|
run: ActiveRun,
|
|
230
|
-
): Promise<
|
|
296
|
+
): Promise<WorkloadBatchOutcome> {
|
|
231
297
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
232
|
-
|
|
298
|
+
const total = items.length;
|
|
299
|
+
let failedCount = 0;
|
|
300
|
+
const outcome = (): WorkloadBatchOutcome => ({
|
|
301
|
+
cancelled: run.cancelled,
|
|
302
|
+
failedCount,
|
|
303
|
+
total,
|
|
304
|
+
});
|
|
305
|
+
if (items.length === 0) return outcome();
|
|
233
306
|
|
|
234
307
|
const budgetMs = typeof payload.budgetMs === 'number' && payload.budgetMs > 0 ? payload.budgetMs : null;
|
|
235
308
|
const batchStart = Date.now();
|
|
236
309
|
const phase = payload.phase;
|
|
310
|
+
const containerEnv = normalizeContainerEnv(payload.env);
|
|
311
|
+
const secrets = collectSecretValues(process.env, containerEnv);
|
|
237
312
|
|
|
238
313
|
for (const item of items) {
|
|
239
|
-
if (run.cancelled) return;
|
|
314
|
+
if (run.cancelled) return outcome();
|
|
240
315
|
|
|
241
316
|
// 必须项短路(省时优化,服务端仍为唯一裁决):init 阶段的必须项失败/超时后,
|
|
242
317
|
// 上报本条结果即结束本批,不再启动后续条目。post 批次不参与必须判定。
|
|
243
318
|
const shortCircuitOnFailure = phase === 'init' && item.required === true;
|
|
244
319
|
const reportThenMaybeStop = (result: WorkloadResult): boolean => {
|
|
245
|
-
|
|
320
|
+
if (result.status !== 'success') failedCount += 1;
|
|
321
|
+
const redactedLog = redactSecrets(result.log, secrets);
|
|
322
|
+
this.report(onResult, redactedLog === result.log ? result : { ...result, log: redactedLog });
|
|
246
323
|
return shortCircuitOnFailure && (result.status === 'failed' || result.status === 'timeout');
|
|
247
324
|
};
|
|
248
325
|
|
|
@@ -251,21 +328,22 @@ export class WorkloadExecutor {
|
|
|
251
328
|
const remaining = budgetMs - (Date.now() - batchStart);
|
|
252
329
|
if (remaining <= 0) {
|
|
253
330
|
// 预算耗尽:后续条目不再启动进程,直接回报 timeout
|
|
254
|
-
if (reportThenMaybeStop(this.emptyResult(item.logId, 'timeout', ''))) return;
|
|
331
|
+
if (reportThenMaybeStop(this.emptyResult(item.logId, 'timeout', ''))) return outcome();
|
|
255
332
|
continue;
|
|
256
333
|
}
|
|
257
334
|
effectiveTimeoutMs = Math.min(item.timeoutMs, remaining);
|
|
258
335
|
}
|
|
259
336
|
|
|
260
337
|
if (!item.script || item.script.trim().length === 0) {
|
|
261
|
-
if (reportThenMaybeStop(this.emptyResult(item.logId, 'failed', EMPTY_SCRIPT_MESSAGE))) return;
|
|
338
|
+
if (reportThenMaybeStop(this.emptyResult(item.logId, 'failed', EMPTY_SCRIPT_MESSAGE))) return outcome();
|
|
262
339
|
continue;
|
|
263
340
|
}
|
|
264
341
|
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
342
|
+
const itemOutcome = await this.executeItem(item, effectiveTimeoutMs, run, containerEnv);
|
|
343
|
+
if (itemOutcome.cancelled) return outcome();
|
|
344
|
+
if (itemOutcome.result && reportThenMaybeStop(itemOutcome.result)) return outcome();
|
|
268
345
|
}
|
|
346
|
+
return outcome();
|
|
269
347
|
}
|
|
270
348
|
|
|
271
349
|
private report(onResult: WorkloadResultSink, result: WorkloadResult): void {
|
|
@@ -281,7 +359,12 @@ export class WorkloadExecutor {
|
|
|
281
359
|
return { logId, status, exitCode: null, log, startedAt: now, finishedAt: now };
|
|
282
360
|
}
|
|
283
361
|
|
|
284
|
-
private executeItem(
|
|
362
|
+
private executeItem(
|
|
363
|
+
item: WorkloadItem,
|
|
364
|
+
timeoutMs: number,
|
|
365
|
+
run: ActiveRun,
|
|
366
|
+
containerEnv: Record<string, string>,
|
|
367
|
+
): Promise<ItemOutcome> {
|
|
285
368
|
return new Promise<ItemOutcome>((resolve) => {
|
|
286
369
|
if (run.cancelled) {
|
|
287
370
|
resolve({ cancelled: true });
|
|
@@ -294,7 +377,7 @@ export class WorkloadExecutor {
|
|
|
294
377
|
child = spawn('bash', ['-c', item.script], {
|
|
295
378
|
cwd: homedir(),
|
|
296
379
|
detached: true,
|
|
297
|
-
env: buildWorkloadEnv(item.params),
|
|
380
|
+
env: buildWorkloadEnv(item.params, containerEnv),
|
|
298
381
|
});
|
|
299
382
|
} catch (error) {
|
|
300
383
|
resolve({
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerHarnessCommands } from '../../src/commands/harness.js';
|
|
4
|
+
import type { HarnessPrepareResult } from '../../src/harness/prepare.js';
|
|
5
|
+
|
|
6
|
+
function buildProgram(deps: Parameters<typeof registerHarnessCommands>[1] = {}) {
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program.name('agent-gear').exitOverride();
|
|
9
|
+
registerHarnessCommands(program, deps);
|
|
10
|
+
return program;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SUCCESS_RESULT: HarnessPrepareResult = {
|
|
14
|
+
opencodeVersion: '1.2.3',
|
|
15
|
+
env: {
|
|
16
|
+
modelBaseUrl: 'http://admin-api:8002/v1',
|
|
17
|
+
defaultWorkspace: '/root/workspace',
|
|
18
|
+
modelCount: 2,
|
|
19
|
+
},
|
|
20
|
+
config: {
|
|
21
|
+
path: '/root/.config/opencode/opencode.jsonc',
|
|
22
|
+
created: true,
|
|
23
|
+
modelCount: 2,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('agent-gear harness command (T-AG15)', () => {
|
|
28
|
+
const originalExitCode = process.exitCode;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
32
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
33
|
+
process.exitCode = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
process.exitCode = originalExitCode;
|
|
39
|
+
delete process.env.ZAG_MODEL_API_KEY;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows prepare in `agent-gear harness --help`', async () => {
|
|
43
|
+
let output = '';
|
|
44
|
+
const program = buildProgram();
|
|
45
|
+
program.configureOutput({ writeOut: (text) => { output += text; }, writeErr: () => {} });
|
|
46
|
+
|
|
47
|
+
await program.parseAsync(['node', 'agent-gear', 'harness', '--help']).catch(() => {});
|
|
48
|
+
|
|
49
|
+
expect(output).toContain('prepare');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects unknown subcommands with a non-zero exit code', async () => {
|
|
53
|
+
const program = buildProgram();
|
|
54
|
+
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
|
55
|
+
|
|
56
|
+
await expect(
|
|
57
|
+
program.parseAsync(['node', 'agent-gear', 'harness', 'bogus']),
|
|
58
|
+
).rejects.toMatchObject({ exitCode: 1 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('prints a secret-free summary on success', async () => {
|
|
62
|
+
const prepare = vi.fn(async () => SUCCESS_RESULT);
|
|
63
|
+
const program = buildProgram({ prepare });
|
|
64
|
+
|
|
65
|
+
await program.parseAsync(['node', 'agent-gear', 'harness', 'prepare']);
|
|
66
|
+
|
|
67
|
+
expect(prepare).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(process.exitCode).toBeUndefined();
|
|
69
|
+
const output = vi.mocked(console.log).mock.calls.flat().join(' ');
|
|
70
|
+
expect(output).toContain('OpenCode harness prepared');
|
|
71
|
+
expect(output).toContain('version=1.2.3');
|
|
72
|
+
expect(output).not.toContain('zag_sk_');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('exits non-zero and redacts secrets when prepare fails', async () => {
|
|
76
|
+
process.env.ZAG_MODEL_API_KEY = 'zag_sk_super_secret';
|
|
77
|
+
const prepare = vi.fn(async () => {
|
|
78
|
+
throw new Error('failed with key zag_sk_super_secret');
|
|
79
|
+
});
|
|
80
|
+
const program = buildProgram({ prepare });
|
|
81
|
+
|
|
82
|
+
await program.parseAsync(['node', 'agent-gear', 'harness', 'prepare']);
|
|
83
|
+
|
|
84
|
+
expect(process.exitCode).toBe(1);
|
|
85
|
+
const errors = vi.mocked(console.error).mock.calls.flat().join(' ');
|
|
86
|
+
expect(errors).toContain('harness prepare failed');
|
|
87
|
+
expect(errors).not.toContain('zag_sk_super_secret');
|
|
88
|
+
expect(errors).toContain('***');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -229,7 +229,7 @@ describe('SocketIO Client', () => {
|
|
|
229
229
|
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
230
230
|
key: 'zag_k_1_abc123',
|
|
231
231
|
hostname: 'my-machine',
|
|
232
|
-
version: '2.
|
|
232
|
+
version: '2.5.0',
|
|
233
233
|
resumeToken: undefined,
|
|
234
234
|
homeDir: '/home/user',
|
|
235
235
|
platform: 'linux',
|
|
@@ -247,7 +247,7 @@ describe('SocketIO Client', () => {
|
|
|
247
247
|
expect(mockSocket.emit).toHaveBeenCalledWith('register', {
|
|
248
248
|
key: 'zag_k_1_abc123',
|
|
249
249
|
hostname: 'test-machine',
|
|
250
|
-
version: '2.
|
|
250
|
+
version: '2.5.0',
|
|
251
251
|
resumeToken: undefined,
|
|
252
252
|
homeDir: '/home/user',
|
|
253
253
|
platform: 'linux',
|
|
@@ -328,6 +328,144 @@ describe('SocketIO Client', () => {
|
|
|
328
328
|
|
|
329
329
|
expect(console.error).toHaveBeenCalledWith('Connection error: connection failed');
|
|
330
330
|
});
|
|
331
|
+
|
|
332
|
+
it('T-AG16: uses an explicit handshake token instead of the key (controller-api standard connection)', () => {
|
|
333
|
+
const mockSocket = createMockSocket();
|
|
334
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
335
|
+
|
|
336
|
+
connectToServer('wss://controller.example.com', 'zag_k_1_host_key', {
|
|
337
|
+
handshakeToken: 'zag_k_1_host_key',
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(io).toHaveBeenCalledWith('wss://controller.example.com', expect.objectContaining({
|
|
341
|
+
auth: { token: 'zag_k_1_host_key' },
|
|
342
|
+
}));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('T-AG16b: never puts the host key into the URL/query and supports handshakeToken=false', () => {
|
|
346
|
+
const mockSocket = createMockSocket();
|
|
347
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
348
|
+
|
|
349
|
+
connectToServer('wss://controller.example.com?token=leaked', 'zag_k_1_host_key', {
|
|
350
|
+
handshakeToken: false,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(io).toHaveBeenCalledWith(expect.not.stringContaining('token='), expect.objectContaining({ auth: {} }));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('T-AG3: forwards the full registered payload (including initializing) to onRegistered', () => {
|
|
357
|
+
const mockSocket = createMockSocket();
|
|
358
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
359
|
+
const onRegistered = vi.fn();
|
|
360
|
+
|
|
361
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { onRegistered });
|
|
362
|
+
mockSocket._trigger('registered', { controllerId: 'ctrl-1', initializing: true });
|
|
363
|
+
|
|
364
|
+
expect(onRegistered).toHaveBeenCalledWith({ controllerId: 'ctrl-1', initializing: true });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('T-AG4: invokes onWorkloadBatchComplete after an init batch ends', async () => {
|
|
368
|
+
const mockSocket = createMockSocket();
|
|
369
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
370
|
+
const onWorkloadBatchComplete = vi.fn();
|
|
371
|
+
|
|
372
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { onWorkloadBatchComplete });
|
|
373
|
+
mockSocket._trigger('workload:run', {
|
|
374
|
+
phase: 'init',
|
|
375
|
+
// 空脚本立即产生失败结果(不需要真实 spawn),用于锚定「批次结束即回调且摘要反映失败」
|
|
376
|
+
items: [{ logId: 'init-1', workloadName: 'Init', script: '', timeoutMs: 5000 }],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await vi.waitFor(() => {
|
|
380
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('workload:result', expect.objectContaining({ logId: 'init-1' }));
|
|
381
|
+
expect(onWorkloadBatchComplete).toHaveBeenCalledWith({
|
|
382
|
+
phase: 'init',
|
|
383
|
+
itemCount: 1,
|
|
384
|
+
status: 'failed',
|
|
385
|
+
cancelled: false,
|
|
386
|
+
failedCount: 1,
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('T-AG4b: invokes onWorkloadBatchComplete even when a required init item fails', async () => {
|
|
392
|
+
const mockSocket = createMockSocket();
|
|
393
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
394
|
+
const onWorkloadBatchComplete = vi.fn();
|
|
395
|
+
|
|
396
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { onWorkloadBatchComplete });
|
|
397
|
+
mockSocket._trigger('workload:run', {
|
|
398
|
+
phase: 'init',
|
|
399
|
+
items: [{ logId: 'init-fail', workloadName: 'Fail', script: '', timeoutMs: 5000, required: true }],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await vi.waitFor(() => {
|
|
403
|
+
expect(onWorkloadBatchComplete).toHaveBeenCalledWith({
|
|
404
|
+
phase: 'init',
|
|
405
|
+
itemCount: 1,
|
|
406
|
+
status: 'failed',
|
|
407
|
+
cancelled: false,
|
|
408
|
+
failedCount: 1,
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('T-AG18: skips the local MCP server when skipLocalMcpStart is set (dual connection singleton)', () => {
|
|
414
|
+
const mockSocket = createMockSocket();
|
|
415
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
416
|
+
|
|
417
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { skipLocalMcpStart: true });
|
|
418
|
+
|
|
419
|
+
expect(localServerInstances).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('T-AG17: delegates shutdown to onShutdown without clearing local config or exiting', () => {
|
|
423
|
+
const mockSocket = createMockSocket();
|
|
424
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
425
|
+
const onShutdown = vi.fn();
|
|
426
|
+
|
|
427
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', { onShutdown });
|
|
428
|
+
mockSocket._trigger('shutdown', { reason: 'container destroyed' });
|
|
429
|
+
|
|
430
|
+
expect(onShutdown).toHaveBeenCalledWith({ reason: 'container destroyed' });
|
|
431
|
+
expect(clearConfigKey).not.toHaveBeenCalled();
|
|
432
|
+
expect(process.exit).not.toHaveBeenCalled();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('T-AG16c: does not handle workload events when handleWorkload=false', () => {
|
|
436
|
+
const mockSocket = createMockSocket();
|
|
437
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
438
|
+
const onWorkloadBatchComplete = vi.fn();
|
|
439
|
+
|
|
440
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123', {
|
|
441
|
+
handleWorkload: false,
|
|
442
|
+
onWorkloadBatchComplete,
|
|
443
|
+
});
|
|
444
|
+
mockSocket._trigger('connect');
|
|
445
|
+
mockSocket._trigger('workload:run', {
|
|
446
|
+
phase: 'init',
|
|
447
|
+
items: [{ logId: 'x', workloadName: 'X', script: 'echo nope', timeoutMs: 5000 }],
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(onWorkloadBatchComplete).not.toHaveBeenCalled();
|
|
451
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('workload:result', expect.anything());
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('T-AG13: redacts secret values from invalid workload payload logs', () => {
|
|
455
|
+
const mockSocket = createMockSocket();
|
|
456
|
+
vi.mocked(io).mockReturnValue(mockSocket as never);
|
|
457
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
458
|
+
|
|
459
|
+
connectToServer('http://localhost:4001', 'zag_k_1_abc123');
|
|
460
|
+
mockSocket._trigger('workload:run', {
|
|
461
|
+
phase: 'init',
|
|
462
|
+
items: 'invalid',
|
|
463
|
+
env: { ZAG_MODEL_API_KEY: 'zag_sk_super_secret' },
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.not.stringContaining('zag_sk_super_secret'));
|
|
467
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('***'));
|
|
468
|
+
});
|
|
331
469
|
});
|
|
332
470
|
|
|
333
471
|
describe('unregister', () => {
|