cursor-opencode-provider 0.8.1 → 0.8.3
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/CHANGELOG.md +95 -0
- package/README.md +204 -37
- package/SECURITY.md +20 -8
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +1 -0
- package/dist/context/build.js +32 -5
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/paths.d.ts +1 -1
- package/dist/context/paths.js +7 -4
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.js +4 -3
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +114 -23
- package/dist/language-model.js +2290 -469
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +50 -29
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/plugin-opencode2.js +185 -25
- package/dist/plugin.js +28 -5
- package/dist/pricing-data.d.ts +26 -2
- package/dist/pricing-data.js +26 -2
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/messages.js +91 -8
- package/dist/protocol/request.d.ts +35 -19
- package/dist/protocol/request.js +53 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/tool-call-bridge.d.ts +2 -0
- package/dist/protocol/tool-call-bridge.js +10 -0
- package/dist/protocol/tools.d.ts +74 -8
- package/dist/protocol/tools.js +431 -141
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +16 -4
- package/dist/replay-safety.js +34 -25
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +94 -9
- package/dist/session.js +153 -34
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/transport/connect.d.ts +8 -1
- package/dist/transport/connect.js +103 -23
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +63 -29
- package/package.json +3 -2
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { open, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { encodeMessage } from "./protocol/messages.js";
|
|
6
|
+
import { trace } from "./debug.js";
|
|
7
|
+
import { processShared } from "./process-shared.js";
|
|
8
|
+
export const MAX_RUNTIME_WRITE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const MAX_RUNTIME_READ_BYTES = 1024 * 1024;
|
|
10
|
+
export const RUNTIME_FILE_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
11
|
+
export const MAX_RUNTIME_SPILL_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
export const MAX_RUNTIME_SPILL_FILES = 128;
|
|
13
|
+
const COMPLETED_JOB_TTL_MS = 60_000;
|
|
14
|
+
const maintenance = processShared("runtime-maintenance.v1", () => new Map());
|
|
15
|
+
const spillWrites = processShared("runtime-spill-writes.v1", () => new Map());
|
|
16
|
+
/** Thirty missed one-second header refreshes. */
|
|
17
|
+
const LOST_SUPERVISOR_MS = 30_000;
|
|
18
|
+
/** Deletions per sweep; examining entries is cheap, unlinking is the budgeted work. */
|
|
19
|
+
const SWEEP_DELETE_BUDGET = 64;
|
|
20
|
+
const SPILL_NAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.txt$/i;
|
|
21
|
+
const TERMINAL_NAME = /^[1-9]\d{0,9}\.txt$/;
|
|
22
|
+
const PARTIAL_READ_NOTICE = "\n[Partial read: runtime output was capped. It is NOT the complete file.]\n";
|
|
23
|
+
const STORAGE_NOTICE = "\n[Partial read: terminal output storage failed; captured output may be incomplete. It is NOT the complete file.]\n";
|
|
24
|
+
/** Prefix budget beyond the 8 MiB write cap (covers an oversized YAML header). */
|
|
25
|
+
const RUNTIME_SCAN_HEAD_EXTRA = 64 * 1024;
|
|
26
|
+
/** Enough for the completion footer (`exit_code`, `ended_at`, storage flag). */
|
|
27
|
+
const RUNTIME_SCAN_TAIL_BYTES = 16 * 1024;
|
|
28
|
+
/** Slice encoded text only between code points, including a tail slice. */
|
|
29
|
+
function utf8Slice(bytes, start, end) {
|
|
30
|
+
while (start < end && (bytes[start] & 0xc0) === 0x80)
|
|
31
|
+
start++;
|
|
32
|
+
while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
33
|
+
end--;
|
|
34
|
+
return bytes.subarray(start, end).toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
function directory(dir, create = false) {
|
|
37
|
+
if (create) {
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== "EEXIST")
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const stat = fs.lstatSync(dir);
|
|
47
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
48
|
+
throw new Error("Runtime directory is not a real directory");
|
|
49
|
+
if (create && (stat.mode & 0o777) !== 0o700)
|
|
50
|
+
fs.chmodSync(dir, 0o700);
|
|
51
|
+
}
|
|
52
|
+
/** The one provider-owned layout, pinned by the RequestContext's project root. */
|
|
53
|
+
export function runtimeLayout(root, create = false) {
|
|
54
|
+
root = path.resolve(root);
|
|
55
|
+
// A conversation's frozen context outlives the cache folder it names.
|
|
56
|
+
if (create)
|
|
57
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
58
|
+
directory(root);
|
|
59
|
+
const layout = { root, agentTools: path.join(root, "agent-tools"), terminals: path.join(root, "terminals") };
|
|
60
|
+
directory(layout.agentTools, create);
|
|
61
|
+
directory(layout.terminals, create);
|
|
62
|
+
return layout;
|
|
63
|
+
}
|
|
64
|
+
export function runtimeRootFromContext(context) {
|
|
65
|
+
const env = context?.env;
|
|
66
|
+
return typeof env?.project_folder === "string" && path.isAbsolute(env.project_folder)
|
|
67
|
+
? env.project_folder : undefined;
|
|
68
|
+
}
|
|
69
|
+
function errorText(error) {
|
|
70
|
+
return error instanceof Error ? error.message : String(error);
|
|
71
|
+
}
|
|
72
|
+
/** True when `candidate` is the real (non-symlink) directory `expected`. */
|
|
73
|
+
function sameDirectory(candidate, expected) {
|
|
74
|
+
try {
|
|
75
|
+
const actual = fs.lstatSync(candidate);
|
|
76
|
+
const wanted = fs.lstatSync(expected);
|
|
77
|
+
return actual.isDirectory() && !actual.isSymbolicLink() && actual.ino === wanted.ino && actual.dev === wanted.dev;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Recognize only the exact reserved metadata files. Every other path,
|
|
85
|
+
* including the runtime folders themselves, other names inside them, and
|
|
86
|
+
* paths that resolve elsewhere, belongs to the host's own tools.
|
|
87
|
+
*/
|
|
88
|
+
function metadataTarget(root, requested) {
|
|
89
|
+
if (!path.isAbsolute(requested))
|
|
90
|
+
return undefined;
|
|
91
|
+
const target = path.resolve(requested);
|
|
92
|
+
const parent = path.dirname(target);
|
|
93
|
+
const name = path.basename(target);
|
|
94
|
+
for (const [folder, kind] of [["agent-tools", "spill"], ["terminals", "terminal"]]) {
|
|
95
|
+
const dir = path.join(root, folder);
|
|
96
|
+
let insensitive;
|
|
97
|
+
if (parent === dir) {
|
|
98
|
+
insensitive = sameDirectory(path.join(root, folder.toUpperCase()), dir);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Only fold spelling when the filesystem proves both directory names
|
|
102
|
+
// are the same real directory. Linux case-sensitive paths stay distinct.
|
|
103
|
+
if (parent.toLowerCase() !== dir.toLowerCase() || !sameDirectory(parent, dir))
|
|
104
|
+
continue;
|
|
105
|
+
insensitive = true;
|
|
106
|
+
}
|
|
107
|
+
const normalizedName = insensitive ? name.toLowerCase() : name;
|
|
108
|
+
if (!(kind === "spill" ? SPILL_NAME : TERMINAL_NAME).test(normalizedName))
|
|
109
|
+
return undefined;
|
|
110
|
+
return { target: path.join(dir, normalizedName), kind };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
async function readMetadata(target, args, terminal, storageFailed = false) {
|
|
115
|
+
const parent = fs.lstatSync(path.dirname(target));
|
|
116
|
+
const expectedFile = fs.lstatSync(target);
|
|
117
|
+
const handle = await open(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
118
|
+
try {
|
|
119
|
+
const stat = await handle.stat();
|
|
120
|
+
const currentParent = fs.lstatSync(path.dirname(target));
|
|
121
|
+
if (!currentParent.isDirectory() || currentParent.isSymbolicLink() || currentParent.ino !== parent.ino || currentParent.dev !== parent.dev) {
|
|
122
|
+
throw new Error("Runtime directory changed during read");
|
|
123
|
+
}
|
|
124
|
+
if (!stat.isFile() || stat.nlink > 1 || expectedFile.isSymbolicLink() || stat.ino !== expectedFile.ino || stat.dev !== expectedFile.dev) {
|
|
125
|
+
throw new Error("Runtime metadata must be the expected regular file without hard links");
|
|
126
|
+
}
|
|
127
|
+
const size = stat.size;
|
|
128
|
+
// The completion footer outlives the temporary job/error marker. The
|
|
129
|
+
// command and title precede status and have no length limit.
|
|
130
|
+
if (terminal && !storageFailed) {
|
|
131
|
+
const headerLength = await terminalHeaderLength(handle, size);
|
|
132
|
+
const status = await readRange(handle, Math.max(0, headerLength - 512), Math.min(headerLength, 512));
|
|
133
|
+
const footer = await readRange(handle, Math.max(0, size - RUNTIME_SCAN_TAIL_BYTES), RUNTIME_SCAN_TAIL_BYTES);
|
|
134
|
+
storageFailed = /\nstatus: (?:aborted|failed) *\n/.test(status.toString("latin1"))
|
|
135
|
+
&& /\noutput_storage_failed: true\n---\n$/.test(footer.toString("latin1"));
|
|
136
|
+
}
|
|
137
|
+
const offset = typeof args.offset === "number" && args.offset > 0 ? Math.floor(args.offset) : 1;
|
|
138
|
+
const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : undefined;
|
|
139
|
+
const ranged = offset > 1 || limit !== undefined;
|
|
140
|
+
const contentBudget = MAX_RUNTIME_READ_BYTES - Buffer.byteLength(PARTIAL_READ_NOTICE) - (storageFailed ? Buffer.byteLength(STORAGE_NOTICE) : 0);
|
|
141
|
+
const allowed = storageFailed ? contentBudget : MAX_RUNTIME_READ_BYTES;
|
|
142
|
+
// Two bytes past the budget tell a complete range's own terminator apart
|
|
143
|
+
// from more output.
|
|
144
|
+
const scan = await scanLines(handle, size, offset, limit === undefined ? Infinity : offset + limit, allowed + 2);
|
|
145
|
+
// A complete range is its lines joined, without the last line's terminator.
|
|
146
|
+
const page = ranged && scan.page.length <= allowed + 1 && scan.page.at(-1) === 0x0a ? scan.page.subarray(0, -1) : scan.page;
|
|
147
|
+
const capped = page.length > allowed;
|
|
148
|
+
let content = page.toString("utf8");
|
|
149
|
+
if (capped && terminal && !ranged) {
|
|
150
|
+
const omit = "\n[Earlier output omitted]\n";
|
|
151
|
+
const headKeep = 2048;
|
|
152
|
+
const tailBudget = Math.max(0, contentBudget - headKeep - Buffer.byteLength(omit));
|
|
153
|
+
const tail = await readRange(handle, Math.max(0, size - tailBudget), tailBudget);
|
|
154
|
+
content = utf8Slice(page, 0, headKeep) + omit + utf8Slice(tail, 0, tail.length);
|
|
155
|
+
}
|
|
156
|
+
else if (capped) {
|
|
157
|
+
content = utf8Slice(page, 0, contentBudget);
|
|
158
|
+
}
|
|
159
|
+
if (storageFailed)
|
|
160
|
+
content += STORAGE_NOTICE;
|
|
161
|
+
if (capped)
|
|
162
|
+
content += PARTIAL_READ_NOTICE;
|
|
163
|
+
return { success: { path: target, content, total_lines: scan.lines, file_size: size, truncated: capped || storageFailed, range_applied: ranged } };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await handle.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The first `cap` bytes of lines `first` up to `end`, and the file's line
|
|
171
|
+
* count, read in fixed chunks so a small page of a large record never holds
|
|
172
|
+
* the whole record in memory.
|
|
173
|
+
*/
|
|
174
|
+
async function scanLines(handle, size, first, end, cap) {
|
|
175
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
176
|
+
const page = [];
|
|
177
|
+
let kept = 0;
|
|
178
|
+
let line = 1;
|
|
179
|
+
let last = 0x0a;
|
|
180
|
+
for (let position = 0; position < size;) {
|
|
181
|
+
const read = await handle.read(chunk, 0, Math.min(chunk.length, size - position), position);
|
|
182
|
+
if (!read.bytesRead)
|
|
183
|
+
break;
|
|
184
|
+
const bytes = chunk.subarray(0, read.bytesRead);
|
|
185
|
+
for (let from = 0; from < bytes.length;) {
|
|
186
|
+
const newline = bytes.indexOf(0x0a, from);
|
|
187
|
+
const stop = newline < 0 ? bytes.length : newline + 1;
|
|
188
|
+
if (line >= first && line < end && kept < cap) {
|
|
189
|
+
const piece = Buffer.from(bytes.subarray(from, Math.min(stop, from + cap - kept)));
|
|
190
|
+
page.push(piece);
|
|
191
|
+
kept += piece.length;
|
|
192
|
+
}
|
|
193
|
+
if (newline >= 0)
|
|
194
|
+
line++;
|
|
195
|
+
from = stop;
|
|
196
|
+
}
|
|
197
|
+
last = bytes[bytes.length - 1];
|
|
198
|
+
position += bytes.length;
|
|
199
|
+
}
|
|
200
|
+
return { page: Buffer.concat(page), lines: last === 0x0a ? line - 1 : line };
|
|
201
|
+
}
|
|
202
|
+
async function readRange(handle, position, length) {
|
|
203
|
+
const bytes = Buffer.alloc(length);
|
|
204
|
+
let used = 0;
|
|
205
|
+
while (used < length) {
|
|
206
|
+
const read = await handle.read(bytes, used, length - used, position + used);
|
|
207
|
+
if (!read.bytesRead)
|
|
208
|
+
break;
|
|
209
|
+
used += read.bytesRead;
|
|
210
|
+
}
|
|
211
|
+
return bytes.subarray(0, used);
|
|
212
|
+
}
|
|
213
|
+
/** Bytes through the header's closing `---` line, or 0 when it has none. */
|
|
214
|
+
async function terminalHeaderLength(handle, size) {
|
|
215
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
216
|
+
let window = Buffer.alloc(0);
|
|
217
|
+
let start = 0;
|
|
218
|
+
let end = -1;
|
|
219
|
+
while (end < 0 && start + window.length < size) {
|
|
220
|
+
const read = await handle.read(chunk, 0, chunk.length, start + window.length);
|
|
221
|
+
if (!read.bytesRead)
|
|
222
|
+
break;
|
|
223
|
+
const from = Math.max(start ? 0 : 3, window.length - 4);
|
|
224
|
+
window = Buffer.concat([window, chunk.subarray(0, read.bytesRead)]);
|
|
225
|
+
end = window.indexOf("\n---\n", from);
|
|
226
|
+
if (end < 0 && window.length > 4) {
|
|
227
|
+
start += window.length - 4;
|
|
228
|
+
window = window.subarray(window.length - 4);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return end < 0 ? 0 : start + end + 5;
|
|
232
|
+
}
|
|
233
|
+
function processAlive(pid) {
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, 0);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return error.code === "EPERM";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A live supervisor rewrites its header every second. A running record that
|
|
244
|
+
* has gone quiet and whose supervisor no longer exists (killed, crashed, or
|
|
245
|
+
* lost to a reboot) is finalized as aborted; its group leader has already
|
|
246
|
+
* stopped the command through the supervisor's lifeline.
|
|
247
|
+
*/
|
|
248
|
+
function finalizeLostTerminal(target, now) {
|
|
249
|
+
let fd;
|
|
250
|
+
try {
|
|
251
|
+
fd = fs.openSync(target, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const stat = fs.fstatSync(fd);
|
|
258
|
+
if (!stat.isFile() || stat.nlink !== 1 || now - stat.mtimeMs < LOST_SUPERVISOR_MS)
|
|
259
|
+
return;
|
|
260
|
+
// The command and title precede status and have no length limit, so read
|
|
261
|
+
// until the header ends instead of a fixed prefix.
|
|
262
|
+
const limit = Math.min(stat.size, MAX_RUNTIME_WRITE_BYTES + RUNTIME_SCAN_HEAD_EXTRA);
|
|
263
|
+
let head = Buffer.alloc(0);
|
|
264
|
+
let end = -1;
|
|
265
|
+
while (end < 0 && head.length < limit) {
|
|
266
|
+
const chunk = Buffer.alloc(Math.min(Math.max(64 * 1024, head.length), limit - head.length));
|
|
267
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, head.length);
|
|
268
|
+
if (!read)
|
|
269
|
+
break;
|
|
270
|
+
const from = Math.max(3, head.length - 4);
|
|
271
|
+
head = Buffer.concat([head, chunk.subarray(0, read)]);
|
|
272
|
+
end = head.indexOf("\n---\n", from);
|
|
273
|
+
}
|
|
274
|
+
// latin1 keeps string indexes equal to byte offsets for the in-place patch.
|
|
275
|
+
const text = head.toString("latin1");
|
|
276
|
+
if (!text.startsWith("---\n") || end < 0)
|
|
277
|
+
return;
|
|
278
|
+
const header = text.slice(0, end + 1);
|
|
279
|
+
const status = header.indexOf("\nstatus: running ");
|
|
280
|
+
const pid = Number(/\npid: (\d+)\n/.exec(header)?.[1]);
|
|
281
|
+
if (status < 0 || !Number.isSafeInteger(pid) || pid <= 1 || processAlive(pid))
|
|
282
|
+
return;
|
|
283
|
+
fs.writeSync(fd, "\nstatus: aborted ", status);
|
|
284
|
+
const endedAt = new Date(stat.mtimeMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
285
|
+
fs.writeSync(fd, "\n[Terminal supervisor stopped before the command finished; its exit status is unknown.]\n"
|
|
286
|
+
+ `\n---\nexit_code: unknown\nended_at: ${endedAt}\n---\n`, stat.size);
|
|
287
|
+
trace(`runtime metadata: finalized terminal with a lost supervisor ${path.basename(target)}`);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
trace(`runtime metadata: lost-terminal check failed: ${errorText(error)}`);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
fs.closeSync(fd);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function terminalStorageFailed(layout, target) {
|
|
297
|
+
const jobs = path.join(layout.terminals, ".jobs");
|
|
298
|
+
const id = path.basename(target, ".txt");
|
|
299
|
+
let fd;
|
|
300
|
+
try {
|
|
301
|
+
fd = fs.openSync(path.join(jobs, `${id}.json`), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === "ENOENT")
|
|
305
|
+
return false;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
let owner;
|
|
309
|
+
try {
|
|
310
|
+
const bytes = Buffer.alloc(4096);
|
|
311
|
+
const stat = fs.fstatSync(fd);
|
|
312
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > bytes.length)
|
|
313
|
+
throw new Error("Invalid terminal owner record");
|
|
314
|
+
owner = JSON.parse(bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0)).toString());
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
fs.closeSync(fd);
|
|
318
|
+
}
|
|
319
|
+
if (owner.shellId !== Number(id) || !SPILL_NAME.test(owner.job + ".txt"))
|
|
320
|
+
throw new Error("Invalid terminal owner identity");
|
|
321
|
+
directory(jobs);
|
|
322
|
+
const job = path.join(jobs, owner.job);
|
|
323
|
+
// A swept job leaves its index briefly (or, with an unreadable owner, until
|
|
324
|
+
// the TTL); the finished record's footer carries any storage failure.
|
|
325
|
+
const stat = fs.lstatSync(job, { throwIfNoEntry: false });
|
|
326
|
+
if (!stat)
|
|
327
|
+
return false;
|
|
328
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
329
|
+
throw new Error("Runtime directory is not a real directory");
|
|
330
|
+
return fs.existsSync(path.join(job, "error"));
|
|
331
|
+
}
|
|
332
|
+
async function writeSpillLocked(layout, target, content) {
|
|
333
|
+
const bytes = Buffer.from(content);
|
|
334
|
+
if (bytes.length > MAX_RUNTIME_WRITE_BYTES)
|
|
335
|
+
throw new Error("Runtime metadata exceeds the 8 MiB write limit");
|
|
336
|
+
const lock = path.join(layout.agentTools, ".write-lock");
|
|
337
|
+
let lockFd;
|
|
338
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
339
|
+
try {
|
|
340
|
+
lockFd = fs.openSync(lock, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600);
|
|
341
|
+
fs.writeSync(lockFd, String(process.pid));
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (lockFd !== undefined) {
|
|
346
|
+
fs.closeSync(lockFd);
|
|
347
|
+
fs.unlinkSync(lock);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
if (error.code !== "EEXIST")
|
|
351
|
+
throw error;
|
|
352
|
+
const stat = fs.lstatSync(lock);
|
|
353
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 32)
|
|
354
|
+
throw new Error("Unsafe runtime write lock");
|
|
355
|
+
const pid = Number(fs.readFileSync(lock, "utf8"));
|
|
356
|
+
if ((Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
|
|
357
|
+
|| (!(Number.isSafeInteger(pid) && pid > 0) && Date.now() - stat.mtimeMs < LOST_SUPERVISOR_MS)) {
|
|
358
|
+
throw new Error("Runtime spill store is busy; retry the write");
|
|
359
|
+
}
|
|
360
|
+
const current = fs.lstatSync(lock);
|
|
361
|
+
if (current.ino !== stat.ino || current.dev !== stat.dev)
|
|
362
|
+
throw new Error("Runtime write lock changed");
|
|
363
|
+
fs.unlinkSync(lock);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (lockFd === undefined)
|
|
367
|
+
throw new Error("Runtime spill store is busy; retry the write");
|
|
368
|
+
try {
|
|
369
|
+
let totalBytes = bytes.length;
|
|
370
|
+
let totalFiles = 1;
|
|
371
|
+
for (const entry of fs.readdirSync(layout.agentTools, { withFileTypes: true })) {
|
|
372
|
+
// The exclusive writer lock proves these are abandoned atomic writes,
|
|
373
|
+
// rather than another process's in-progress temporary file.
|
|
374
|
+
if (entry.isFile() && /^\.spill-[0-9a-f-]{36}$/.test(entry.name)) {
|
|
375
|
+
const file = path.join(layout.agentTools, entry.name);
|
|
376
|
+
const stat = fs.lstatSync(file);
|
|
377
|
+
if (!stat.isSymbolicLink() && stat.nlink === 1)
|
|
378
|
+
fs.unlinkSync(file);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!entry.isFile() || !SPILL_NAME.test(entry.name))
|
|
382
|
+
continue;
|
|
383
|
+
const file = path.join(layout.agentTools, entry.name);
|
|
384
|
+
if (file === target)
|
|
385
|
+
continue;
|
|
386
|
+
const stat = fs.lstatSync(file);
|
|
387
|
+
totalBytes += stat.size;
|
|
388
|
+
totalFiles++;
|
|
389
|
+
}
|
|
390
|
+
if (totalBytes > MAX_RUNTIME_SPILL_BYTES || totalFiles > MAX_RUNTIME_SPILL_FILES) {
|
|
391
|
+
throw new Error("Runtime spill store reached its 64 MiB / 128-file limit; reuse a spill path or wait for expiry");
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const stat = fs.lstatSync(target);
|
|
395
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
|
|
396
|
+
throw new Error("Unsafe existing runtime metadata file");
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error.code !== "ENOENT")
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
const temporary = path.join(layout.agentTools, `.spill-${randomUUID()}`);
|
|
403
|
+
const parent = fs.lstatSync(layout.agentTools);
|
|
404
|
+
const handle = await open(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
405
|
+
try {
|
|
406
|
+
await handle.writeFile(bytes);
|
|
407
|
+
await handle.close();
|
|
408
|
+
runtimeLayout(layout.root);
|
|
409
|
+
const currentParent = fs.lstatSync(layout.agentTools);
|
|
410
|
+
if (currentParent.ino !== parent.ino || currentParent.dev !== parent.dev)
|
|
411
|
+
throw new Error("Runtime directory changed during write");
|
|
412
|
+
fs.renameSync(temporary, target);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
await handle.close().catch(() => { });
|
|
416
|
+
await unlink(temporary).catch(() => { });
|
|
417
|
+
}
|
|
418
|
+
return { success: { path: target, lines_created: content ? content.split("\n").length : 0, file_size: bytes.length } };
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
fs.closeSync(lockFd);
|
|
422
|
+
fs.unlinkSync(lock);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function writeSpill(layout, target, content) {
|
|
426
|
+
const prior = spillWrites.get(layout.root) ?? Promise.resolve();
|
|
427
|
+
const current = prior.catch(() => { }).then(() => writeSpillLocked(layout, target, content));
|
|
428
|
+
spillWrites.set(layout.root, current);
|
|
429
|
+
try {
|
|
430
|
+
return await current;
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
if (spillWrites.get(layout.root) === current)
|
|
434
|
+
spillWrites.delete(layout.root);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
export function maintainRuntimeFiles(layout, now = Date.now()) {
|
|
438
|
+
if (now - (maintenance.get(layout.root) ?? 0) < 60_000)
|
|
439
|
+
return;
|
|
440
|
+
maintenance.delete(layout.root);
|
|
441
|
+
maintenance.set(layout.root, now);
|
|
442
|
+
while (maintenance.size > 512)
|
|
443
|
+
maintenance.delete(maintenance.keys().next().value);
|
|
444
|
+
sweepRuntimeFiles(layout, now);
|
|
445
|
+
}
|
|
446
|
+
/** Native internal filesystem requests never enter the host's workspace tools. */
|
|
447
|
+
export async function runtimeExecMessages(message, context, allowTools) {
|
|
448
|
+
const root = runtimeRootFromContext(context);
|
|
449
|
+
const variant = message.write_args ? "write_args" : message.read_args ? "read_args" : undefined;
|
|
450
|
+
if (!root || !variant)
|
|
451
|
+
return undefined;
|
|
452
|
+
const args = message[variant];
|
|
453
|
+
const requested = typeof args.path === "string" ? args.path : "";
|
|
454
|
+
let target;
|
|
455
|
+
let result;
|
|
456
|
+
const resultField = variant === "write_args" ? "write_result" : "read_result";
|
|
457
|
+
try {
|
|
458
|
+
target = metadataTarget(root, requested);
|
|
459
|
+
if (!target)
|
|
460
|
+
return undefined;
|
|
461
|
+
if (!allowTools)
|
|
462
|
+
throw new Error("Runtime metadata operations are unavailable on a text-only request");
|
|
463
|
+
const layout = runtimeLayout(root, variant === "write_args");
|
|
464
|
+
maintainRuntimeFiles(layout);
|
|
465
|
+
if (variant === "write_args") {
|
|
466
|
+
if (target.kind !== "spill")
|
|
467
|
+
throw new Error("Terminal records are writable only by their owning runner");
|
|
468
|
+
const data = args.file_bytes;
|
|
469
|
+
let content = typeof args.file_text === "string" ? args.file_text : undefined;
|
|
470
|
+
if (data instanceof Uint8Array && data.length) {
|
|
471
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
472
|
+
}
|
|
473
|
+
else if (typeof data === "string" && data) {
|
|
474
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data, "base64"));
|
|
475
|
+
}
|
|
476
|
+
if (content === undefined || content.includes("\0"))
|
|
477
|
+
throw new Error("Catalog spill must contain UTF-8 text");
|
|
478
|
+
result = await writeSpill(layout, target.target, content);
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
if (target.kind === "terminal") {
|
|
482
|
+
finalizeLostTerminal(target.target, Date.now());
|
|
483
|
+
}
|
|
484
|
+
const storageFailed = target.kind === "terminal" && terminalStorageFailed(layout, target.target);
|
|
485
|
+
result = await readMetadata(target.target, args, target.kind === "terminal", storageFailed);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const code = error.code;
|
|
490
|
+
result = variant === "read_args" && (code === "ENOENT" || code === "ENOTDIR")
|
|
491
|
+
? { file_not_found: { path: requested } }
|
|
492
|
+
: { error: { path: requested, error: errorText(error) } };
|
|
493
|
+
}
|
|
494
|
+
trace(`runtime metadata: ${variant} id=${message.id} ${"success" in result ? "success" : "error"}`);
|
|
495
|
+
return [
|
|
496
|
+
encodeMessage("AgentClientMessage", { exec_client_message: { id: message.id, [resultField]: result } }),
|
|
497
|
+
encodeMessage("AgentClientMessage", { exec_client_control_message: { stream_close: { id: message.id } } }),
|
|
498
|
+
];
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Age-based cleanup. A live supervisor rewrites its record every second, so a
|
|
502
|
+
* terminal record or job untouched for the TTL is finished, was never
|
|
503
|
+
* started, or was orphaned by a crash or reboot. Only deletions are budgeted:
|
|
504
|
+
* young entries early in directory order cannot stall later cleanup.
|
|
505
|
+
*/
|
|
506
|
+
export function sweepRuntimeFiles(layout, now = Date.now()) {
|
|
507
|
+
let budget = SWEEP_DELETE_BUDGET;
|
|
508
|
+
const mtime = (file) => {
|
|
509
|
+
try {
|
|
510
|
+
return fs.lstatSync(file).mtimeMs;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
for (const [dir, pattern] of [[layout.agentTools, SPILL_NAME], [layout.terminals, TERMINAL_NAME]]) {
|
|
517
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
518
|
+
if (budget <= 0)
|
|
519
|
+
return;
|
|
520
|
+
if (!entry.isFile() || !pattern.test(entry.name))
|
|
521
|
+
continue;
|
|
522
|
+
const target = path.join(dir, entry.name);
|
|
523
|
+
try {
|
|
524
|
+
const stat = fs.lstatSync(target);
|
|
525
|
+
if (stat.isSymbolicLink() || stat.nlink !== 1 || now - stat.mtimeMs < RUNTIME_FILE_TTL_MS)
|
|
526
|
+
continue;
|
|
527
|
+
fs.unlinkSync(target);
|
|
528
|
+
budget--;
|
|
529
|
+
}
|
|
530
|
+
catch { /* another runner or cleanup may have replaced the entry */ }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const jobs = path.join(layout.terminals, ".jobs");
|
|
534
|
+
try {
|
|
535
|
+
directory(jobs);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return; /* no supervised job has been launched yet */
|
|
539
|
+
}
|
|
540
|
+
for (const entry of fs.readdirSync(jobs, { withFileTypes: true })) {
|
|
541
|
+
if (budget <= 0)
|
|
542
|
+
return;
|
|
543
|
+
const target = path.join(jobs, entry.name);
|
|
544
|
+
try {
|
|
545
|
+
if (entry.isDirectory() && SPILL_NAME.test(entry.name + ".txt")) {
|
|
546
|
+
let shellId;
|
|
547
|
+
try {
|
|
548
|
+
const owner = JSON.parse(fs.readFileSync(path.join(target, "owner.json"), "utf8"));
|
|
549
|
+
if (Number.isSafeInteger(owner.shellId) && owner.shellId >= 1000 && owner.shellId < 1_000_000)
|
|
550
|
+
shellId = owner.shellId;
|
|
551
|
+
}
|
|
552
|
+
catch { /* a job whose owner record is unreadable still ages out */ }
|
|
553
|
+
const record = shellId === undefined ? undefined : path.join(layout.terminals, `${shellId}.txt`);
|
|
554
|
+
const newest = Math.max(mtime(target), mtime(path.join(target, "done")), record ? mtime(record) : 0);
|
|
555
|
+
const completedAt = mtime(path.join(target, "done"));
|
|
556
|
+
const completed = completedAt > 0 && now - completedAt >= COMPLETED_JOB_TTL_MS;
|
|
557
|
+
if (!completed && now - newest < RUNTIME_FILE_TTL_MS)
|
|
558
|
+
continue;
|
|
559
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
560
|
+
budget--;
|
|
561
|
+
if (shellId === undefined)
|
|
562
|
+
continue;
|
|
563
|
+
const index = path.join(jobs, `${shellId}.json`);
|
|
564
|
+
try {
|
|
565
|
+
if (JSON.parse(fs.readFileSync(index, "utf8")).job === entry.name)
|
|
566
|
+
fs.rmSync(index, { force: true });
|
|
567
|
+
}
|
|
568
|
+
catch { /* the index belongs to another job or is already gone */ }
|
|
569
|
+
}
|
|
570
|
+
else if (entry.isFile() && /^[1-9]\d{3,5}\.json$/.test(entry.name)) {
|
|
571
|
+
// An index whose job is gone would otherwise keep its shell id reserved.
|
|
572
|
+
const job = JSON.parse(fs.readFileSync(target, "utf8")).job;
|
|
573
|
+
if (now - mtime(target) < RUNTIME_FILE_TTL_MS || (typeof job === "string" && fs.existsSync(path.join(jobs, job))))
|
|
574
|
+
continue;
|
|
575
|
+
fs.unlinkSync(target);
|
|
576
|
+
budget--;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch { /* another runner or cleanup may have replaced the entry */ }
|
|
580
|
+
}
|
|
581
|
+
}
|