@fitlab-ai/agent-infra 0.11.12 → 0.11.13
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/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
- package/dist/lib/internal/sandbox-control.js +1 -81
- package/dist/lib/internal/task-finalization.js +26 -19
- package/dist/lib/internal/task-lifecycle.js +1 -1
- package/dist/lib/platform/github-client.js +33 -3
- package/dist/lib/sandbox/commands/create.js +6 -2
- package/dist/lib/sandbox/commands/ls.js +4 -5
- package/dist/lib/sandbox/commands/rm.js +1 -1
- package/dist/lib/sandbox/control/client.js +4 -64
- package/dist/lib/sandbox/control/executor.js +4 -50
- package/dist/lib/sandbox/control/lifecycle.js +66 -4
- package/dist/lib/sandbox/control/protocol.js +5 -16
- package/dist/lib/sandbox/control/server.js +203 -234
- package/dist/lib/sandbox/control/task-view.js +0 -13
- package/dist/lib/sandbox/mounts.js +7 -0
- package/dist/lib/sandbox/recovery.js +28 -9
- package/dist/lib/sandbox/removal.js +19 -0
- package/dist/lib/sandbox/workspace-identity.js +18 -6
- package/dist/lib/sandbox/workspace-view.js +48 -2
- package/dist/lib/task/commands/ls.js +3 -3
- package/dist/lib/task/control-authority.js +2 -0
- package/dist/lib/task/control-recovery.js +1 -2
- package/dist/lib/task/finalization.js +14 -0
- package/dist/lib/task/lifecycle.js +203 -31
- package/dist/lib/task/orchestration.js +26 -72
- package/dist/lib/task/short-id.js +37 -27
- package/dist/lib/task/verification-engine.js +4 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +20 -23
- package/lib/internal/sandbox-control.ts +0 -97
- package/lib/internal/task-finalization.ts +30 -18
- package/lib/internal/task-lifecycle.ts +1 -1
- package/lib/platform/github-client.ts +34 -3
- package/lib/sandbox/commands/create.ts +6 -1
- package/lib/sandbox/commands/ls.ts +4 -5
- package/lib/sandbox/commands/rm.ts +1 -1
- package/lib/sandbox/control/client.ts +5 -78
- package/lib/sandbox/control/executor.ts +2 -53
- package/lib/sandbox/control/lifecycle.ts +67 -4
- package/lib/sandbox/control/protocol.ts +6 -20
- package/lib/sandbox/control/server.ts +9 -44
- package/lib/sandbox/control/task-view.ts +0 -19
- package/lib/sandbox/mounts.ts +8 -0
- package/lib/sandbox/recovery.ts +35 -16
- package/lib/sandbox/removal.ts +21 -2
- package/lib/sandbox/workspace-identity.ts +16 -6
- package/lib/sandbox/workspace-view.ts +41 -2
- package/lib/task/commands/ls.ts +3 -3
- package/lib/task/control-authority.ts +1 -0
- package/lib/task/control-recovery.ts +2 -3
- package/lib/task/finalization.ts +14 -0
- package/lib/task/lifecycle.ts +180 -27
- package/lib/task/orchestration.ts +30 -68
- package/lib/task/short-id.ts +33 -40
- package/lib/task/verification-engine.ts +3 -9
- package/package.json +1 -1
- package/templates/.agents/rules/README.en.md +1 -0
- package/templates/.agents/rules/README.zh-CN.md +1 -0
- package/templates/.agents/rules/strategy-pattern.en.md +25 -0
- package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
- package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
- package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
- package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
|
@@ -10,11 +10,9 @@ import {
|
|
|
10
10
|
type SandboxControlRequest,
|
|
11
11
|
type SandboxControlResponse,
|
|
12
12
|
type SandboxAgentClientRequest,
|
|
13
|
-
type SandboxTaskCreateRequest
|
|
14
|
-
type SandboxTaskFinalizationRequest
|
|
13
|
+
type SandboxTaskCreateRequest
|
|
15
14
|
} from './protocol.ts';
|
|
16
15
|
import { isAgentClientId } from '../../agent-clients/types.ts';
|
|
17
|
-
import { normalizeAgentToken } from '../../agent-clients/tokens.ts';
|
|
18
16
|
import { readSandboxControlPayload, readSandboxControlStatus } from './state.ts';
|
|
19
17
|
import type { TaskCreateCandidateV1 } from '../../task/create.ts';
|
|
20
18
|
import { accessSandboxTaskView, taskViewFromStatus, type TaskViewAccessEffect } from './task-view.ts';
|
|
@@ -25,7 +23,6 @@ import { loadConfig } from '../config.ts';
|
|
|
25
23
|
import { recoverSandboxControlFromHost } from './host-recovery.ts';
|
|
26
24
|
|
|
27
25
|
const SANDBOX_CONTROL_RESPONSE_SETTLE_MS = 250;
|
|
28
|
-
const SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS = 5 * 60_000;
|
|
29
26
|
|
|
30
27
|
export class SandboxControlClientError extends Error {
|
|
31
28
|
readonly detail: SandboxControlError;
|
|
@@ -114,7 +111,6 @@ function preflight(
|
|
|
114
111
|
|
|
115
112
|
function taskViewEffectForRequest(request: SandboxControlRequest): TaskViewAccessEffect | null {
|
|
116
113
|
if (request.family === 'task-lifecycle') return 'progress';
|
|
117
|
-
if (request.family === 'task-finalization') return 'recovery';
|
|
118
114
|
return null;
|
|
119
115
|
}
|
|
120
116
|
|
|
@@ -216,9 +212,6 @@ function exchangeSandboxControl(request: SandboxControlRequest, params: Readonly
|
|
|
216
212
|
|| (requestTask.kind === 'task' && requestTask.taskId !== identity.taskId))) {
|
|
217
213
|
clientError('SANDBOX_CONTROL_IDENTITY_TOPOLOGY_MISMATCH', 'request task does not match the sandbox identity', false);
|
|
218
214
|
}
|
|
219
|
-
if (identity.mode === 'branch-only' && request.family === 'task-finalization') {
|
|
220
|
-
clientError('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks', false);
|
|
221
|
-
}
|
|
222
215
|
}
|
|
223
216
|
const encoded = `${JSON.stringify(request)}\n`;
|
|
224
217
|
if (Buffer.byteLength(encoded, 'utf8') > SANDBOX_CONTROL_MAX_BYTES) {
|
|
@@ -421,9 +414,12 @@ export function requestSandboxControl(params: Readonly<{
|
|
|
421
414
|
token?: string; generation?: string; timeoutMs?: number;
|
|
422
415
|
}>): SandboxControlResponse {
|
|
423
416
|
if (!isSandboxControlFamily(params.family)) clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' is not allowed`, false);
|
|
424
|
-
if (params.family === 'task-create' || params.family === 'agent-client'
|
|
417
|
+
if (params.family === 'task-create' || params.family === 'agent-client') {
|
|
425
418
|
clientError('SANDBOX_CONTROL_COMMAND_DENIED', `'${params.family}' requires a typed request`, false);
|
|
426
419
|
}
|
|
420
|
+
if (params.args[1] === 'complete') {
|
|
421
|
+
clientError('SANDBOX_CONTROL_COMMAND_DENIED', 'complete must use task-finalization', false);
|
|
422
|
+
}
|
|
427
423
|
const auth = authority(params);
|
|
428
424
|
const issuedAt = Date.now();
|
|
429
425
|
const request: SandboxControlRequest = {
|
|
@@ -463,75 +459,6 @@ export function requestSandboxAgentOperation(params: Readonly<{
|
|
|
463
459
|
return exchangeSandboxControl(request, params);
|
|
464
460
|
}
|
|
465
461
|
|
|
466
|
-
export function requestSandboxTaskFinalization(params: Readonly<{
|
|
467
|
-
agent: string;
|
|
468
|
-
channelDir?: string;
|
|
469
|
-
statusDir?: string;
|
|
470
|
-
token?: string;
|
|
471
|
-
generation?: string;
|
|
472
|
-
timeoutMs?: number;
|
|
473
|
-
recoveryBudgetMs?: number;
|
|
474
|
-
}>): SandboxControlResponse {
|
|
475
|
-
const agent = normalizeAgentToken(params.agent);
|
|
476
|
-
if (!agent) clientError('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization agent is invalid', false);
|
|
477
|
-
const auth = authority(params);
|
|
478
|
-
const issuedAt = Date.now();
|
|
479
|
-
const id = randomUUID();
|
|
480
|
-
const request: SandboxTaskFinalizationRequest = {
|
|
481
|
-
version: 4,
|
|
482
|
-
id,
|
|
483
|
-
...auth,
|
|
484
|
-
issuedAt,
|
|
485
|
-
expiresAt: issuedAt + SANDBOX_CONTROL_ADMISSION_WINDOW_MS,
|
|
486
|
-
family: 'task-finalization',
|
|
487
|
-
operation: 'complete',
|
|
488
|
-
agent,
|
|
489
|
-
args: []
|
|
490
|
-
};
|
|
491
|
-
let response: SandboxControlResponse;
|
|
492
|
-
try {
|
|
493
|
-
response = exchangeSandboxControl(request, params);
|
|
494
|
-
} catch (error) {
|
|
495
|
-
if (!(error instanceof SandboxControlClientError)
|
|
496
|
-
|| error.detail.code !== 'SANDBOX_CONTROL_RESULT_UNKNOWN'
|
|
497
|
-
|| !error.accepted || !error.requestId) throw error;
|
|
498
|
-
return recoverAcceptedTaskFinalization(error.requestId, params);
|
|
499
|
-
}
|
|
500
|
-
if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') {
|
|
501
|
-
return recoverAcceptedTaskFinalization(response.id, params);
|
|
502
|
-
}
|
|
503
|
-
return response;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
export function recoverAcceptedTaskFinalization(
|
|
507
|
-
requestId: string,
|
|
508
|
-
params: Readonly<{ recoveryBudgetMs?: number }>,
|
|
509
|
-
recover: typeof recoverSandboxControl = recoverSandboxControl
|
|
510
|
-
): SandboxControlResponse {
|
|
511
|
-
const deadline = Date.now() + (params.recoveryBudgetMs ?? SANDBOX_TASK_FINALIZATION_RECOVERY_BUDGET_MS);
|
|
512
|
-
while (Date.now() < deadline) {
|
|
513
|
-
const timeoutMs = Math.min(30_000, deadline - Date.now());
|
|
514
|
-
if (timeoutMs <= 0) break;
|
|
515
|
-
try {
|
|
516
|
-
const response = recover(requestId, { timeoutMs });
|
|
517
|
-
if (response.phase === 'rejected' && response.error?.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN') continue;
|
|
518
|
-
return response;
|
|
519
|
-
} catch (error) {
|
|
520
|
-
if (error instanceof SandboxControlClientError
|
|
521
|
-
&& error.detail.code === 'SANDBOX_CONTROL_RESULT_UNKNOWN'
|
|
522
|
-
&& error.requestId === requestId) continue;
|
|
523
|
-
throw error;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
clientError(
|
|
527
|
-
'SANDBOX_CONTROL_RESULT_UNKNOWN',
|
|
528
|
-
'accepted finalization request remained unknown after the automatic recovery budget; inspect the receipt and resume by request id',
|
|
529
|
-
false,
|
|
530
|
-
true,
|
|
531
|
-
requestId
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
462
|
export function requestSandboxTaskCreate(params: Readonly<{
|
|
536
463
|
candidate: TaskCreateCandidateV1; channelDir?: string; statusDir?: string;
|
|
537
464
|
token?: string; generation?: string; timeoutMs?: number;
|
|
@@ -10,8 +10,6 @@ import {
|
|
|
10
10
|
taskCreateFailure,
|
|
11
11
|
type TaskCreateResult
|
|
12
12
|
} from '../../task/create-service.ts';
|
|
13
|
-
import { applyTaskFinalization } from '../../task/finalization.ts';
|
|
14
|
-
import { serializeTaskFinalizationEnvelope } from '../../task/finalization-envelope.ts';
|
|
15
13
|
import {
|
|
16
14
|
bindSandboxControlTask,
|
|
17
15
|
validateSandboxControlRequest,
|
|
@@ -256,22 +254,6 @@ function taskCreateExecutionFailure(requestId: string, code: string, message: st
|
|
|
256
254
|
}));
|
|
257
255
|
}
|
|
258
256
|
|
|
259
|
-
function finalizationResult(result: Awaited<ReturnType<typeof applyTaskFinalization>>): SandboxControlExecutionResult {
|
|
260
|
-
const payload = {
|
|
261
|
-
version: 1,
|
|
262
|
-
status: result.status,
|
|
263
|
-
changed: result.changed,
|
|
264
|
-
accepted: true,
|
|
265
|
-
result,
|
|
266
|
-
error: result.error
|
|
267
|
-
};
|
|
268
|
-
return {
|
|
269
|
-
exitCode: result.status === 'completed' ? 0 : result.status === 'blocked' ? 2 : 1,
|
|
270
|
-
stdout: `${JSON.stringify(payload)}\n`,
|
|
271
|
-
stderr: ''
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
257
|
type ExecuteRequestOptions = Readonly<{
|
|
276
258
|
createTask?: typeof createTask;
|
|
277
259
|
agentClientOperation?: AgentClientSandboxControlOperation;
|
|
@@ -301,26 +283,6 @@ async function executeRequestInner(
|
|
|
301
283
|
validateAgentClientOperation(request, manifest);
|
|
302
284
|
return executeAgentClientOperation(request, manifest, manifestPath, options.agentClientOperation);
|
|
303
285
|
}
|
|
304
|
-
if (request.family === 'task-finalization') {
|
|
305
|
-
const parsed = parseTaskControlOperation(
|
|
306
|
-
'task-finalization', [manifest.taskId!, 'complete', '--agent', request.agent]
|
|
307
|
-
);
|
|
308
|
-
if (parsed.family !== 'task-finalization') throw new Error('SANDBOX_CONTROL_FINALIZATION_OPERATION_INVALID');
|
|
309
|
-
const operation = parsed;
|
|
310
|
-
const result = dispatchTaskControlOperation(
|
|
311
|
-
createSandboxExecutorExecutionContext({
|
|
312
|
-
repoRoot: manifest.repoRoot,
|
|
313
|
-
worktreeRoot: manifest.worktreeRoot,
|
|
314
|
-
runtimeDir: manifest.runtimeDir,
|
|
315
|
-
taskId: manifest.taskId!,
|
|
316
|
-
generation: manifest.generation,
|
|
317
|
-
manifestPath,
|
|
318
|
-
requestId: request.id
|
|
319
|
-
}),
|
|
320
|
-
operation
|
|
321
|
-
);
|
|
322
|
-
return finalizationResult(await result);
|
|
323
|
-
}
|
|
324
286
|
const boundArgs = bindSandboxControlTask(request, manifest.taskId!);
|
|
325
287
|
let operation: TaskControlOperation;
|
|
326
288
|
try {
|
|
@@ -438,7 +400,7 @@ export async function runSandboxControlExecutor(requestPath: string, nonce: stri
|
|
|
438
400
|
family: request.family,
|
|
439
401
|
operation: operation && typeof operation === 'object' && 'family' in operation && operation.family === 'task-lifecycle'
|
|
440
402
|
? operation.request.intent
|
|
441
|
-
: request.family === '
|
|
403
|
+
: request.family === 'agent-client' ? request.operation : null,
|
|
442
404
|
phase: 'started-committed',
|
|
443
405
|
outcome: 'in-progress'
|
|
444
406
|
});
|
|
@@ -454,20 +416,7 @@ export async function runSandboxControlExecutor(requestPath: string, nonce: stri
|
|
|
454
416
|
});
|
|
455
417
|
const detail = error instanceof Error ? error.message : String(error);
|
|
456
418
|
const code = /^([A-Z][A-Z0-9_]+)/u.exec(detail)?.[1] ?? 'SANDBOX_CONTROL_EXECUTOR_FAILED';
|
|
457
|
-
result = request.family === 'task-
|
|
458
|
-
? {
|
|
459
|
-
exitCode: 1,
|
|
460
|
-
stdout: serializeTaskFinalizationEnvelope({
|
|
461
|
-
status: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? 'unknown' : 'failed',
|
|
462
|
-
changed: code === 'SANDBOX_CONTROL_RESULT_UNKNOWN' ? null : false,
|
|
463
|
-
accepted: true,
|
|
464
|
-
requestId: request.id,
|
|
465
|
-
result: null,
|
|
466
|
-
error: { code, message: detail, retryable: false }
|
|
467
|
-
}),
|
|
468
|
-
stderr: ''
|
|
469
|
-
}
|
|
470
|
-
: request.family === 'task-create'
|
|
419
|
+
result = request.family === 'task-create'
|
|
471
420
|
? taskCreateExecutionFailure(request.id, code, detail)
|
|
472
421
|
: lifecycleFailure(code, detail);
|
|
473
422
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type SandboxResourceLock
|
|
24
24
|
} from './native-file-lock.ts';
|
|
25
25
|
import { isSandboxAuthorityEvidence } from '../engines/authority.ts';
|
|
26
|
+
import { validateSandboxControlIdentity } from './identity-sentinel.ts';
|
|
26
27
|
import {
|
|
27
28
|
parseSandboxControlStatus,
|
|
28
29
|
readExecution,
|
|
@@ -639,6 +640,51 @@ function markSandboxControlRootQuiescing(root: string): void {
|
|
|
639
640
|
}
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
function readTaskCompletionManifest(
|
|
644
|
+
root: string,
|
|
645
|
+
binding: Readonly<{ taskId: string; generation: string; controlRootId: string; statusDir: string }>
|
|
646
|
+
): SandboxControlManifest {
|
|
647
|
+
const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
|
|
648
|
+
const identity = validateSandboxControlIdentity({
|
|
649
|
+
publicStatusDir: binding.statusDir,
|
|
650
|
+
root,
|
|
651
|
+
mode: manifest.mode,
|
|
652
|
+
taskId: manifest.taskId,
|
|
653
|
+
generation: manifest.generation,
|
|
654
|
+
controlRootId: manifest.controlRootId
|
|
655
|
+
});
|
|
656
|
+
if (identity.state !== 'valid' || manifest.mode !== 'task-bound'
|
|
657
|
+
|| manifest.taskId !== binding.taskId || manifest.generation !== binding.generation
|
|
658
|
+
|| manifest.controlRootId !== binding.controlRootId) {
|
|
659
|
+
throw new Error('SANDBOX_CONTROL_IDENTITY_MISMATCH');
|
|
660
|
+
}
|
|
661
|
+
return manifest;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function quiesceSandboxControlForTaskCompletion(
|
|
665
|
+
root: string,
|
|
666
|
+
binding: Readonly<{ taskId: string; generation: string; controlRootId: string; statusDir: string }>,
|
|
667
|
+
timeoutMs = DEFAULT_QUIESCE_TIMEOUT_MS
|
|
668
|
+
): Promise<void> {
|
|
669
|
+
const resolvedRoot = path.resolve(root);
|
|
670
|
+
const manifest = readTaskCompletionManifest(resolvedRoot, binding);
|
|
671
|
+
markSandboxControlRootQuiescing(resolvedRoot);
|
|
672
|
+
const deadline = Date.now() + timeoutMs;
|
|
673
|
+
const startupPath = path.join(resolvedRoot, BROKER_STARTING_FILE);
|
|
674
|
+
const brokerPath = path.join(resolvedRoot, 'broker.json');
|
|
675
|
+
while (true) {
|
|
676
|
+
const startup = readStartupOwner(startupPath);
|
|
677
|
+
if (fs.existsSync(startupPath) && !startup) throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
|
|
678
|
+
const broker = readBrokerOwner(brokerPath);
|
|
679
|
+
if (broker && (broker.token !== manifest.token || broker.generation !== manifest.generation)) {
|
|
680
|
+
throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
|
|
681
|
+
}
|
|
682
|
+
if (!startup && !broker) return;
|
|
683
|
+
if (Date.now() >= deadline) throw new Error('SANDBOX_CONTROL_BROKER_DRAIN_TIMEOUT');
|
|
684
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
642
688
|
function recordSandboxRemovalPending(
|
|
643
689
|
root: string,
|
|
644
690
|
manifest: SandboxControlManifest,
|
|
@@ -1108,12 +1154,29 @@ function readSandboxControlManifestValue(manifestPath: string): SandboxControlMa
|
|
|
1108
1154
|
|| (candidate.mode === 'branch-only' && candidate.taskId !== null)
|
|
1109
1155
|
|| !isSandboxAuthorityEvidence(candidate.authorityEvidence)) throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
|
|
1110
1156
|
const root = path.dirname(path.resolve(manifestPath));
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1157
|
+
const canonicalRoot = fs.realpathSync.native(root);
|
|
1158
|
+
const matchesControlChildPath = (candidatePath: string, name: string): boolean => {
|
|
1159
|
+
const resolved = path.resolve(candidatePath);
|
|
1160
|
+
if (path.basename(resolved) !== name) return false;
|
|
1161
|
+
const expected = path.join(canonicalRoot, name);
|
|
1162
|
+
try {
|
|
1163
|
+
return fs.realpathSync.native(resolved) === expected;
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
1166
|
+
try {
|
|
1167
|
+
return fs.realpathSync.native(path.dirname(resolved)) === canonicalRoot;
|
|
1168
|
+
} catch (parentError) {
|
|
1169
|
+
if ((parentError as NodeJS.ErrnoException).code !== 'ENOENT') throw parentError;
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
if (!matchesControlChildPath(candidate.channelDir, 'channel')
|
|
1175
|
+
|| !matchesControlChildPath(candidate.publicStatusDir, 'public')
|
|
1176
|
+
|| !matchesControlChildPath(candidate.processingDir, 'processing')) {
|
|
1114
1177
|
throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
|
|
1115
1178
|
}
|
|
1116
|
-
if (
|
|
1179
|
+
if (!matchesControlChildPath(candidate.runtimeDir, 'runtime')) {
|
|
1117
1180
|
throw new Error('SANDBOX_CONTROL_MANIFEST_INVALID');
|
|
1118
1181
|
}
|
|
1119
1182
|
const normalized: SandboxControlManifest = {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { validateTaskCreateCandidate, type TaskCreateCandidateV1 } from '../../task/create.ts';
|
|
3
|
-
import { normalizeAgentToken } from '../../agent-clients/tokens.ts';
|
|
4
3
|
import { isAgentClientId } from '../../agent-clients/types.ts';
|
|
5
4
|
import type { ProcessIdentity } from '../../server/process-state.ts';
|
|
6
5
|
import type { SandboxAuthorityEvidenceV1 } from '../engines/authority.ts';
|
|
@@ -15,7 +14,7 @@ export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
|
|
|
15
14
|
export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
|
|
16
15
|
export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
|
|
17
16
|
export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
|
|
18
|
-
export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-
|
|
17
|
+
export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-create', 'agent-client'] as const;
|
|
19
18
|
export type SandboxControlTimingPolicy = Readonly<{
|
|
20
19
|
controlTickMs: number;
|
|
21
20
|
parkedBindingInitialMs: number;
|
|
@@ -62,9 +61,6 @@ type RequestBase = Readonly<{
|
|
|
62
61
|
export type SandboxTaskCommandRequest = RequestBase & Readonly<{
|
|
63
62
|
family: 'task-lifecycle'; args: string[];
|
|
64
63
|
}>;
|
|
65
|
-
export type SandboxTaskFinalizationRequest = RequestBase & Readonly<{
|
|
66
|
-
family: 'task-finalization'; operation: 'complete'; agent: string; args: [];
|
|
67
|
-
}>;
|
|
68
64
|
export type SandboxTaskCreateRequest = RequestBase & Readonly<{
|
|
69
65
|
family: 'task-create'; candidate: TaskCreateCandidateV1;
|
|
70
66
|
}>;
|
|
@@ -74,7 +70,7 @@ export type SandboxAgentClientRequest = RequestBase & Readonly<{
|
|
|
74
70
|
operation: string;
|
|
75
71
|
payload: Record<string, unknown>;
|
|
76
72
|
}>;
|
|
77
|
-
export type SandboxControlRequest = SandboxTaskCommandRequest |
|
|
73
|
+
export type SandboxControlRequest = SandboxTaskCommandRequest | SandboxTaskCreateRequest | SandboxAgentClientRequest;
|
|
78
74
|
export type SandboxControlError = Readonly<{ code: string; message: string; retryable: boolean }>;
|
|
79
75
|
export type SandboxControlResultEvidence = Readonly<{
|
|
80
76
|
version: 1;
|
|
@@ -270,24 +266,14 @@ export function validateSandboxControlRequest(
|
|
|
270
266
|
}
|
|
271
267
|
return request as SandboxAgentClientRequest;
|
|
272
268
|
}
|
|
273
|
-
if (request.family === 'task-finalization') {
|
|
274
|
-
const expected = ['agent', 'args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
|
|
275
|
-
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
|
|
276
|
-
|| request.operation !== 'complete'
|
|
277
|
-
|| !Array.isArray(request.args) || request.args.length !== 0
|
|
278
|
-
|| typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent) {
|
|
279
|
-
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
|
|
280
|
-
}
|
|
281
|
-
if (manifest.mode !== 'task-bound' || !manifest.taskId) {
|
|
282
|
-
fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks');
|
|
283
|
-
}
|
|
284
|
-
return request as SandboxTaskFinalizationRequest;
|
|
285
|
-
}
|
|
286
269
|
const expected = ['args', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
|
|
287
270
|
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
|
|
288
271
|
|| !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
|
|
289
272
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
|
|
290
273
|
}
|
|
274
|
+
if (request.args[1] === 'complete') {
|
|
275
|
+
fail('SANDBOX_CONTROL_COMMAND_DENIED', 'complete must use task-finalization');
|
|
276
|
+
}
|
|
291
277
|
if (manifest.mode !== 'task-bound' || !manifest.taskId) {
|
|
292
278
|
fail(
|
|
293
279
|
'SANDBOX_CONTROL_BRANCH_ONLY',
|
|
@@ -298,7 +284,7 @@ export function validateSandboxControlRequest(
|
|
|
298
284
|
}
|
|
299
285
|
|
|
300
286
|
export function bindSandboxControlTask(request: SandboxControlRequest, taskId: string): string[] {
|
|
301
|
-
if (request.family === 'task-create' || request.family === 'agent-client'
|
|
287
|
+
if (request.family === 'task-create' || request.family === 'agent-client') {
|
|
302
288
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', `${request.family} requests do not bind a current task`);
|
|
303
289
|
}
|
|
304
290
|
if (request.args.length === 0) fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
|
|
@@ -76,7 +76,6 @@ import { recoverAgentClientOperation, validateAgentClientOperation } from './age
|
|
|
76
76
|
import { validateSandboxControlIdentity } from './identity-sentinel.ts';
|
|
77
77
|
import {
|
|
78
78
|
mergeSandboxTaskView,
|
|
79
|
-
taskViewAfterFinalization,
|
|
80
79
|
taskViewForManifest,
|
|
81
80
|
type SandboxTaskView
|
|
82
81
|
} from './task-view.ts';
|
|
@@ -91,34 +90,6 @@ type ActiveExecution = {
|
|
|
91
90
|
settled: boolean;
|
|
92
91
|
};
|
|
93
92
|
|
|
94
|
-
async function taskViewForFinalizationResult(
|
|
95
|
-
manifest: SandboxControlManifest,
|
|
96
|
-
request: SandboxControlRequest,
|
|
97
|
-
result: SandboxControlExecutionResult | null
|
|
98
|
-
): Promise<SandboxTaskView | null> {
|
|
99
|
-
if (request.family !== 'task-finalization' || result?.exitCode !== 0 || !manifest.taskId) return null;
|
|
100
|
-
let receipt: unknown = null;
|
|
101
|
-
for (let attempt = 0; attempt < 100 && !receipt; attempt += 1) {
|
|
102
|
-
try {
|
|
103
|
-
receipt = readTaskFinalizationReceipt(manifest.repoRoot, manifest.taskId);
|
|
104
|
-
} catch {
|
|
105
|
-
// A missing or temporarily moving task directory is retried before publishing completion.
|
|
106
|
-
}
|
|
107
|
-
if (!receipt) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
108
|
-
}
|
|
109
|
-
const finalized = taskViewAfterFinalization({
|
|
110
|
-
taskId: manifest.taskId,
|
|
111
|
-
generation: manifest.generation,
|
|
112
|
-
requestId: request.id,
|
|
113
|
-
receipt
|
|
114
|
-
});
|
|
115
|
-
if (finalized.state === 'unknown') return finalized;
|
|
116
|
-
const canonical = taskViewForManifest({ ...manifest, receipt });
|
|
117
|
-
return canonical.state === 'current' && canonical.observedSource === 'completed'
|
|
118
|
-
? canonical
|
|
119
|
-
: finalized;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
93
|
function appendBrokerAudit(
|
|
123
94
|
manifest: SandboxControlManifest,
|
|
124
95
|
event: string,
|
|
@@ -128,7 +99,6 @@ function appendBrokerAudit(
|
|
|
128
99
|
}
|
|
129
100
|
|
|
130
101
|
function operationKey(request: SandboxControlRequest): string | null {
|
|
131
|
-
if (request.family === 'task-finalization') return request.operation;
|
|
132
102
|
if (request.family === 'task-create') return 'create';
|
|
133
103
|
if (request.family === 'agent-client') return request.operation;
|
|
134
104
|
if (request.family !== 'task-lifecycle') return null;
|
|
@@ -327,9 +297,7 @@ async function readRecoveryDomain(
|
|
|
327
297
|
payloadOutput: string | null
|
|
328
298
|
): Promise<RecoveryDomainEvidence> {
|
|
329
299
|
if (!operation) return { domain: null };
|
|
330
|
-
const taskRef = request.
|
|
331
|
-
? manifest.taskId
|
|
332
|
-
: 'args' in request ? request.args[0] ?? null : manifest.taskId;
|
|
300
|
+
const taskRef = 'args' in request ? request.args[0] ?? null : manifest.taskId;
|
|
333
301
|
const output = parseControlOutput(payloadOutput);
|
|
334
302
|
if (!taskRef && operation.family !== 'task-create' && operation.family !== 'agent-client') {
|
|
335
303
|
return { domain: null };
|
|
@@ -842,6 +810,7 @@ export async function serveSandboxControl(
|
|
|
842
810
|
let bindingBackoffMs: number = timing.parkedBindingInitialMs;
|
|
843
811
|
try {
|
|
844
812
|
if (!brokerOwns()) return;
|
|
813
|
+
if (isSandboxControlRootQuiescing(root)) return;
|
|
845
814
|
if (taskView.state === 'current' && taskView.observedSource === 'completed') {
|
|
846
815
|
// Recheck durable re-entry evidence before publishing a trusted startup view.
|
|
847
816
|
const unverified = { ...taskView, state: 'unknown' as const,
|
|
@@ -932,11 +901,6 @@ export async function serveSandboxControl(
|
|
|
932
901
|
settledExecution = active;
|
|
933
902
|
active = null;
|
|
934
903
|
}
|
|
935
|
-
const finalizationView = settledExecution
|
|
936
|
-
? await taskViewForFinalizationResult(manifest, settledExecution.request, settledExecution.result)
|
|
937
|
-
: null;
|
|
938
|
-
if (finalizationView) taskView = finalizationView;
|
|
939
|
-
|
|
940
904
|
let reasonCode: string | null = null;
|
|
941
905
|
try {
|
|
942
906
|
if (!brokerOwns()) break;
|
|
@@ -1005,7 +969,9 @@ export async function serveSandboxControl(
|
|
|
1005
969
|
}
|
|
1006
970
|
|
|
1007
971
|
let retiring = false;
|
|
1008
|
-
|
|
972
|
+
const quiescing = isSandboxControlRootQuiescing(root);
|
|
973
|
+
if (quiescing && !active) break;
|
|
974
|
+
if (!quiescing) for (const name of fs.readdirSync(requestsDir).sort()) {
|
|
1009
975
|
if (!/^[a-f0-9-]{16,64}\.json$/.test(name)) continue;
|
|
1010
976
|
const id = name.slice(0, -5);
|
|
1011
977
|
const source = path.join(requestsDir, name);
|
|
@@ -1013,6 +979,10 @@ export async function serveSandboxControl(
|
|
|
1013
979
|
let validatedRequest: SandboxControlRequest | null = null;
|
|
1014
980
|
let prepared: PreparedSandboxControlExecution | null = null;
|
|
1015
981
|
try {
|
|
982
|
+
if (isSandboxControlRootQuiescing(root)) {
|
|
983
|
+
retiring = true;
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
1016
986
|
if (!brokerOwns()) {
|
|
1017
987
|
retiring = true;
|
|
1018
988
|
break;
|
|
@@ -1230,11 +1200,6 @@ export async function serveSandboxControl(
|
|
|
1230
1200
|
}
|
|
1231
1201
|
}
|
|
1232
1202
|
if (owned && active.result && active.resultEvidenceWritten) {
|
|
1233
|
-
const finalizationView = await taskViewForFinalizationResult(manifest, active.request, active.result);
|
|
1234
|
-
if (finalizationView) {
|
|
1235
|
-
taskView = finalizationView;
|
|
1236
|
-
writeSandboxControlStatus(manifest, broker, 'healthy', null, null, Date.now(), taskView);
|
|
1237
|
-
}
|
|
1238
1203
|
if (publishExecutionResult(manifest, active.request, active.result, brokerOwns)) {
|
|
1239
1204
|
if (brokerOwns()) {
|
|
1240
1205
|
removeAcceptedResponse(manifest, active.request.id);
|
|
@@ -180,25 +180,6 @@ export function projectSandboxTaskView(input: TaskViewProjectionInput): SandboxT
|
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
export function taskViewAfterFinalization(params: Readonly<{
|
|
184
|
-
taskId: string;
|
|
185
|
-
generation: string;
|
|
186
|
-
requestId: string;
|
|
187
|
-
receipt: unknown;
|
|
188
|
-
}>): SandboxTaskView {
|
|
189
|
-
const receipt = receiptIdentity(params.receipt, params.generation, params.requestId);
|
|
190
|
-
if (!receipt) {
|
|
191
|
-
return {
|
|
192
|
-
state: 'unknown', taskId: params.taskId, observedSource: 'unknown', receipt: null,
|
|
193
|
-
reasonCode: 'SANDBOX_TASK_VIEW_RECEIPT_INVALID'
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
return {
|
|
197
|
-
state: 'finalized-stale', taskId: params.taskId, observedSource: 'active', receipt,
|
|
198
|
-
reasonCode: 'SANDBOX_TASK_VIEW_FINALIZED'
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
183
|
export function mergeSandboxTaskView(
|
|
203
184
|
canonical: SandboxTaskView,
|
|
204
185
|
previous: SandboxTaskView
|
package/lib/sandbox/mounts.ts
CHANGED
|
@@ -31,6 +31,7 @@ export function sandboxCoreBindMounts(
|
|
|
31
31
|
worktreeGitFile?: string;
|
|
32
32
|
runtimeDir?: string;
|
|
33
33
|
taskSources?: string[];
|
|
34
|
+
completedTaskSource?: string;
|
|
34
35
|
taskId?: string;
|
|
35
36
|
}
|
|
36
37
|
): SandboxBindMountDeclaration[] {
|
|
@@ -77,6 +78,13 @@ export function sandboxCoreBindMounts(
|
|
|
77
78
|
containerPath: taskMountPath,
|
|
78
79
|
readOnly: false
|
|
79
80
|
});
|
|
81
|
+
if (overrides.completedTaskSource) {
|
|
82
|
+
mounts.push({
|
|
83
|
+
hostPaths: [overrides.completedTaskSource],
|
|
84
|
+
containerPath: path.posix.join('/workspace/.agents/workspace/completed', overrides.taskId!),
|
|
85
|
+
readOnly: false
|
|
86
|
+
});
|
|
87
|
+
}
|
|
80
88
|
}
|
|
81
89
|
mounts.push(
|
|
82
90
|
{
|
package/lib/sandbox/recovery.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { sandboxCoreBindMounts } from './mounts.ts';
|
|
|
25
25
|
import { resolveSandboxGitMetadata } from './git-metadata.ts';
|
|
26
26
|
import {
|
|
27
27
|
assertSandboxTaskSource,
|
|
28
|
+
sandboxCompletedTaskMountSource,
|
|
28
29
|
sandboxControlPaths,
|
|
29
30
|
sandboxWorkspaceViewPaths
|
|
30
31
|
} from './workspace-view.ts';
|
|
@@ -673,31 +674,43 @@ export function startCompletedSandboxContainer(params: {
|
|
|
673
674
|
const activeRoot = path.join(params.config.repoRoot, '.agents', 'workspace', 'active');
|
|
674
675
|
const historicalSource = path.join(activeRoot, params.taskId);
|
|
675
676
|
fs.mkdirSync(activeRoot, { recursive: true });
|
|
677
|
+
let replacedEmptyRoot = false;
|
|
678
|
+
let emptyRootMode = 0o700;
|
|
676
679
|
try {
|
|
677
|
-
fs.lstatSync(historicalSource);
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
680
|
+
const existing = fs.lstatSync(historicalSource);
|
|
681
|
+
if (existing.isSymbolicLink() || !existing.isDirectory() || fs.readdirSync(historicalSource).length > 0) {
|
|
682
|
+
throw new Error(
|
|
683
|
+
`SANDBOX_COMPLETED_SOURCE_CONFLICT: historical task source already exists at ${historicalSource}`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
emptyRootMode = existing.mode & 0o777;
|
|
687
|
+
fs.rmdirSync(historicalSource);
|
|
688
|
+
replacedEmptyRoot = true;
|
|
681
689
|
} catch (error) {
|
|
682
690
|
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
|
683
691
|
}
|
|
684
692
|
|
|
685
|
-
|
|
686
|
-
completedSource,
|
|
687
|
-
historicalSource,
|
|
688
|
-
process.platform === 'win32' ? 'junction' : 'dir'
|
|
689
|
-
);
|
|
693
|
+
let temporaryLink = false;
|
|
690
694
|
try {
|
|
695
|
+
fs.symlinkSync(
|
|
696
|
+
completedSource,
|
|
697
|
+
historicalSource,
|
|
698
|
+
process.platform === 'win32' ? 'junction' : 'dir'
|
|
699
|
+
);
|
|
700
|
+
temporaryLink = true;
|
|
691
701
|
params.start(params.engine, params.container);
|
|
692
702
|
} finally {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
703
|
+
if (temporaryLink) {
|
|
704
|
+
const temporarySource = fs.lstatSync(historicalSource);
|
|
705
|
+
const resolvedSource = fs.realpathSync.native(historicalSource);
|
|
706
|
+
if (!temporarySource.isSymbolicLink() || resolvedSource !== completedSource) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`SANDBOX_COMPLETED_SOURCE_CHANGED: refusing to remove unexpected path at ${historicalSource}`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
fs.unlinkSync(historicalSource);
|
|
699
712
|
}
|
|
700
|
-
fs.
|
|
713
|
+
if (replacedEmptyRoot) fs.mkdirSync(historicalSource, { mode: emptyRootMode });
|
|
701
714
|
}
|
|
702
715
|
}
|
|
703
716
|
|
|
@@ -749,6 +762,7 @@ function expectedMounts(params: {
|
|
|
749
762
|
? recoveryTaskSources(config.repoRoot, params.workspace.taskId)
|
|
750
763
|
: null;
|
|
751
764
|
const taskId = params.workspace.mode === 'task-bound' ? params.workspace.taskId : null;
|
|
765
|
+
const completedTaskSource = taskId ? sandboxCompletedTaskMountSource(config.repoRoot, taskId) : null;
|
|
752
766
|
const worktrees = worktreeDirCandidates(config, branch).filter((candidate) =>
|
|
753
767
|
fs.existsSync(path.join(candidate, '.git'))
|
|
754
768
|
);
|
|
@@ -770,6 +784,7 @@ function expectedMounts(params: {
|
|
|
770
784
|
: {
|
|
771
785
|
runtimeDir: control.runtimeDir,
|
|
772
786
|
taskSources: taskSources!.accessiblePaths,
|
|
787
|
+
completedTaskSource: completedTaskSource!,
|
|
773
788
|
taskId: taskId!
|
|
774
789
|
})
|
|
775
790
|
}).map((mount) => ({
|
|
@@ -780,6 +795,10 @@ function expectedMounts(params: {
|
|
|
780
795
|
&& mount.containerPath === `/workspace/.agents/workspace/active/${taskId}`
|
|
781
796
|
? { sourceAccessiblePaths: taskSources!.accessiblePaths }
|
|
782
797
|
: {}),
|
|
798
|
+
...(completedTaskSource !== null && taskId !== null
|
|
799
|
+
&& mount.containerPath === `/workspace/.agents/workspace/completed/${taskId}`
|
|
800
|
+
? { sourceAccessiblePaths: [completedTaskSource] }
|
|
801
|
+
: {}),
|
|
783
802
|
expectedRW: !mount.readOnly
|
|
784
803
|
}));
|
|
785
804
|
const live = tools.flatMap((tool) =>
|