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.
Files changed (93) hide show
  1. package/AGENTS.md +64 -5
  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 +24 -1
  67. package/dist/workload-executor.d.ts.map +1 -1
  68. package/dist/workload-executor.js +78 -16
  69. package/dist/workload-executor.js.map +1 -1
  70. package/dist/workload-executor.test.js +99 -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 +120 -0
  91. package/src/workload-executor.ts +100 -17
  92. package/tests/commands/harness.test.ts +90 -0
  93. package/tests/socketio-client.test.ts +140 -2
@@ -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 都从当前 `process.env` 重新派生,参数 env 仅对当前 item 生效、不污染后续脚本;
192
+ * 每次 spawn 都重新派生,参数 env 仅对当前 item 生效、不污染后续脚本;
131
193
  * 不修改脚本文本、不做字符串替换(避免注入)。
132
194
  */
133
- function buildWorkloadEnv(params: Record<string, unknown> | undefined): NodeJS.ProcessEnv {
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<void> | null = null;
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<void> {
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<void> {
296
+ ): Promise<WorkloadBatchOutcome> {
231
297
  const items = Array.isArray(payload?.items) ? payload.items : [];
232
- if (items.length === 0) return;
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
- this.report(onResult, result);
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 outcome = await this.executeItem(item, effectiveTimeoutMs, run);
266
- if (outcome.cancelled) return;
267
- if (outcome.result && reportThenMaybeStop(outcome.result)) return;
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(item: WorkloadItem, timeoutMs: number, run: ActiveRun): Promise<ItemOutcome> {
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.4.0',
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.4.0',
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', () => {