@opengeni/runtime 2.5.2 → 2.5.3-canary.1
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/{agent-Q2VNH5FV.js → agent-RJQLKFIW.js} +2 -2
- package/dist/{chunk-LGLJ5PVD.js → chunk-46BFUGFL.js} +2622 -1368
- package/dist/chunk-46BFUGFL.js.map +1 -0
- package/dist/{chunk-CD22I76N.js → chunk-FHGLYLBL.js} +2 -2
- package/dist/{chunk-25B3EQRW.js → chunk-UNICCRW5.js} +4 -4
- package/dist/{chunk-GJC7H2FC.js → chunk-VUEWS4GI.js} +494 -95
- package/dist/chunk-VUEWS4GI.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +22 -4
- package/dist/{localSources-Z5IJIIRV.js → localSources-PVFCAPN2.js} +3 -3
- package/dist/mcp-operation-dispatch.d.ts +45 -0
- package/dist/mcp-operation-observation.d.ts +137 -0
- package/dist/operational-instructions.d.ts +1 -1
- package/dist/sandbox/archive-spool.d.ts +14 -0
- package/dist/sandbox/host-archive-spool.d.ts +19 -0
- package/dist/sandbox/index.d.ts +5 -1
- package/dist/sandbox/index.js +7 -3
- package/dist/sandbox/routing/backend-resolver.d.ts +1 -0
- package/dist/sandbox/selfhosted/file-transfer.d.ts +20 -0
- package/dist/sandbox/selfhosted/session.d.ts +7 -0
- package/dist/sandbox/workspace-archive.d.ts +7 -0
- package/dist/workspace-skills.d.ts +4 -0
- package/dist/workspace-tool-gateway.js +4 -4
- package/package.json +11 -11
- package/src/bundled_default_skills/document-parsing/SKILL.md +36 -0
- package/src/bundled_site_skills/opengeni-sites/SKILL.md +1 -1
- package/src/index.ts +231 -45
- package/src/lazy-tool-transport.ts +2 -0
- package/src/mcp-operation-dispatch.ts +144 -0
- package/src/mcp-operation-observation.ts +148 -0
- package/src/operational-instructions.ts +1 -1
- package/src/runtime-skills.ts +7 -2
- package/src/sandbox/archive-spool.ts +16 -0
- package/src/sandbox/host-archive-spool.ts +1040 -0
- package/src/sandbox/index.ts +292 -203
- package/src/sandbox/routing/backend-resolver.ts +2 -0
- package/src/sandbox/selfhosted/control-rpc.ts +22 -0
- package/src/sandbox/selfhosted/fault-rendering.ts +9 -0
- package/src/sandbox/selfhosted/file-transfer.ts +160 -0
- package/src/sandbox/selfhosted/op-testing.ts +3 -0
- package/src/sandbox/selfhosted/session.ts +194 -5
- package/src/sandbox/workspace-archive.ts +54 -1
- package/src/workspace-skills.ts +171 -31
- package/dist/chunk-GJC7H2FC.js.map +0 -1
- package/dist/chunk-LGLJ5PVD.js.map +0 -1
- /package/dist/{agent-Q2VNH5FV.js.map → agent-RJQLKFIW.js.map} +0 -0
- /package/dist/{chunk-CD22I76N.js.map → chunk-FHGLYLBL.js.map} +0 -0
- /package/dist/{chunk-25B3EQRW.js.map → chunk-UNICCRW5.js.map} +0 -0
- /package/dist/{localSources-Z5IJIIRV.js.map → localSources-PVFCAPN2.js.map} +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { blake3 } from "@noble/hashes/blake3";
|
|
2
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
3
|
+
import { ControlRequest, ControlResponse, OpState } from "@opengeni/agent-proto";
|
|
4
|
+
import { agentErrorToControlError } from "./control-rpc";
|
|
5
|
+
|
|
6
|
+
// Below the protocol's 512 KiB chunk maximum, leaving control-envelope headroom.
|
|
7
|
+
export const SELFHOSTED_FILE_CHUNK_BYTES = 256 * 1024;
|
|
8
|
+
|
|
9
|
+
export function fileContentDigest(content: Uint8Array): string {
|
|
10
|
+
return bytesToHex(blake3(content));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class FileTransferOutcomeError extends Error {
|
|
14
|
+
readonly retryable = false;
|
|
15
|
+
constructor(
|
|
16
|
+
readonly operationId: string,
|
|
17
|
+
readonly phase: "begin" | "upload" | "verify",
|
|
18
|
+
cause: unknown,
|
|
19
|
+
readonly observationError?: unknown,
|
|
20
|
+
) {
|
|
21
|
+
const detail = cause instanceof Error ? cause.message : "unknown failure";
|
|
22
|
+
const observation =
|
|
23
|
+
observationError instanceof Error ? ` Outcome check: ${observationError.message}` : "";
|
|
24
|
+
super(
|
|
25
|
+
`File transfer ${operationId} ${phase} failed: ${detail}.${observation} No mutation was replayed; inspect the destination before another edit.`,
|
|
26
|
+
{ cause },
|
|
27
|
+
);
|
|
28
|
+
this.name = "FileTransferOutcomeError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The caller must reauthorize every request and keep the exact physical route.
|
|
33
|
+
* No mutation is automatically replayed, including after an ambiguous reply. */
|
|
34
|
+
export async function transferEditorFile(input: {
|
|
35
|
+
path: string;
|
|
36
|
+
content: Uint8Array;
|
|
37
|
+
baseContent?: Uint8Array;
|
|
38
|
+
request: (requestId: string, op: NonNullable<ControlRequest["op"]>) => Promise<ControlResponse>;
|
|
39
|
+
}): Promise<{ recovered: boolean }> {
|
|
40
|
+
const opId = `fsw-${crypto.randomUUID()}`;
|
|
41
|
+
const digest = fileContentDigest(input.content);
|
|
42
|
+
const checked = (reply: ControlResponse) => {
|
|
43
|
+
if (reply.error) throw agentErrorToControlError(reply.error, reply.requestId);
|
|
44
|
+
if (!reply.result) throw new Error("The machine omitted the file-transfer result.");
|
|
45
|
+
return reply.result;
|
|
46
|
+
};
|
|
47
|
+
const send = async (requestId: string, op: NonNullable<ControlRequest["op"]>) => {
|
|
48
|
+
const reply = await input.request(requestId, op);
|
|
49
|
+
if (reply.requestId !== requestId)
|
|
50
|
+
throw new Error("The machine returned a mismatched file-transfer response identity.");
|
|
51
|
+
return checked(reply);
|
|
52
|
+
};
|
|
53
|
+
const isVerified = (observed: NonNullable<ControlResponse["result"]>): boolean =>
|
|
54
|
+
!(
|
|
55
|
+
observed.$case !== "opStatus" ||
|
|
56
|
+
observed.opStatus.opId !== opId ||
|
|
57
|
+
observed.opStatus.state !== OpState.OP_STATE_COMPLETE ||
|
|
58
|
+
observed.opStatus.exit?.exitCode !== 0 ||
|
|
59
|
+
observed.opStatus.exit.cancelled ||
|
|
60
|
+
observed.opStatus.exit.timedOut ||
|
|
61
|
+
observed.opStatus.exit.failureCode ||
|
|
62
|
+
observed.opStatus.exit.digests.content !== digest ||
|
|
63
|
+
observed.opStatus.exit.totals.content !== String(input.content.byteLength)
|
|
64
|
+
);
|
|
65
|
+
const verifyOutcome = async (cancelRunning = false): Promise<void> => {
|
|
66
|
+
const observed = await send(crypto.randomUUID(), { $case: "opQuery", opQuery: { opId } });
|
|
67
|
+
if (isVerified(observed)) return;
|
|
68
|
+
if (
|
|
69
|
+
cancelRunning &&
|
|
70
|
+
observed.$case === "opStatus" &&
|
|
71
|
+
observed.opStatus.opId === opId &&
|
|
72
|
+
observed.opStatus.state === OpState.OP_STATE_RUNNING
|
|
73
|
+
) {
|
|
74
|
+
// This explicit transfer is being abandoned. Cancel only its known live
|
|
75
|
+
// identity through the same reauthorized callback; never sweep or expire
|
|
76
|
+
// other work. A commit racing cancellation must retain its real receipt.
|
|
77
|
+
const cancelled = await send(crypto.randomUUID(), { $case: "opCancel", opCancel: { opId } });
|
|
78
|
+
if (isVerified(cancelled)) return;
|
|
79
|
+
if (
|
|
80
|
+
cancelled.$case === "opStatus" &&
|
|
81
|
+
cancelled.opStatus.opId === opId &&
|
|
82
|
+
cancelled.opStatus.state === OpState.OP_STATE_COMPLETE &&
|
|
83
|
+
cancelled.opStatus.exit?.cancelled
|
|
84
|
+
) {
|
|
85
|
+
throw new Error("Cancellation of the abandoned transfer's private staging was verified.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error(
|
|
89
|
+
"The file-transfer outcome is not verified; the operation was not replayed. Inspect the destination before another edit.",
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
let phase: "begin" | "upload" | "verify" = "begin";
|
|
93
|
+
try {
|
|
94
|
+
const begun = await send(opId, {
|
|
95
|
+
$case: "opStart",
|
|
96
|
+
opStart: {
|
|
97
|
+
op: {
|
|
98
|
+
$case: "fsWrite",
|
|
99
|
+
fsWrite: {
|
|
100
|
+
path: input.path,
|
|
101
|
+
createParents: true,
|
|
102
|
+
mode: 0,
|
|
103
|
+
expectedBaseDigest:
|
|
104
|
+
input.baseContent === undefined ? "" : fileContentDigest(input.baseContent),
|
|
105
|
+
expectedAbsent: input.baseContent === undefined,
|
|
106
|
+
contentDigest: digest,
|
|
107
|
+
contentSize: String(input.content.byteLength),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
windowBytes: "0",
|
|
111
|
+
deadlineMs: "0",
|
|
112
|
+
originId: "",
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (
|
|
116
|
+
begun.$case !== "opStart" ||
|
|
117
|
+
!begun.opStart.accepted ||
|
|
118
|
+
begun.opStart.status?.opId !== opId
|
|
119
|
+
) {
|
|
120
|
+
throw new Error("The machine did not accept the exact file-transfer operation.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
phase = "upload";
|
|
124
|
+
let offset = 0;
|
|
125
|
+
let seq = 0;
|
|
126
|
+
do {
|
|
127
|
+
const end = Math.min(input.content.byteLength, offset + SELFHOSTED_FILE_CHUNK_BYTES);
|
|
128
|
+
const reply = await send(crypto.randomUUID(), {
|
|
129
|
+
$case: "writeChunk",
|
|
130
|
+
writeChunk: {
|
|
131
|
+
opId,
|
|
132
|
+
seq: String(seq),
|
|
133
|
+
offset: String(offset),
|
|
134
|
+
bytes: input.content.subarray(offset, end),
|
|
135
|
+
last: end === input.content.byteLength,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
if (reply.$case !== "writeChunk" || reply.writeChunk.seq !== String(seq)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"The machine returned a mismatched file-transfer acknowledgment; no chunk was replayed.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
offset = end;
|
|
144
|
+
seq += 1;
|
|
145
|
+
} while (offset < input.content.byteLength);
|
|
146
|
+
phase = "verify";
|
|
147
|
+
await verifyOutcome();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (phase === "verify") throw new FileTransferOutcomeError(opId, phase, error);
|
|
150
|
+
// An acknowledgment can be lost after commit. Observe this exact operation
|
|
151
|
+
// once under current authority; never restart it or resend a chunk.
|
|
152
|
+
try {
|
|
153
|
+
await verifyOutcome(true);
|
|
154
|
+
return { recovered: true };
|
|
155
|
+
} catch (observationError) {
|
|
156
|
+
throw new FileTransferOutcomeError(opId, phase, error, observationError);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { recovered: false };
|
|
160
|
+
}
|
|
@@ -459,6 +459,7 @@ export class FakeOpRunner implements ControlRpc {
|
|
|
459
459
|
opId,
|
|
460
460
|
state: complete ? OpState.OP_STATE_COMPLETE : OpState.OP_STATE_RUNNING,
|
|
461
461
|
nextSeq: complete ? (run.exitSeq + 1n).toString() : "1",
|
|
462
|
+
writeOffset: "0",
|
|
462
463
|
exit: complete ? run.exit : undefined,
|
|
463
464
|
lostReason: OpLostReason.OP_LOST_REASON_UNSPECIFIED,
|
|
464
465
|
};
|
|
@@ -617,6 +618,7 @@ function lostStatus(opId: string): OpStatus {
|
|
|
617
618
|
return {
|
|
618
619
|
opId,
|
|
619
620
|
state: OpState.OP_STATE_LOST,
|
|
621
|
+
writeOffset: "0",
|
|
620
622
|
nextSeq: "0",
|
|
621
623
|
exit: undefined,
|
|
622
624
|
lostReason: OpLostReason.OP_LOST_REASON_EVICTED,
|
|
@@ -627,6 +629,7 @@ function cancelledStatus(opId: string): OpStatus {
|
|
|
627
629
|
return {
|
|
628
630
|
opId,
|
|
629
631
|
state: OpState.OP_STATE_COMPLETE,
|
|
632
|
+
writeOffset: "0",
|
|
630
633
|
nextSeq: "1",
|
|
631
634
|
exit: {
|
|
632
635
|
exitCode: -1,
|
|
@@ -31,6 +31,11 @@ import {
|
|
|
31
31
|
type StreamChannel,
|
|
32
32
|
} from "@opengeni/agent-proto";
|
|
33
33
|
import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
|
|
34
|
+
import {
|
|
35
|
+
SELFHOSTED_FILE_CHUNK_BYTES,
|
|
36
|
+
fileContentDigest,
|
|
37
|
+
transferEditorFile,
|
|
38
|
+
} from "./file-transfer";
|
|
34
39
|
// `Manifest` from the ALLOWED sandbox-leaf entrypoint (`@openai/agents/sandbox`
|
|
35
40
|
// re-exports `@openai/agents-core/sandbox`, which exports the Manifest class) —
|
|
36
41
|
// NOT the agent-loop `@openai/agents` root the sandbox leaf forbids. The live
|
|
@@ -262,6 +267,7 @@ export interface SelfhostedBackgroundCommandAdoption {
|
|
|
262
267
|
/** Exact command-admission snapshot. A worker resolves this immediately before
|
|
263
268
|
* every exec/Git admission; retries and already-started operations retain it. */
|
|
264
269
|
export interface SelfhostedOperationAdmission {
|
|
270
|
+
transactionalFsWriteSupported?: boolean;
|
|
265
271
|
/** Workspace segment of the physical agent/relay route. Personal machines may
|
|
266
272
|
* originate in a different same-organization workspace than the session. */
|
|
267
273
|
workspaceId?: string;
|
|
@@ -284,6 +290,8 @@ export interface SelfhostedOperationAdmission {
|
|
|
284
290
|
}
|
|
285
291
|
|
|
286
292
|
export interface SelfhostedSessionDeps {
|
|
293
|
+
/** Exact live daemon capability; absent preserves legacy small-file writes. */
|
|
294
|
+
transactionalFsWriteSupported?: boolean;
|
|
287
295
|
/** Authorization/session workspace. */
|
|
288
296
|
workspaceId: string;
|
|
289
297
|
/** Physical control-plane workspace. Defaults to workspaceId for legacy and
|
|
@@ -488,6 +496,7 @@ export class SelfhostedSession {
|
|
|
488
496
|
private readonly resourcePolicy: OperationResourcePolicy | undefined;
|
|
489
497
|
private readonly resourcePolicySupported: boolean;
|
|
490
498
|
private readonly operationCpuQuotaSupported: boolean;
|
|
499
|
+
private readonly transactionalFsWriteSupported: boolean;
|
|
491
500
|
private readonly defaultOpStream: SelfhostedOpStreamDeps | undefined;
|
|
492
501
|
private readonly connectionInstanceId: string;
|
|
493
502
|
private readonly resolveOperationAdmission:
|
|
@@ -560,6 +569,7 @@ export class SelfhostedSession {
|
|
|
560
569
|
this.resourcePolicy = normalizeOperationResourcePolicy(deps.operationResourcePolicy);
|
|
561
570
|
this.resourcePolicySupported = deps.operationResourcePolicySupported === true;
|
|
562
571
|
this.operationCpuQuotaSupported = deps.operationCpuQuotaSupported === true;
|
|
572
|
+
this.transactionalFsWriteSupported = deps.transactionalFsWriteSupported === true;
|
|
563
573
|
this.defaultOpStream = deps.opStream;
|
|
564
574
|
this.connectionInstanceId = deps.connectionInstanceId;
|
|
565
575
|
this.resolveOperationAdmission = deps.resolveOperationAdmission;
|
|
@@ -577,6 +587,7 @@ export class SelfhostedSession {
|
|
|
577
587
|
subject: this.subject,
|
|
578
588
|
resourcePolicy: this.resourcePolicy,
|
|
579
589
|
opStream: deps.opStream,
|
|
590
|
+
transactionalFsWriteSupported: this.transactionalFsWriteSupported,
|
|
580
591
|
})
|
|
581
592
|
: undefined;
|
|
582
593
|
// A valid Manifest whose root is the machine's actual effective root. There
|
|
@@ -644,6 +655,7 @@ export class SelfhostedSession {
|
|
|
644
655
|
}
|
|
645
656
|
|
|
646
657
|
private async admitOperation(commandPolicy: boolean): Promise<{
|
|
658
|
+
transactionalFsWriteSupported: boolean;
|
|
647
659
|
controlWorkspaceId: string;
|
|
648
660
|
connectionInstanceId: string;
|
|
649
661
|
subject: string;
|
|
@@ -664,6 +676,7 @@ export class SelfhostedSession {
|
|
|
664
676
|
subject: this.subject,
|
|
665
677
|
resourcePolicy: commandPolicy ? this.resourcePolicy : undefined,
|
|
666
678
|
opStream: this.defaultOpStream,
|
|
679
|
+
transactionalFsWriteSupported: this.transactionalFsWriteSupported,
|
|
667
680
|
};
|
|
668
681
|
}
|
|
669
682
|
|
|
@@ -698,6 +711,7 @@ export class SelfhostedSession {
|
|
|
698
711
|
),
|
|
699
712
|
resourcePolicy: commandPolicy ? resourcePolicy : undefined,
|
|
700
713
|
opStream: resolved.opStream,
|
|
714
|
+
transactionalFsWriteSupported: resolved.transactionalFsWriteSupported === true,
|
|
701
715
|
};
|
|
702
716
|
}
|
|
703
717
|
|
|
@@ -879,7 +893,9 @@ export class SelfhostedSession {
|
|
|
879
893
|
machineId: this.agentId,
|
|
880
894
|
faultClass: selfhostedFaultClass(error),
|
|
881
895
|
// Known only on a PAYLOAD_TOO_LARGE fault (the agent's encoded_bytes detail).
|
|
882
|
-
...(Number.isFinite(encoded)
|
|
896
|
+
...(Number.isFinite(encoded) && error.detail.direction !== "request"
|
|
897
|
+
? { replyBytes: encoded }
|
|
898
|
+
: {}),
|
|
883
899
|
});
|
|
884
900
|
// Fold the retry count into the DRAINING copy when we actually retried, so
|
|
885
901
|
// the surfaced message reads "…retried N times first…".
|
|
@@ -1324,8 +1340,169 @@ export class SelfhostedSession {
|
|
|
1324
1340
|
const pathExists = (path: string): Promise<boolean> => this.pathExists(path, runAs);
|
|
1325
1341
|
const readText = async (path: string): Promise<string> =>
|
|
1326
1342
|
decoder.decode(await this.readFile({ path, ...(runAs ? { runAs } : {}) }));
|
|
1327
|
-
const writeText = async (
|
|
1328
|
-
|
|
1343
|
+
const writeText = async (
|
|
1344
|
+
path: string,
|
|
1345
|
+
content: string,
|
|
1346
|
+
base?: string | (() => Promise<string | undefined>),
|
|
1347
|
+
): Promise<((source: string, sourceBase: string) => Promise<void>) | undefined> => {
|
|
1348
|
+
const bytes = encoder.encode(content);
|
|
1349
|
+
if (bytes.byteLength <= SELFHOSTED_FILE_CHUNK_BYTES) {
|
|
1350
|
+
await this.writeFile({ path, content, createParents: true });
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const initial = await this.admitOperation(false);
|
|
1354
|
+
if (!initial.transactionalFsWriteSupported) {
|
|
1355
|
+
// Older agents may have a larger negotiated message budget. Preserve
|
|
1356
|
+
// their existing writes; a real size rejection remains a typed fault.
|
|
1357
|
+
await this.writeFile({ path, content, createParents: true });
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (runAs) {
|
|
1361
|
+
throw new SelfhostedControlError({
|
|
1362
|
+
code: ErrorCode.ERROR_CODE_UNSUPPORTED,
|
|
1363
|
+
message:
|
|
1364
|
+
"Transactional file editing does not support runAs impersonation; no file was written.",
|
|
1365
|
+
reason: null,
|
|
1366
|
+
retryable: false,
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
const requestTransfer = async (
|
|
1370
|
+
requestId: string,
|
|
1371
|
+
op: NonNullable<ControlRequest["op"]>,
|
|
1372
|
+
): Promise<ControlResponse> => {
|
|
1373
|
+
const startedAt = Date.now();
|
|
1374
|
+
try {
|
|
1375
|
+
const current = await this.admitOperation(false);
|
|
1376
|
+
if (
|
|
1377
|
+
!current.transactionalFsWriteSupported ||
|
|
1378
|
+
current.subject !== initial.subject ||
|
|
1379
|
+
current.connectionInstanceId !== initial.connectionInstanceId
|
|
1380
|
+
) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
"The authorized machine connection changed during the file transfer; the operation was not replayed.",
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const response = await this.controlRpc.request(
|
|
1386
|
+
current.subject,
|
|
1387
|
+
{
|
|
1388
|
+
requestId,
|
|
1389
|
+
epoch: this.epoch,
|
|
1390
|
+
op,
|
|
1391
|
+
resourcePolicy: undefined,
|
|
1392
|
+
},
|
|
1393
|
+
{ timeoutMs: this.timeoutMs },
|
|
1394
|
+
);
|
|
1395
|
+
if (response.requestId !== requestId)
|
|
1396
|
+
throw new Error("The machine returned a mismatched file-transfer response identity.");
|
|
1397
|
+
const error = response.error
|
|
1398
|
+
? agentErrorToControlError(response.error, requestId)
|
|
1399
|
+
: undefined;
|
|
1400
|
+
this.emitOp({
|
|
1401
|
+
op: op.$case,
|
|
1402
|
+
outcome: error || !response.result ? "failed" : "ok",
|
|
1403
|
+
healed: false,
|
|
1404
|
+
retries: 0,
|
|
1405
|
+
durationMs: Date.now() - startedAt,
|
|
1406
|
+
machineId: this.agentId,
|
|
1407
|
+
...(error
|
|
1408
|
+
? { code: error.code, reason: error.reason, faultClass: selfhostedFaultClass(error) }
|
|
1409
|
+
: {}),
|
|
1410
|
+
});
|
|
1411
|
+
return response;
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
this.emitOp({
|
|
1414
|
+
op: op.$case,
|
|
1415
|
+
outcome: "failed",
|
|
1416
|
+
healed: false,
|
|
1417
|
+
retries: 0,
|
|
1418
|
+
durationMs: Date.now() - startedAt,
|
|
1419
|
+
machineId: this.agentId,
|
|
1420
|
+
...(error instanceof SelfhostedControlError
|
|
1421
|
+
? { code: error.code, reason: error.reason, faultClass: selfhostedFaultClass(error) }
|
|
1422
|
+
: {}),
|
|
1423
|
+
});
|
|
1424
|
+
throw error;
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
const resolvedBase = typeof base === "function" ? await base() : base;
|
|
1428
|
+
const transferStartedAt = Date.now();
|
|
1429
|
+
try {
|
|
1430
|
+
const result = await transferEditorFile({
|
|
1431
|
+
path: resolveConnectedMachinePath(this.workspaceRoot, path),
|
|
1432
|
+
content: bytes,
|
|
1433
|
+
...(resolvedBase === undefined ? {} : { baseContent: encoder.encode(resolvedBase) }),
|
|
1434
|
+
request: requestTransfer,
|
|
1435
|
+
});
|
|
1436
|
+
// Unlike physical request observations, this is a verified file outcome.
|
|
1437
|
+
this.emitOp({
|
|
1438
|
+
op: "fsWrite",
|
|
1439
|
+
outcome: "ok",
|
|
1440
|
+
healed: result.recovered,
|
|
1441
|
+
retries: 0,
|
|
1442
|
+
durationMs: Date.now() - transferStartedAt,
|
|
1443
|
+
machineId: this.agentId,
|
|
1444
|
+
});
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
this.emitOp({
|
|
1447
|
+
op: "fsWrite",
|
|
1448
|
+
outcome: "failed",
|
|
1449
|
+
healed: false,
|
|
1450
|
+
retries: 0,
|
|
1451
|
+
durationMs: Date.now() - transferStartedAt,
|
|
1452
|
+
machineId: this.agentId,
|
|
1453
|
+
});
|
|
1454
|
+
throw error;
|
|
1455
|
+
}
|
|
1456
|
+
return async (source, sourceBase) => {
|
|
1457
|
+
// A move is verified destination replacement followed by source cleanup,
|
|
1458
|
+
// not an atomic rename. Never retarget cleanup after a runner change.
|
|
1459
|
+
// The source check and deletion are not CAS against unrelated writers.
|
|
1460
|
+
try {
|
|
1461
|
+
const sourcePath = resolveConnectedMachinePath(this.workspaceRoot, source);
|
|
1462
|
+
const sourceBytes: Uint8Array[] = [];
|
|
1463
|
+
let offset = 0;
|
|
1464
|
+
let total = Infinity;
|
|
1465
|
+
while (offset < total) {
|
|
1466
|
+
const reply = await requestTransfer(crypto.randomUUID(), {
|
|
1467
|
+
$case: "fsRead",
|
|
1468
|
+
fsRead: {
|
|
1469
|
+
path: sourcePath,
|
|
1470
|
+
offset: String(offset),
|
|
1471
|
+
length: String(SELFHOSTED_FILE_CHUNK_BYTES),
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
if (reply.error) throw agentErrorToControlError(reply.error);
|
|
1475
|
+
if (reply.result?.$case !== "fsRead")
|
|
1476
|
+
throw new Error("Source verification response missing");
|
|
1477
|
+
const part = reply.result.fsRead;
|
|
1478
|
+
total = safeWireSize(part.totalSize, "source file size");
|
|
1479
|
+
if (!part.content.length && offset < total)
|
|
1480
|
+
throw new Error("Source verification was incomplete");
|
|
1481
|
+
sourceBytes.push(part.content);
|
|
1482
|
+
offset += part.content.length;
|
|
1483
|
+
}
|
|
1484
|
+
const actual = new Uint8Array(offset);
|
|
1485
|
+
let cursor = 0;
|
|
1486
|
+
for (const chunk of sourceBytes) {
|
|
1487
|
+
actual.set(chunk, cursor);
|
|
1488
|
+
cursor += chunk.length;
|
|
1489
|
+
}
|
|
1490
|
+
if (fileContentDigest(actual) !== fileContentDigest(encoder.encode(sourceBase)))
|
|
1491
|
+
throw new Error("Source changed before move cleanup");
|
|
1492
|
+
const reply = await requestTransfer(crypto.randomUUID(), {
|
|
1493
|
+
$case: "fsRemove",
|
|
1494
|
+
fsRemove: { path: sourcePath, recursive: false },
|
|
1495
|
+
});
|
|
1496
|
+
if (reply.error) throw agentErrorToControlError(reply.error);
|
|
1497
|
+
if (reply.result?.$case !== "fsRemove")
|
|
1498
|
+
throw new Error("Source cleanup response missing");
|
|
1499
|
+
} catch (cause) {
|
|
1500
|
+
throw new Error(
|
|
1501
|
+
"The destination edit was verified, but source cleanup was not verified. The move was not replayed; inspect both paths.",
|
|
1502
|
+
{ cause },
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1329
1506
|
};
|
|
1330
1507
|
const deletePath = async (path: string): Promise<void> => {
|
|
1331
1508
|
const result = await this.call({
|
|
@@ -1351,9 +1528,15 @@ export class SelfhostedSession {
|
|
|
1351
1528
|
const current = await readText(operation.path);
|
|
1352
1529
|
const next = applyDiff(current, operation.diff);
|
|
1353
1530
|
const destination = operation.moveTo ?? operation.path;
|
|
1354
|
-
|
|
1531
|
+
const destinationBase =
|
|
1532
|
+
destination === operation.path
|
|
1533
|
+
? current
|
|
1534
|
+
: async () =>
|
|
1535
|
+
(await pathExists(destination)) ? await readText(destination) : undefined;
|
|
1536
|
+
const cleanup = await writeText(destination, next, destinationBase);
|
|
1355
1537
|
if (operation.moveTo && destination !== operation.path) {
|
|
1356
|
-
await
|
|
1538
|
+
if (cleanup) await cleanup(operation.path, current);
|
|
1539
|
+
else await deletePath(operation.path);
|
|
1357
1540
|
}
|
|
1358
1541
|
return {};
|
|
1359
1542
|
},
|
|
@@ -1708,6 +1891,7 @@ export class SelfhostedSandboxClient {
|
|
|
1708
1891
|
private readonly operationResourcePolicy: SelfhostedOperationResourcePolicy | undefined;
|
|
1709
1892
|
private readonly operationResourcePolicySupported: boolean | undefined;
|
|
1710
1893
|
private readonly operationCpuQuotaSupported: boolean | undefined;
|
|
1894
|
+
private readonly transactionalFsWriteSupported: boolean | undefined;
|
|
1711
1895
|
private readonly resolveOperationAdmission:
|
|
1712
1896
|
| (() => Promise<SelfhostedOperationAdmission | null>)
|
|
1713
1897
|
| undefined;
|
|
@@ -1748,6 +1932,7 @@ export class SelfhostedSandboxClient {
|
|
|
1748
1932
|
operationResourcePolicy?: SelfhostedOperationResourcePolicy;
|
|
1749
1933
|
operationResourcePolicySupported?: boolean;
|
|
1750
1934
|
operationCpuQuotaSupported?: boolean;
|
|
1935
|
+
transactionalFsWriteSupported?: boolean;
|
|
1751
1936
|
resolveOperationAdmission?: () => Promise<SelfhostedOperationAdmission | null>;
|
|
1752
1937
|
adoptBackgroundCommand?: SelfhostedSessionDeps["adoptBackgroundCommand"];
|
|
1753
1938
|
settleBackgroundCommand?: SelfhostedSessionDeps["settleBackgroundCommand"];
|
|
@@ -1779,6 +1964,7 @@ export class SelfhostedSandboxClient {
|
|
|
1779
1964
|
this.operationResourcePolicy = opts.operationResourcePolicy;
|
|
1780
1965
|
this.operationResourcePolicySupported = opts.operationResourcePolicySupported;
|
|
1781
1966
|
this.operationCpuQuotaSupported = opts.operationCpuQuotaSupported;
|
|
1967
|
+
this.transactionalFsWriteSupported = opts.transactionalFsWriteSupported;
|
|
1782
1968
|
this.resolveOperationAdmission = opts.resolveOperationAdmission;
|
|
1783
1969
|
this.adoptBackgroundCommand = opts.adoptBackgroundCommand;
|
|
1784
1970
|
this.settleBackgroundCommand = opts.settleBackgroundCommand;
|
|
@@ -1822,6 +2008,7 @@ export class SelfhostedSandboxClient {
|
|
|
1822
2008
|
...(this.operationCpuQuotaSupported !== undefined
|
|
1823
2009
|
? { operationCpuQuotaSupported: this.operationCpuQuotaSupported }
|
|
1824
2010
|
: {}),
|
|
2011
|
+
transactionalFsWriteSupported: this.transactionalFsWriteSupported === true,
|
|
1825
2012
|
...(this.resolveOperationAdmission !== undefined
|
|
1826
2013
|
? { resolveOperationAdmission: this.resolveOperationAdmission }
|
|
1827
2014
|
: {}),
|
|
@@ -1941,6 +2128,7 @@ export interface SelfhostedSessionBuild {
|
|
|
1941
2128
|
operationResourcePolicySupported?: boolean;
|
|
1942
2129
|
/** Exact live CPU enforcement capability paired with the initial snapshot. */
|
|
1943
2130
|
operationCpuQuotaSupported?: boolean;
|
|
2131
|
+
transactionalFsWriteSupported?: boolean;
|
|
1944
2132
|
/** Live last-boundary operation admission resolver; see SelfhostedSessionDeps. */
|
|
1945
2133
|
resolveOperationAdmission?: () => Promise<SelfhostedOperationAdmission | null>;
|
|
1946
2134
|
adoptBackgroundCommand?: SelfhostedSessionDeps["adoptBackgroundCommand"];
|
|
@@ -1996,6 +2184,7 @@ export async function buildSelfhostedBackendSession(
|
|
|
1996
2184
|
...(deps.operationCpuQuotaSupported !== undefined
|
|
1997
2185
|
? { operationCpuQuotaSupported: deps.operationCpuQuotaSupported }
|
|
1998
2186
|
: {}),
|
|
2187
|
+
transactionalFsWriteSupported: deps.transactionalFsWriteSupported === true,
|
|
1999
2188
|
...(deps.resolveOperationAdmission !== undefined
|
|
2000
2189
|
? { resolveOperationAdmission: deps.resolveOperationAdmission }
|
|
2001
2190
|
: {}),
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
type WorkspaceTreeFingerprint,
|
|
15
15
|
} from "@opengeni/contracts";
|
|
16
16
|
import { withSandboxProviderCapture } from "./provider-operation-gate";
|
|
17
|
+
import type { VerifiedHostWorkspaceArchive } from "./archive-spool";
|
|
18
|
+
import { captureHostWorkspaceArchive, fingerprintHostWorkspace } from "./host-archive-spool";
|
|
17
19
|
|
|
18
20
|
export {
|
|
19
21
|
WORKSPACE_ARCHIVE_DESCRIPTOR_VERSION,
|
|
@@ -36,6 +38,55 @@ export type VerifiedWorkspaceArchive = {
|
|
|
36
38
|
nativeSnapshot?: NativeSnapshotRef;
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
export type VerifiedWorkspaceArchivePayload =
|
|
42
|
+
| VerifiedWorkspaceArchive
|
|
43
|
+
| VerifiedHostWorkspaceArchive;
|
|
44
|
+
|
|
45
|
+
/** Host-backed portable archives can go directly to object storage without an
|
|
46
|
+
* intermediate whole-document string or buffer. Existing inline/provider APIs
|
|
47
|
+
* retain their compatibility path when disk-backed publication is unavailable. */
|
|
48
|
+
export async function captureWorkspaceArchiveForStorage(
|
|
49
|
+
session: unknown,
|
|
50
|
+
capturedAtMs: number,
|
|
51
|
+
options: WorkspaceArchiveCaptureOptions,
|
|
52
|
+
objectStorageAvailable: boolean,
|
|
53
|
+
): Promise<VerifiedWorkspaceArchivePayload> {
|
|
54
|
+
const target = session as WorkspaceSession;
|
|
55
|
+
const root = hostBackedWorkspaceRoot(target);
|
|
56
|
+
if (
|
|
57
|
+
process.platform !== "linux" ||
|
|
58
|
+
!root ||
|
|
59
|
+
!objectStorageAvailable ||
|
|
60
|
+
options.strategy === "portable_tar"
|
|
61
|
+
) {
|
|
62
|
+
return await captureVerifiedWorkspaceArchive(session, capturedAtMs, options);
|
|
63
|
+
}
|
|
64
|
+
return await withSandboxProviderCapture(session, async () => {
|
|
65
|
+
const { spool, workspace } = await captureHostWorkspaceArchive(
|
|
66
|
+
root,
|
|
67
|
+
workspaceFingerprintExcludes(target),
|
|
68
|
+
);
|
|
69
|
+
return {
|
|
70
|
+
kind: "host_spool" as const,
|
|
71
|
+
spool,
|
|
72
|
+
descriptor: {
|
|
73
|
+
version: 1 as const,
|
|
74
|
+
revision: `wa1:${String(capturedAtMs).padStart(13, "0")}:${spool.sha256}`,
|
|
75
|
+
archiveSha256: spool.sha256,
|
|
76
|
+
archiveBytes: spool.byteSize,
|
|
77
|
+
capturedAt: new Date(capturedAtMs).toISOString(),
|
|
78
|
+
workspace,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function disposeWorkspaceArchive(
|
|
85
|
+
archive: VerifiedWorkspaceArchivePayload | undefined,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (archive?.kind === "host_spool") await archive.spool.dispose();
|
|
88
|
+
}
|
|
89
|
+
|
|
39
90
|
export type WorkspaceArchiveIntegrityCode =
|
|
40
91
|
| "archive_metadata_missing"
|
|
41
92
|
| "archive_metadata_invalid"
|
|
@@ -789,7 +840,9 @@ export async function verifyRestoredWorkspace(
|
|
|
789
840
|
"selected host-backed workspace archive was restored by a non-host-backed provider",
|
|
790
841
|
);
|
|
791
842
|
}
|
|
792
|
-
return
|
|
843
|
+
return process.platform === "linux"
|
|
844
|
+
? fingerprintHostWorkspace(root, workspaceFingerprintExcludes(target))
|
|
845
|
+
: fingerprintHostBackedWorkspace(root, workspaceFingerprintExcludes(target));
|
|
793
846
|
})()
|
|
794
847
|
: await fingerprintRemoteWorkspace(target);
|
|
795
848
|
if (!fingerprintsEqual(actual, descriptor.workspace)) {
|