@the-open-engine/zeroshot 6.10.0 → 6.10.2
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/cli/index.js +16 -0
- package/cluster-hooks/block-ask-user-question.py +2 -3
- package/cluster-hooks/block-dangerous-git.py +2 -3
- package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/claude.js +42 -1
- package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
- package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
- package/lib/agent-cli-provider/contract-options.js +2 -0
- package/lib/agent-cli-provider/contract-options.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +6 -2
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/agent-lifecycle.js +13 -6
- package/src/agent/agent-task-executor.js +527 -313
- package/src/agent-cli-provider/adapters/claude.ts +46 -1
- package/src/agent-cli-provider/contract-options.ts +6 -0
- package/src/agent-cli-provider/types.ts +9 -6
- package/src/claude-task-runner.js +168 -44
- package/src/darwin-keychain-boundary.js +174 -0
- package/src/task-spawn-cleanup-ownership.js +82 -0
- package/src/worktree-claude-config.js +193 -85
- package/src/worktree-tooling-env.js +3 -0
- package/task-lib/attachable-watcher.js +93 -267
- package/task-lib/command-spec-cleanup.js +219 -0
- package/task-lib/commands/clean.js +35 -5
- package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
- package/task-lib/commands/kill.js +117 -4
- package/task-lib/commands/status.js +1 -0
- package/task-lib/runner.js +61 -4
- package/task-lib/store.js +68 -6
- package/task-lib/watcher-output-runtime.js +284 -0
- package/task-lib/watcher.js +124 -257
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getString, isRecord, stringifyJson, tryParseJson } from '../json';
|
|
2
|
+
import { contractError } from '../contract-errors';
|
|
2
3
|
import {
|
|
3
4
|
type BuildProviderCommandOptions,
|
|
4
5
|
type ClaudeCliFeatures,
|
|
@@ -66,6 +67,10 @@ function detectCliFeatures(helpText?: string | null): ClaudeCliFeatures {
|
|
|
66
67
|
supportsVerbose: unknown ? true : /--verbose/.test(help),
|
|
67
68
|
supportsModel: unknown ? true : /--model/.test(help),
|
|
68
69
|
supportsEffort: unknown ? true : /--effort/.test(help),
|
|
70
|
+
// Settings carry mandatory safety hooks, so an unprobed or old CLI must
|
|
71
|
+
// fail closed before a provider process is spawned.
|
|
72
|
+
supportsSettings: !unknown && /--settings/.test(help),
|
|
73
|
+
supportsMcpConfig: !unknown && /--mcp-config/.test(help),
|
|
69
74
|
supportsResume: unknown ? true : /--resume/.test(help),
|
|
70
75
|
unknown,
|
|
71
76
|
};
|
|
@@ -131,6 +136,40 @@ function addSessionArgs(args: string[], options: BuildProviderCommandOptions): v
|
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
function addSettingsArgs(args: string[], options: BuildProviderCommandOptions): void {
|
|
140
|
+
const settingsPath = options.claudeSettingsFile?.trim();
|
|
141
|
+
if (settingsPath) {
|
|
142
|
+
args.push('--settings', settingsPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addMcpConfigArgs(args: string[], options: BuildProviderCommandOptions): void {
|
|
147
|
+
if (!options.mcpConfig?.length) return;
|
|
148
|
+
args.push('--mcp-config', ...options.mcpConfig);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function failClosedUnsupportedRunConfig(options: BuildProviderCommandOptions): void {
|
|
152
|
+
const features = optionFeatures(options);
|
|
153
|
+
if (options.claudeSettingsFile?.trim() && features.supportsSettings === false) {
|
|
154
|
+
throw contractError({
|
|
155
|
+
code: 'invalid-field',
|
|
156
|
+
field: 'options.cliFeatures.supportsSettings',
|
|
157
|
+
exitCode: 2,
|
|
158
|
+
message:
|
|
159
|
+
'Claude CLI does not advertise --settings support required for Zeroshot safety hooks. Upgrade Claude Code before running this task.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (options.mcpConfig?.length && features.supportsMcpConfig === false) {
|
|
163
|
+
throw contractError({
|
|
164
|
+
code: 'invalid-field',
|
|
165
|
+
field: 'options.cliFeatures.supportsMcpConfig',
|
|
166
|
+
exitCode: 2,
|
|
167
|
+
message:
|
|
168
|
+
'Claude CLI does not advertise --mcp-config support required to preserve repository MCP tools. Upgrade Claude Code before running this task.',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
134
173
|
function extractSessionId(line: string): string | null {
|
|
135
174
|
const event = tryParseJson(line.trim());
|
|
136
175
|
if (!isRecord(event)) return null;
|
|
@@ -185,15 +224,21 @@ function collectWarnings(options: BuildProviderCommandOptions): WarningMetadata[
|
|
|
185
224
|
}
|
|
186
225
|
|
|
187
226
|
function buildCommand(context: string, options: BuildProviderCommandOptions = {}): CommandSpec {
|
|
227
|
+
failClosedUnsupportedRunConfig(options);
|
|
188
228
|
const { command, args: commandPrefix } = resolveClaudeCommand();
|
|
189
|
-
const args: string[] = [...commandPrefix, '--print'
|
|
229
|
+
const args: string[] = [...commandPrefix, '--print'];
|
|
190
230
|
const authEnv = options.authEnv ?? {};
|
|
191
231
|
|
|
232
|
+
// --mcp-config is variadic. A following option terminates its values before
|
|
233
|
+
// the positional prompt.
|
|
234
|
+
addMcpConfigArgs(args, options);
|
|
235
|
+
args.push('--input-format', 'text');
|
|
192
236
|
addOutputArgs(args, options);
|
|
193
237
|
addSchemaArgs(args, options);
|
|
194
238
|
addModelArgs(args, options);
|
|
195
239
|
addAutoApproveArgs(args, options);
|
|
196
240
|
addSessionArgs(args, options);
|
|
241
|
+
addSettingsArgs(args, options);
|
|
197
242
|
|
|
198
243
|
args.push(context);
|
|
199
244
|
|
|
@@ -23,6 +23,7 @@ const CLI_FEATURE_FIELDS = [
|
|
|
23
23
|
'supportsVerbose',
|
|
24
24
|
'supportsModel',
|
|
25
25
|
'supportsEffort',
|
|
26
|
+
'supportsSettings',
|
|
26
27
|
'supportsJson',
|
|
27
28
|
'supportsOutputSchema',
|
|
28
29
|
'supportsDir',
|
|
@@ -209,6 +210,11 @@ function normalizeBuildOptions(value: Record<string, unknown>): BuildProviderCom
|
|
|
209
210
|
'continueSession',
|
|
210
211
|
optionalBooleanValue(value.continueSession, 'options.continueSession')
|
|
211
212
|
);
|
|
213
|
+
addDefined(
|
|
214
|
+
result,
|
|
215
|
+
'claudeSettingsFile',
|
|
216
|
+
optionalStringValue(value.claudeSettingsFile, 'options.claudeSettingsFile')
|
|
217
|
+
);
|
|
212
218
|
addDefined(result, 'cliFeatures', optionalCliFeatures(value.cliFeatures));
|
|
213
219
|
addDefined(result, 'mcpConfig', optionalMcpConfig(value.mcpConfig));
|
|
214
220
|
addDefined(
|
|
@@ -101,6 +101,8 @@ export interface ClaudeCliFeatures extends BaseCliFeatures {
|
|
|
101
101
|
readonly supportsVerbose: boolean;
|
|
102
102
|
readonly supportsModel: boolean;
|
|
103
103
|
readonly supportsEffort: boolean;
|
|
104
|
+
readonly supportsSettings: boolean;
|
|
105
|
+
readonly supportsMcpConfig: boolean;
|
|
104
106
|
readonly supportsResume: boolean;
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -195,6 +197,7 @@ export interface CliFeatureOverrides {
|
|
|
195
197
|
readonly supportsVerbose?: boolean;
|
|
196
198
|
readonly supportsModel?: boolean;
|
|
197
199
|
readonly supportsEffort?: boolean;
|
|
200
|
+
readonly supportsSettings?: boolean;
|
|
198
201
|
readonly supportsJson?: boolean;
|
|
199
202
|
readonly supportsOutputSchema?: boolean;
|
|
200
203
|
readonly supportsDir?: boolean;
|
|
@@ -231,10 +234,10 @@ export interface CliFeatureOverrides {
|
|
|
231
234
|
}
|
|
232
235
|
|
|
233
236
|
export interface CleanupMetadata {
|
|
234
|
-
readonly kind: 'temp-file';
|
|
237
|
+
readonly kind: 'temp-file' | 'temp-directory';
|
|
235
238
|
readonly provider: ProviderId;
|
|
236
239
|
readonly path: string;
|
|
237
|
-
readonly reason: 'output-schema';
|
|
240
|
+
readonly reason: 'output-schema' | 'settings-overlay';
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
export interface WarningMetadata {
|
|
@@ -268,14 +271,14 @@ export interface BuildProviderCommandOptions {
|
|
|
268
271
|
readonly autoApprove?: boolean;
|
|
269
272
|
readonly resumeSessionId?: string;
|
|
270
273
|
readonly continueSession?: boolean;
|
|
274
|
+
readonly claudeSettingsFile?: string;
|
|
271
275
|
readonly cliFeatures?: CliFeatureOverrides;
|
|
272
276
|
readonly authEnv?: Readonly<Record<string, string>>;
|
|
273
277
|
readonly strictSchema?: boolean;
|
|
274
278
|
readonly gateway?: GatewayBuildOptions;
|
|
275
|
-
// MCP server configs forwarded to providers that accept an MCP config CLI flag
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// entry. Providers whose adapter models no MCP flag ignore this field.
|
|
279
|
+
// MCP server configs forwarded to providers that accept an MCP config CLI flag. Claude accepts
|
|
280
|
+
// one variadic `--mcp-config` followed by file paths; adapters such as Copilot accept repeated
|
|
281
|
+
// flags with inline JSON so configs survive container path translation.
|
|
279
282
|
readonly mcpConfig?: readonly string[];
|
|
280
283
|
}
|
|
281
284
|
|
|
@@ -12,7 +12,24 @@ const { loadSettings } = require('../lib/settings');
|
|
|
12
12
|
const { normalizeProviderName } = require('../lib/provider-names');
|
|
13
13
|
const { getProvider } = require('./providers');
|
|
14
14
|
const { prependWorktreeToolBinToEnv } = require('./worktree-tooling-env');
|
|
15
|
-
const {
|
|
15
|
+
const { applyDarwinKeychainBoundaryToEnv } = require('./darwin-keychain-boundary');
|
|
16
|
+
const { getTask, getTaskBySpawnOwnershipToken } = require('../task-lib/store.js');
|
|
17
|
+
const {
|
|
18
|
+
TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
|
|
19
|
+
cleanupCallerOwnedCommand,
|
|
20
|
+
callerOwnsCommandCleanup,
|
|
21
|
+
createTaskSpawnOwnershipToken,
|
|
22
|
+
requireTaskIdFromWrapperResult,
|
|
23
|
+
trackTaskWrapperCleanupOwnership,
|
|
24
|
+
} = require('./task-spawn-cleanup-ownership');
|
|
25
|
+
const {
|
|
26
|
+
CLAUDE_MCP_CONFIG_ENV,
|
|
27
|
+
CLAUDE_SETTINGS_ENV,
|
|
28
|
+
cleanupClaudeSettingsOverlay,
|
|
29
|
+
prepareClaudeSettingsOverlay,
|
|
30
|
+
resolveContainerMcpConfigPath,
|
|
31
|
+
resolveRepoMcpConfigPath,
|
|
32
|
+
} = require('./worktree-claude-config');
|
|
16
33
|
const {
|
|
17
34
|
appendTaskRunModelArgs,
|
|
18
35
|
wrapTaskRunWithIsolatedSettings,
|
|
@@ -80,6 +97,28 @@ function runCommand(command, args, options = {}, callback = null) {
|
|
|
80
97
|
});
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
const TASK_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed', 'stale', 'cancelled']);
|
|
101
|
+
|
|
102
|
+
async function cleanupPersistedTaskAfterLaunchFailure(ctPath, taskId) {
|
|
103
|
+
let lastError = null;
|
|
104
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
105
|
+
let commandError = null;
|
|
106
|
+
try {
|
|
107
|
+
await runCommand(ctPath, ['kill', taskId], { timeout: 10000 });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
commandError = error;
|
|
110
|
+
}
|
|
111
|
+
const task = getTask(taskId);
|
|
112
|
+
if (!task || (TASK_TERMINAL_STATUSES.has(task.status) && !task.commandCleanup)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
lastError =
|
|
116
|
+
commandError ||
|
|
117
|
+
new Error(`Task ${taskId} termination and command cleanup were not confirmed`);
|
|
118
|
+
}
|
|
119
|
+
throw lastError || new Error(`Task ${taskId} cleanup failed`);
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
function runCommandSync(command, args, options = {}) {
|
|
84
123
|
const timeout = options.timeout ?? 30000;
|
|
85
124
|
const result = spawnSync(command, args, { ...options, timeout });
|
|
@@ -95,6 +134,17 @@ function runCommandSync(command, args, options = {}) {
|
|
|
95
134
|
return result.stdout?.toString() || '';
|
|
96
135
|
}
|
|
97
136
|
|
|
137
|
+
function appendIsolatedMcpConfigArgs(command, provider, options) {
|
|
138
|
+
if (provider !== 'claude') return;
|
|
139
|
+
const mcpConfigPath = resolveContainerMcpConfigPath({
|
|
140
|
+
cwd: options.cwd || process.cwd(),
|
|
141
|
+
worktreePath: options.worktreePath || null,
|
|
142
|
+
});
|
|
143
|
+
if (mcpConfigPath) {
|
|
144
|
+
command.push('--mcp-config', mcpConfigPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
98
148
|
class ClaudeTaskRunner extends TaskRunner {
|
|
99
149
|
/**
|
|
100
150
|
* @param {Object} options
|
|
@@ -102,6 +152,7 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
102
152
|
* @param {boolean} [options.quiet] - Suppress console logging
|
|
103
153
|
* @param {number} [options.timeout] - Task timeout in ms (default: 1 hour)
|
|
104
154
|
* @param {Function} [options.onOutput] - Callback for output lines
|
|
155
|
+
* @param {Function} [options.applyDarwinKeychainBoundary] - Boundary injection seam for tests
|
|
105
156
|
*/
|
|
106
157
|
constructor(options = {}) {
|
|
107
158
|
super();
|
|
@@ -109,6 +160,8 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
109
160
|
this.quiet = options.quiet || false;
|
|
110
161
|
this.timeout = options.timeout || 60 * 60 * 1000;
|
|
111
162
|
this.onOutput = options.onOutput || null;
|
|
163
|
+
this.applyDarwinKeychainBoundary =
|
|
164
|
+
options.applyDarwinKeychainBoundary || applyDarwinKeychainBoundaryToEnv;
|
|
112
165
|
}
|
|
113
166
|
|
|
114
167
|
/**
|
|
@@ -191,14 +244,20 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
191
244
|
worktreePath,
|
|
192
245
|
});
|
|
193
246
|
|
|
194
|
-
|
|
247
|
+
let taskId;
|
|
248
|
+
try {
|
|
249
|
+
taskId = await this._spawnAndGetTaskId(ctPath, args, cwd, spawnEnv, agentId);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
cleanupCallerOwnedCommand(error, () =>
|
|
252
|
+
cleanupClaudeSettingsOverlay(spawnEnv[CLAUDE_SETTINGS_ENV])
|
|
253
|
+
);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
195
256
|
|
|
257
|
+
// Once a task ID is returned, the detached watcher owns provider
|
|
258
|
+
// termination and command cleanup.
|
|
196
259
|
this._log(`📋 [${agentId}]: Following zeroshot logs for ${taskId}`);
|
|
197
|
-
|
|
198
|
-
// Wait for task registration
|
|
199
260
|
await this._waitForTaskReady(ctPath, taskId);
|
|
200
|
-
|
|
201
|
-
// Follow logs until completion
|
|
202
261
|
return this._followLogs(ctPath, taskId, agentId);
|
|
203
262
|
}
|
|
204
263
|
|
|
@@ -321,16 +380,30 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
321
380
|
const spawnEnv = {
|
|
322
381
|
...process.env,
|
|
323
382
|
};
|
|
383
|
+
let claudeSettingsPath = null;
|
|
324
384
|
if (providerName === 'claude' && resolvedModelSpec?.model) {
|
|
325
385
|
spawnEnv.ANTHROPIC_MODEL = resolvedModelSpec.model;
|
|
326
386
|
}
|
|
327
387
|
if (providerName === 'claude') {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
388
|
+
claudeSettingsPath = prepareClaudeSettingsOverlay({
|
|
389
|
+
includeDangerousGit: Boolean(worktreePath),
|
|
390
|
+
});
|
|
391
|
+
spawnEnv[CLAUDE_SETTINGS_ENV] = claudeSettingsPath;
|
|
392
|
+
const mcpConfigPath = resolveRepoMcpConfigPath({ cwd, worktreePath });
|
|
393
|
+
if (mcpConfigPath) {
|
|
394
|
+
spawnEnv[CLAUDE_MCP_CONFIG_ENV] = mcpConfigPath;
|
|
331
395
|
}
|
|
332
396
|
}
|
|
333
397
|
|
|
398
|
+
// KEYCHAIN BOUNDARY (darwin only): keep non-interactive worker descendants
|
|
399
|
+
// away from the user's GUI Keychain session (issue #704).
|
|
400
|
+
try {
|
|
401
|
+
this.applyDarwinKeychainBoundary(spawnEnv);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (claudeSettingsPath) cleanupClaudeSettingsOverlay(claudeSettingsPath);
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
|
|
334
407
|
prependWorktreeToolBinToEnv(spawnEnv, { cwd, worktreePath });
|
|
335
408
|
|
|
336
409
|
return spawnEnv;
|
|
@@ -345,16 +418,49 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
345
418
|
* @returns {Promise<string>}
|
|
346
419
|
*/
|
|
347
420
|
_spawnAndGetTaskId(ctPath, args, cwd, spawnEnv, _agentId) {
|
|
421
|
+
const ownershipToken = createTaskSpawnOwnershipToken();
|
|
422
|
+
const findPersistedTaskId = () => getTaskBySpawnOwnershipToken(ownershipToken)?.id || null;
|
|
348
423
|
return new Promise((resolve, reject) => {
|
|
349
424
|
const proc = spawn(ctPath, args, {
|
|
350
425
|
cwd,
|
|
351
426
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
352
|
-
env: spawnEnv,
|
|
427
|
+
env: { ...spawnEnv, [TASK_SPAWN_OWNERSHIP_TOKEN_ENV]: ownershipToken },
|
|
353
428
|
windowsHide: true,
|
|
354
429
|
});
|
|
355
430
|
|
|
356
431
|
let stdout = '';
|
|
357
432
|
let stderr = '';
|
|
433
|
+
let settled = false;
|
|
434
|
+
const classifyCleanupOwnership = trackTaskWrapperCleanupOwnership(findPersistedTaskId);
|
|
435
|
+
const rejectWithOwnership = async (error) => {
|
|
436
|
+
if (settled) return;
|
|
437
|
+
settled = true;
|
|
438
|
+
const classifiedError = classifyCleanupOwnership(error);
|
|
439
|
+
if (!callerOwnsCommandCleanup(classifiedError)) {
|
|
440
|
+
classifiedError.spawnOwnershipToken = ownershipToken;
|
|
441
|
+
let persistedTaskId = null;
|
|
442
|
+
let lookupError = null;
|
|
443
|
+
try {
|
|
444
|
+
persistedTaskId = findPersistedTaskId();
|
|
445
|
+
} catch (lookupFailure) {
|
|
446
|
+
lookupError = lookupFailure;
|
|
447
|
+
}
|
|
448
|
+
classifiedError.taskId = persistedTaskId;
|
|
449
|
+
try {
|
|
450
|
+
if (lookupError) throw lookupError;
|
|
451
|
+
if (persistedTaskId) {
|
|
452
|
+
await cleanupPersistedTaskAfterLaunchFailure(ctPath, persistedTaskId);
|
|
453
|
+
}
|
|
454
|
+
} catch (cleanupError) {
|
|
455
|
+
classifiedError.message += ` Task cleanup was not confirmed: ${cleanupError.message}`;
|
|
456
|
+
classifiedError.permanent = true;
|
|
457
|
+
classifiedError.restartExhausted = true;
|
|
458
|
+
classifiedError.terminationExhausted = true;
|
|
459
|
+
classifiedError.terminationAttempts = persistedTaskId ? 3 : 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
reject(classifiedError);
|
|
463
|
+
};
|
|
358
464
|
|
|
359
465
|
proc.stdout.on('data', (data) => {
|
|
360
466
|
stdout += data.toString();
|
|
@@ -364,21 +470,26 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
364
470
|
stderr += data.toString();
|
|
365
471
|
});
|
|
366
472
|
|
|
367
|
-
proc.on('close', (code) => {
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
473
|
+
proc.on('close', async (code) => {
|
|
474
|
+
if (settled) return;
|
|
475
|
+
try {
|
|
476
|
+
const taskId = requireTaskIdFromWrapperResult({
|
|
477
|
+
code,
|
|
478
|
+
stdout,
|
|
479
|
+
stderr,
|
|
480
|
+
parseTaskId: (output) =>
|
|
481
|
+
output.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/)?.[1],
|
|
482
|
+
persistedTaskId: findPersistedTaskId(),
|
|
483
|
+
});
|
|
484
|
+
settled = true;
|
|
485
|
+
resolve(taskId);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
await rejectWithOwnership(error);
|
|
377
488
|
}
|
|
378
489
|
});
|
|
379
490
|
|
|
380
|
-
proc.on('error', (error) => {
|
|
381
|
-
|
|
491
|
+
proc.on('error', async (error) => {
|
|
492
|
+
await rejectWithOwnership(error);
|
|
382
493
|
});
|
|
383
494
|
});
|
|
384
495
|
}
|
|
@@ -424,6 +535,7 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
424
535
|
let statusCheckInterval = null;
|
|
425
536
|
let resolved = false;
|
|
426
537
|
let lineBuffer = '';
|
|
538
|
+
let cleanupRecoveryPending = false;
|
|
427
539
|
|
|
428
540
|
// Get log file path
|
|
429
541
|
try {
|
|
@@ -558,33 +670,43 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
558
670
|
|
|
559
671
|
statusCheckInterval = setInterval(() => {
|
|
560
672
|
runCommand(ctPath, ['status', taskId], {}, (error, stdout) => {
|
|
561
|
-
if (resolved) return;
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
(
|
|
566
|
-
|
|
567
|
-
|
|
673
|
+
if (resolved || error) return;
|
|
674
|
+
const terminalMatch = stdout.match(/Status:\s+(completed|failed)/i);
|
|
675
|
+
if (!terminalMatch) return;
|
|
676
|
+
if (/Cleanup:\s+pending/i.test(stdout)) {
|
|
677
|
+
if (!cleanupRecoveryPending) {
|
|
678
|
+
cleanupRecoveryPending = true;
|
|
679
|
+
runCommand(ctPath, ['kill', taskId], { timeout: 10000 }, (cleanupError) => {
|
|
680
|
+
cleanupRecoveryPending = false;
|
|
681
|
+
if (cleanupError) {
|
|
682
|
+
this._log(
|
|
683
|
+
`⚠️ [${agentId}]: Terminal cleanup recovery will retry: ${cleanupError.message}`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
568
690
|
|
|
569
|
-
|
|
691
|
+
const success = terminalMatch[1].toLowerCase() === 'completed';
|
|
692
|
+
pollLogFile();
|
|
570
693
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
694
|
+
setTimeout(() => {
|
|
695
|
+
if (resolved) return;
|
|
696
|
+
resolved = true;
|
|
574
697
|
|
|
575
|
-
|
|
576
|
-
|
|
698
|
+
clearInterval(pollInterval);
|
|
699
|
+
clearInterval(statusCheckInterval);
|
|
577
700
|
|
|
578
|
-
|
|
701
|
+
const errorContext = extractErrorContext(success, stdout);
|
|
579
702
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
703
|
+
resolve({
|
|
704
|
+
success,
|
|
705
|
+
output,
|
|
706
|
+
error: errorContext,
|
|
707
|
+
taskId,
|
|
708
|
+
});
|
|
709
|
+
}, 500);
|
|
588
710
|
});
|
|
589
711
|
}, 1000);
|
|
590
712
|
|
|
@@ -664,6 +786,8 @@ class ClaudeTaskRunner extends TaskRunner {
|
|
|
664
786
|
command.push('--json-schema', JSON.stringify(jsonSchema));
|
|
665
787
|
}
|
|
666
788
|
|
|
789
|
+
appendIsolatedMcpConfigArgs(command, provider, options);
|
|
790
|
+
|
|
667
791
|
let finalContext = context;
|
|
668
792
|
if (jsonSchema && desiredOutputFormat === 'json' && runOutputFormat === 'stream-json') {
|
|
669
793
|
finalContext += `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Darwin worker Keychain boundary (issue #704).
|
|
3
|
+
*
|
|
4
|
+
* Non-interactive local/worktree workers are spawned with the host environment,
|
|
5
|
+
* so worker descendants (e.g. `claude doctor` probing Keychain writes through
|
|
6
|
+
* `security -i`) reach the logged-in user's GUI Keychain session and launch
|
|
7
|
+
* SecurityAgent dialogs from a supposedly non-interactive cluster.
|
|
8
|
+
*
|
|
9
|
+
* On darwin, worker spawn envs get a managed shim directory prepended to PATH
|
|
10
|
+
* containing a `security` wrapper that fails closed on interactive invocations
|
|
11
|
+
* (`-i`, `-p`, or no arguments) with a deterministic diagnostic, and execs the
|
|
12
|
+
* real /usr/bin/security for every other subcommand so provider authentication
|
|
13
|
+
* (e.g. `security find-generic-password`) keeps working.
|
|
14
|
+
*
|
|
15
|
+
* Docker isolation never reaches this code path, and non-darwin platforms are
|
|
16
|
+
* left untouched. Set ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1 to opt out.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { randomUUID } = require('node:crypto');
|
|
23
|
+
|
|
24
|
+
const SHIM_DIR_RELATIVE_PATH = path.join('.zeroshot', 'keychain-shim');
|
|
25
|
+
const REAL_SECURITY_PATH = '/usr/bin/security';
|
|
26
|
+
const OPT_OUT_ENV_VAR = 'ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN';
|
|
27
|
+
|
|
28
|
+
function shellQuote(value) {
|
|
29
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildSecurityShimScript(realSecurityPath) {
|
|
33
|
+
return `#!/bin/sh
|
|
34
|
+
# Managed by Zeroshot (src/darwin-keychain-boundary.js). Do not edit.
|
|
35
|
+
#
|
|
36
|
+
# Non-interactive Zeroshot workers must not open the logged-in user's GUI
|
|
37
|
+
# Keychain session (SecurityAgent). Interactive \`security\` invocations fail
|
|
38
|
+
# closed here; every other subcommand is passed through to the real binary so
|
|
39
|
+
# provider authentication keeps working.
|
|
40
|
+
|
|
41
|
+
REAL_SECURITY=${shellQuote(realSecurityPath)}
|
|
42
|
+
|
|
43
|
+
if [ "\${${OPT_OUT_ENV_VAR}:-0}" = "1" ]; then
|
|
44
|
+
exec "$REAL_SECURITY" "$@"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
fail_closed() {
|
|
48
|
+
echo "zeroshot: blocked interactive 'security' invocation from a non-interactive worker (argv: $*)." >&2
|
|
49
|
+
echo "zeroshot: this cluster has no interactive Keychain session, so SecurityAgent prompts are disabled." >&2
|
|
50
|
+
echo "zeroshot: run the cluster with Docker isolation or configure explicit credentials for the tool that attempted Keychain access." >&2
|
|
51
|
+
echo "zeroshot: set ${OPT_OUT_ENV_VAR}=1 to restore interactive Keychain access." >&2
|
|
52
|
+
exit 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# \`security\` without arguments enters interactive mode.
|
|
56
|
+
[ "$#" -eq 0 ] && fail_closed
|
|
57
|
+
|
|
58
|
+
# Global options precede the subcommand. -i (interactive) and -p (prompt,
|
|
59
|
+
# implies -i) must not reach the real binary; option letters may be bundled
|
|
60
|
+
# (e.g. -qi). Scanning stops at the first non-option token (the subcommand).
|
|
61
|
+
for arg in "$@"; do
|
|
62
|
+
case "$arg" in
|
|
63
|
+
-*i*|-*p*) fail_closed "$@" ;;
|
|
64
|
+
-*) ;;
|
|
65
|
+
*) break ;;
|
|
66
|
+
esac
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
exec "$REAL_SECURITY" "$@"
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create (or refresh) the managed shim directory containing the `security`
|
|
75
|
+
* wrapper. Idempotent: the script is only rewritten when its content changes.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} [options]
|
|
78
|
+
* @param {string} [options.shimBaseDir] - Shim directory (tests only); defaults to ~/.zeroshot/keychain-shim.
|
|
79
|
+
* @param {string} [options.realSecurityPath] - Real binary to exec (tests only); defaults to /usr/bin/security.
|
|
80
|
+
* @returns {string} Absolute path of the shim directory.
|
|
81
|
+
*/
|
|
82
|
+
function ensureDarwinKeychainShimDir(options = {}) {
|
|
83
|
+
const shimDir = options.shimBaseDir || path.join(os.homedir(), SHIM_DIR_RELATIVE_PATH);
|
|
84
|
+
const script = buildSecurityShimScript(options.realSecurityPath || REAL_SECURITY_PATH);
|
|
85
|
+
const shimPath = path.join(shimDir, 'security');
|
|
86
|
+
|
|
87
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
let existing = null;
|
|
90
|
+
try {
|
|
91
|
+
existing = fs.readFileSync(shimPath, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
// Missing or unreadable: (re)write below.
|
|
94
|
+
}
|
|
95
|
+
if (existing !== script) {
|
|
96
|
+
const tempPath = path.join(shimDir, `.security.${process.pid}.${randomUUID()}.tmp`);
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(tempPath, script, { mode: 0o755, flag: 'wx' });
|
|
99
|
+
// The creation mode is subject to umask. Set the final mode before the
|
|
100
|
+
// rename so the live path is never observable as non-executable.
|
|
101
|
+
fs.chmodSync(tempPath, 0o755);
|
|
102
|
+
fs.renameSync(tempPath, shimPath);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
try {
|
|
105
|
+
// Remove any unpublished partial file without masking the publication
|
|
106
|
+
// failure that caused this cleanup path.
|
|
107
|
+
fs.rmSync(tempPath, { force: true });
|
|
108
|
+
} catch (cleanupError) {
|
|
109
|
+
error.message += ` Cleanup also failed: ${cleanupError.message}.`;
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// An existing matching shim may have drifted permissions.
|
|
115
|
+
fs.chmodSync(shimPath, 0o755);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return shimDir;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Prepend the Keychain boundary shim to a worker spawn env's PATH.
|
|
123
|
+
*
|
|
124
|
+
* No-op off darwin and when the operator opted out via
|
|
125
|
+
* ZEROSHOT_ALLOW_INTERACTIVE_KEYCHAIN=1. Fails closed (throws) when the shim
|
|
126
|
+
* cannot be installed: spawning the worker anyway would silently re-expose the
|
|
127
|
+
* interactive Keychain session.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} env - Spawn env to mutate (also returned).
|
|
130
|
+
* @param {object} [options]
|
|
131
|
+
* @param {string} [options.platform] - Platform override (tests only).
|
|
132
|
+
* @param {string} [options.shimBaseDir] - See ensureDarwinKeychainShimDir.
|
|
133
|
+
* @param {string} [options.realSecurityPath] - See ensureDarwinKeychainShimDir.
|
|
134
|
+
* @returns {object} The same env object.
|
|
135
|
+
*/
|
|
136
|
+
function applyDarwinKeychainBoundaryToEnv(env, options = {}) {
|
|
137
|
+
const platform = options.platform || process.platform;
|
|
138
|
+
if (platform !== 'darwin') {
|
|
139
|
+
return env;
|
|
140
|
+
}
|
|
141
|
+
if (env[OPT_OUT_ENV_VAR] === '1' || process.env[OPT_OUT_ENV_VAR] === '1') {
|
|
142
|
+
return env;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let shimDir;
|
|
146
|
+
try {
|
|
147
|
+
shimDir = ensureDarwinKeychainShimDir(options);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Failed to install the darwin Keychain boundary shim: ${error.message}. ` +
|
|
151
|
+
`Non-interactive workers must not reach the interactive Keychain session; ` +
|
|
152
|
+
`use Docker isolation or set ${OPT_OUT_ENV_VAR}=1 to opt out.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Darwin environment keys are case-sensitive: descendants consult PATH,
|
|
157
|
+
// never a differently-cased key such as Path. Preserve empty components
|
|
158
|
+
// because POSIX interprets them as the current directory. An absent PATH
|
|
159
|
+
// retains Node/libuv's default Unix search path after the shim, while an
|
|
160
|
+
// explicitly empty PATH retains its empty component (`<shim>:`).
|
|
161
|
+
const existingEntries =
|
|
162
|
+
env.PATH === undefined
|
|
163
|
+
? ['/usr/bin', '/bin']
|
|
164
|
+
: String(env.PATH)
|
|
165
|
+
.split(path.delimiter)
|
|
166
|
+
.filter((entry) => entry !== shimDir);
|
|
167
|
+
env.PATH = [shimDir, ...existingEntries].join(path.delimiter);
|
|
168
|
+
return env;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
applyDarwinKeychainBoundaryToEnv,
|
|
173
|
+
ensureDarwinKeychainShimDir,
|
|
174
|
+
};
|