@turns/dev 0.0.10 → 0.0.12
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/README.md +8 -1
- package/dist/agent-anchor.js +6 -2
- package/dist/codex-update.js +93 -0
- package/dist/file-browsing.js +72 -4
- package/dist/harness-auth.js +289 -0
- package/dist/path-resolution.js +199 -4
- package/dist/queue-worker.js +139 -2
- package/dist/upload-sessions.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,10 +73,17 @@ bounds a deliberately broad matcher: prose that merely looks like a path
|
|
|
73
73
|
resolves to nothing and is never mentioned to the server, and no directory is
|
|
74
74
|
ever scanned.
|
|
75
75
|
|
|
76
|
-
An explicit file browse may upload up to
|
|
76
|
+
An explicit file browse may upload up to 2 GiB to the same private R2 prefix.
|
|
77
|
+
Files over 100 MiB use retryable 32 MiB multipart requests and report upload
|
|
78
|
+
progress to the requesting browser over the control WebSocket.
|
|
77
79
|
The daemon streams the file from disk and the Worker hands that request stream
|
|
78
80
|
straight to R2, so neither process retains the complete file in memory.
|
|
79
81
|
|
|
82
|
+
Range-based viewers can bypass that upload. A capable daemon advertises live
|
|
83
|
+
file ranges, validates the path through the same allowlist, and streams a
|
|
84
|
+
bounded window as ordered 32 KiB WebSocket chunks. The browser hex view pages
|
|
85
|
+
64 KiB viewport windows, including arbitrary offset jumps.
|
|
86
|
+
|
|
80
87
|
The systemd unit uses `KillMode=process`, and the macOS LaunchAgent uses
|
|
81
88
|
`AbandonProcessGroup`: restarting the uploader leaves its detached agent-anchor
|
|
82
89
|
processes running so in-flight harness turns continue.
|
package/dist/agent-anchor.js
CHANGED
|
@@ -599,14 +599,18 @@ class CodexAppServerTransport extends LineTransport {
|
|
|
599
599
|
? await this.request('thread/start', {
|
|
600
600
|
...configuration,
|
|
601
601
|
...(this.anchor.model && this.anchor.model !== 'auto' ? { model: this.anchor.model } : {}),
|
|
602
|
-
...(this.anchor.thinkingLevel
|
|
602
|
+
...(this.anchor.thinkingLevel
|
|
603
|
+
? { config: { model_reasoning_effort: this.anchor.thinkingLevel } }
|
|
604
|
+
: {}),
|
|
603
605
|
...(this.anchor.codexHistoryMode === 'paginated' ? { historyMode: 'paginated' } : {}),
|
|
604
606
|
}, 90_000)
|
|
605
607
|
: await this.request('thread/resume', {
|
|
606
608
|
threadId: this.threadId,
|
|
607
609
|
...configuration,
|
|
608
610
|
...(this.anchor.model && this.anchor.model !== 'auto' ? { model: this.anchor.model } : {}),
|
|
609
|
-
...(this.anchor.thinkingLevel
|
|
611
|
+
...(this.anchor.thinkingLevel
|
|
612
|
+
? { config: { model_reasoning_effort: this.anchor.thinkingLevel } }
|
|
613
|
+
: {}),
|
|
610
614
|
}, 90_000);
|
|
611
615
|
const threadId = String(opened?.thread?.id || opened?.threadId || opened?.id || '');
|
|
612
616
|
if (!threadId)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Machine-local Codex CLI self-update, callable by the Turns daemon. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { resolveHarnessCommand } from "./harness-registry.js";
|
|
5
|
+
const VERSION_TIMEOUT_MS = 15_000;
|
|
6
|
+
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
7
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
8
|
+
/** Run the resolved executable directly—never through a shell—so update
|
|
9
|
+
* requests cannot become arbitrary command execution. */
|
|
10
|
+
function runCommand(command, args, timeoutMs) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command, args, {
|
|
13
|
+
cwd: os.homedir(),
|
|
14
|
+
env: process.env,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
let output = '';
|
|
18
|
+
let settled = false;
|
|
19
|
+
const settle = (result) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
resolve(result);
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
try {
|
|
28
|
+
child.kill('SIGKILL');
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
settle({ output, exitCode: null, timedOut: true });
|
|
32
|
+
}, timeoutMs);
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
const collect = (chunk) => {
|
|
35
|
+
if (output.length < OUTPUT_LIMIT)
|
|
36
|
+
output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
|
|
37
|
+
};
|
|
38
|
+
child.stdout.on('data', collect);
|
|
39
|
+
child.stderr.on('data', collect);
|
|
40
|
+
child.on('error', () => settle({ output, exitCode: null, failed: true }));
|
|
41
|
+
child.on('close', (exitCode) => settle({ output, exitCode }));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function parseCodexVersion(output) {
|
|
45
|
+
const text = String(output || '').trim();
|
|
46
|
+
const prefixed = text.match(/codex(?:-cli)?\s+([^\s]+)/iu);
|
|
47
|
+
return (prefixed?.[1] || text.split(/\s+/u).at(-1) || '').slice(0, 120);
|
|
48
|
+
}
|
|
49
|
+
function commandFailure(result, fallback) {
|
|
50
|
+
if (result.timedOut)
|
|
51
|
+
return new Error('The Codex update timed out.');
|
|
52
|
+
const lines = result.output
|
|
53
|
+
.replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
return new Error(lines.at(-1) || fallback);
|
|
58
|
+
}
|
|
59
|
+
export function createCodexUpdateManager(dependencies = {}) {
|
|
60
|
+
const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
|
|
61
|
+
const run = dependencies.run || runCommand;
|
|
62
|
+
let active = null;
|
|
63
|
+
const perform = async () => {
|
|
64
|
+
const beforeRun = await run(resolveCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
65
|
+
if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
|
|
66
|
+
throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
|
|
67
|
+
}
|
|
68
|
+
const previousVersion = parseCodexVersion(beforeRun.output);
|
|
69
|
+
const updateRun = await run(resolveCommand(), ['update'], UPDATE_TIMEOUT_MS);
|
|
70
|
+
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
71
|
+
throw commandFailure(updateRun, 'Codex could not update itself. Run `codex update` in a terminal on the machine.');
|
|
72
|
+
}
|
|
73
|
+
// Resolve again because the updater may replace a shim or install path.
|
|
74
|
+
const afterRun = await run(resolveCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
75
|
+
if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
|
|
76
|
+
throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
|
|
77
|
+
}
|
|
78
|
+
const currentVersion = parseCodexVersion(afterRun.output);
|
|
79
|
+
return {
|
|
80
|
+
...(previousVersion ? { previousVersion } : {}),
|
|
81
|
+
...(currentVersion ? { currentVersion } : {}),
|
|
82
|
+
updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
/** Concurrent clicks share one installer process instead of racing writes. */
|
|
87
|
+
update() {
|
|
88
|
+
if (!active)
|
|
89
|
+
active = perform().finally(() => (active = null));
|
|
90
|
+
return active;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/file-browsing.js
CHANGED
|
@@ -17,10 +17,19 @@ import { uploadFileStream } from "./path-resolution.js";
|
|
|
17
17
|
*/
|
|
18
18
|
/** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
|
|
19
19
|
export const MAX_DIRECTORY_ENTRIES = 2_000;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** Kept in step with `MAX_FILE_BYTES` on the server. Multipart transport keeps
|
|
21
|
+
* each individual Worker request below the platform request-body ceiling. */
|
|
22
|
+
export const MAX_BROWSE_FILE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
22
23
|
/** The derived label used in reader-facing errors. */
|
|
23
|
-
export const BROWSE_FILE_LIMIT_LABEL = `${
|
|
24
|
+
export const BROWSE_FILE_LIMIT_LABEL = `${MAX_BROWSE_FILE_BYTES / (1024 * 1024 * 1024)} GB`;
|
|
25
|
+
/** Frequent filesystem chunks are useful for accurate accounting but noisy on
|
|
26
|
+
* a control socket. Four frames per second plus phase boundaries stays smooth. */
|
|
27
|
+
export const BROWSE_PROGRESS_INTERVAL_MS = 250;
|
|
28
|
+
/** One browser range request is deliberately small enough to remain cheap to
|
|
29
|
+
* retry or abandon when a reader scrolls quickly through a large file. */
|
|
30
|
+
export const MAX_BROWSE_RANGE_BYTES = 256 * 1024;
|
|
31
|
+
/** Raw bytes per WebSocket payload. Base64 expands this to roughly 43 KiB. */
|
|
32
|
+
export const BROWSE_RANGE_CHUNK_BYTES = 32 * 1024;
|
|
24
33
|
/** Enough data to distinguish source and prose from arbitrary binary bytes
|
|
25
34
|
* without reading a large artifact into the daemon's memory. */
|
|
26
35
|
export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
|
|
@@ -173,7 +182,50 @@ export function readDirectory(directory) {
|
|
|
173
182
|
// clock inside the body would make every read produce different bytes.
|
|
174
183
|
return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
|
|
175
184
|
}
|
|
176
|
-
|
|
185
|
+
/** Stream one bounded file window without creating or updating its R2 object. */
|
|
186
|
+
export async function streamMachineFileRange({ requested, offset, length, workingDirectory = '', allowedDirectories = [], onReady, onChunk, }) {
|
|
187
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
188
|
+
throw new Error('Invalid file range offset.');
|
|
189
|
+
if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_BROWSE_RANGE_BYTES) {
|
|
190
|
+
throw new Error('Invalid file range length.');
|
|
191
|
+
}
|
|
192
|
+
const resolved = resolveBrowsePath(requested, { workingDirectory, allowedDirectories });
|
|
193
|
+
if (!resolved.stat.isFile())
|
|
194
|
+
throw new Error(`Not a readable file: ${resolved.path}`);
|
|
195
|
+
if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
|
|
196
|
+
throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
|
|
197
|
+
}
|
|
198
|
+
if (offset > resolved.stat.size)
|
|
199
|
+
throw new Error('File range starts beyond the end of the file.');
|
|
200
|
+
const boundedLength = Math.min(length, resolved.stat.size - offset);
|
|
201
|
+
const sample = readFileTypeSample(resolved.path, resolved.stat.size);
|
|
202
|
+
const range = {
|
|
203
|
+
path: resolved.path,
|
|
204
|
+
offset,
|
|
205
|
+
length: boundedLength,
|
|
206
|
+
totalBytes: resolved.stat.size,
|
|
207
|
+
contentType: browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size),
|
|
208
|
+
modifiedAt: isoTime(resolved.stat.mtimeMs),
|
|
209
|
+
};
|
|
210
|
+
onReady(range);
|
|
211
|
+
if (!boundedLength)
|
|
212
|
+
return range;
|
|
213
|
+
const stream = fs.createReadStream(resolved.path, {
|
|
214
|
+
start: offset,
|
|
215
|
+
end: offset + boundedLength - 1,
|
|
216
|
+
highWaterMark: BROWSE_RANGE_CHUNK_BYTES,
|
|
217
|
+
});
|
|
218
|
+
let chunkOffset = offset;
|
|
219
|
+
for await (const value of stream) {
|
|
220
|
+
const chunk = value;
|
|
221
|
+
onChunk(chunkOffset, chunk);
|
|
222
|
+
chunkOffset += chunk.byteLength;
|
|
223
|
+
}
|
|
224
|
+
if (chunkOffset !== offset + boundedLength)
|
|
225
|
+
throw new Error('The file changed while its byte range was being read.');
|
|
226
|
+
return range;
|
|
227
|
+
}
|
|
228
|
+
export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', onUploadProgress, fetchImpl = fetch, }) {
|
|
177
229
|
const resolved = resolveBrowsePath(requested, {
|
|
178
230
|
workingDirectory,
|
|
179
231
|
allowedDirectories: options.allowedDirectories || [],
|
|
@@ -201,6 +253,20 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
|
|
|
201
253
|
const modifiedAt = isoTime(resolved.stat.mtimeMs);
|
|
202
254
|
if (resolved.stat.size === 0)
|
|
203
255
|
return { path: resolved.path, kind: 'file', bytes: 0, contentType, modifiedAt };
|
|
256
|
+
let lastProgressAt = 0;
|
|
257
|
+
let lastUploadedBytes = -1;
|
|
258
|
+
const reportProgress = (phase, uploadedBytes, force = false) => {
|
|
259
|
+
if (!onUploadProgress)
|
|
260
|
+
return;
|
|
261
|
+
const bounded = Math.max(lastUploadedBytes, Math.min(resolved.stat.size, Math.max(0, uploadedBytes)));
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
if (!force && now - lastProgressAt < BROWSE_PROGRESS_INTERVAL_MS && bounded < resolved.stat.size)
|
|
264
|
+
return;
|
|
265
|
+
lastProgressAt = now;
|
|
266
|
+
lastUploadedBytes = bounded;
|
|
267
|
+
onUploadProgress({ path: resolved.path, phase, uploadedBytes: bounded, totalBytes: resolved.stat.size });
|
|
268
|
+
};
|
|
269
|
+
reportProgress('starting', 0, true);
|
|
204
270
|
await uploadFileStream({
|
|
205
271
|
baseUrl,
|
|
206
272
|
options,
|
|
@@ -210,6 +276,8 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
|
|
|
210
276
|
bytes: resolved.stat.size,
|
|
211
277
|
modifiedAt,
|
|
212
278
|
...(contentEtag ? { contentEtag } : {}),
|
|
279
|
+
onProgress: (uploadedBytes) => reportProgress('uploading', uploadedBytes),
|
|
280
|
+
onFinalizing: () => reportProgress('finalizing', resolved.stat.size, true),
|
|
213
281
|
fetchImpl,
|
|
214
282
|
});
|
|
215
283
|
return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness sign-in on this machine, steered from the browser.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code and Codex each hold their own account credentials, and when a
|
|
5
|
+
* grant expires every turn fails until someone runs the harness's login
|
|
6
|
+
* command in a terminal on this machine. The daemon is that terminal: it can
|
|
7
|
+
* run both CLIs' browserless sign-in flows and relay the two strings a user
|
|
8
|
+
* actually needs — the approval URL and, for Codex's device flow, the
|
|
9
|
+
* one-time code — while accepting the single string that may come back, the
|
|
10
|
+
* authorization code Claude's flow reads on stdin.
|
|
11
|
+
*
|
|
12
|
+
* Both flows are driven entirely by scraping the CLI's human-readable
|
|
13
|
+
* output, because neither CLI offers a machine-readable login mode. The
|
|
14
|
+
* scraping is deliberately loose — the first https URL is the approval
|
|
15
|
+
* link, a short hyphenated all-caps token is the device code — so cosmetic
|
|
16
|
+
* output changes in either CLI degrade to "no URL yet" rather than to a
|
|
17
|
+
* crash. Auth *status*, by contrast, is read from the commands both CLIs
|
|
18
|
+
* commit to: `claude auth status --json` and `codex login status`.
|
|
19
|
+
*
|
|
20
|
+
* One flow per harness runs at a time. Flow state lives in this process and
|
|
21
|
+
* dies with it; the credentials themselves live where the harness put them,
|
|
22
|
+
* so a daemon restart mid-flow only means starting the flow again.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import { resolveHarnessCommand } from "./harness-registry.js";
|
|
27
|
+
/** Harnesses whose sign-in this module can run. Mirrors the shared
|
|
28
|
+
* HARNESS_AUTH_HARNESSES list; cli/ cannot import cloudflare/shared. */
|
|
29
|
+
export const HARNESS_AUTH_HARNESSES = ['claude', 'codex'];
|
|
30
|
+
const STATUS_TIMEOUT_MS = 30_000;
|
|
31
|
+
const STATUS_CACHE_MS = 10_000;
|
|
32
|
+
const FLOW_TIMEOUT_MS = 15 * 60_000;
|
|
33
|
+
const OUTPUT_TAIL_LIMIT = 4_096;
|
|
34
|
+
const ACTIVE_FLOW_STATUSES = new Set(['starting', 'awaiting_approval', 'awaiting_code']);
|
|
35
|
+
/** Terminal control sequences stripped before any output is interpreted. */
|
|
36
|
+
function stripAnsi(text) {
|
|
37
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escapes are control characters by definition
|
|
38
|
+
return String(text || '').replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
|
|
39
|
+
}
|
|
40
|
+
/** The first https URL in a chunk of CLI output, if any. */
|
|
41
|
+
export function extractLoginUrl(text) {
|
|
42
|
+
const match = stripAnsi(text).match(/https:\/\/[^\s"')\]]+/u);
|
|
43
|
+
return match ? match[0] : '';
|
|
44
|
+
}
|
|
45
|
+
/** A short hyphenated all-caps token on its own line: Codex's device code. */
|
|
46
|
+
export function extractDeviceCode(text) {
|
|
47
|
+
for (const line of stripAnsi(text).split('\n')) {
|
|
48
|
+
const match = line.trim().match(/^([A-Z0-9]{3,8}-[A-Z0-9]{3,8})$/u);
|
|
49
|
+
if (match)
|
|
50
|
+
return match[1];
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
/** What `claude auth status --json` reported, from either exit status. The
|
|
55
|
+
* JSON object is cut out of the surrounding text rather than assumed to be
|
|
56
|
+
* the whole of it, because warnings can share the stream with the report. */
|
|
57
|
+
export function parseClaudeAuthStatus(output) {
|
|
58
|
+
try {
|
|
59
|
+
const text = String(output || '');
|
|
60
|
+
const status = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1));
|
|
61
|
+
if (typeof status?.loggedIn !== 'boolean')
|
|
62
|
+
return { authenticated: null };
|
|
63
|
+
const account = [status.email, status.subscriptionType ? `(${status.subscriptionType})` : '']
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(' ');
|
|
66
|
+
return { authenticated: status.loggedIn, ...(account ? { account } : {}) };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { authenticated: null };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** What `codex login status` reported — on stderr, where codex prints it.
|
|
73
|
+
* The command has no JSON mode, so the two sentences it prints are the
|
|
74
|
+
* interface: "Logged in using …" and "Not logged in". Anything else is
|
|
75
|
+
* treated as unreadable rather than guessed. */
|
|
76
|
+
export function parseCodexLoginStatus(output) {
|
|
77
|
+
const text = stripAnsi(output);
|
|
78
|
+
const loggedIn = text.match(/Logged in(?: using (.+?))?\s*$/mu);
|
|
79
|
+
if (loggedIn)
|
|
80
|
+
return { authenticated: true, ...(loggedIn[1] ? { account: loggedIn[1].trim() } : {}) };
|
|
81
|
+
if (/Not logged in/u.test(text))
|
|
82
|
+
return { authenticated: false };
|
|
83
|
+
return { authenticated: null };
|
|
84
|
+
}
|
|
85
|
+
/** Both streams interleaved: codex reports status on stderr, claude on stdout. */
|
|
86
|
+
function runCommand(command, args, timeoutMs) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const child = spawn(command, args, {
|
|
89
|
+
cwd: os.homedir(),
|
|
90
|
+
env: process.env,
|
|
91
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
92
|
+
});
|
|
93
|
+
let output = '';
|
|
94
|
+
let settled = false;
|
|
95
|
+
const settle = (result) => {
|
|
96
|
+
if (settled)
|
|
97
|
+
return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
resolve(result);
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
try {
|
|
104
|
+
child.kill('SIGKILL');
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
settle({ output, timedOut: true });
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
const collect = (chunk) => {
|
|
111
|
+
if (output.length < 64 * 1024)
|
|
112
|
+
output += chunk;
|
|
113
|
+
};
|
|
114
|
+
child.stdout.on('data', collect);
|
|
115
|
+
child.stderr.on('data', collect);
|
|
116
|
+
child.on('error', () => settle({ output, failed: true }));
|
|
117
|
+
child.on('close', () => settle({ output }));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function createHarnessAuthManager() {
|
|
121
|
+
/** One sign-in flow per harness, keyed by harness id. */
|
|
122
|
+
const flows = new Map();
|
|
123
|
+
/** Recent status answers, so a polling browser does not spawn a CLI per poll. */
|
|
124
|
+
const statusCache = new Map();
|
|
125
|
+
const flowSnapshot = (harness) => {
|
|
126
|
+
const flow = flows.get(harness);
|
|
127
|
+
if (!flow)
|
|
128
|
+
return { status: 'idle' };
|
|
129
|
+
return {
|
|
130
|
+
status: flow.status,
|
|
131
|
+
...(flow.url ? { url: flow.url } : {}),
|
|
132
|
+
...(flow.code ? { code: flow.code } : {}),
|
|
133
|
+
...(flow.needsCode ? { needsCode: true } : {}),
|
|
134
|
+
...(flow.error ? { error: flow.error } : {}),
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
const readStatus = async (harness) => {
|
|
138
|
+
const cached = statusCache.get(harness);
|
|
139
|
+
if (cached && Date.now() - cached.at < STATUS_CACHE_MS)
|
|
140
|
+
return cached.answer;
|
|
141
|
+
let answer = { authenticated: null };
|
|
142
|
+
try {
|
|
143
|
+
const command = resolveHarnessCommand(harness);
|
|
144
|
+
const args = harness === 'claude' ? ['auth', 'status', '--json'] : ['login', 'status'];
|
|
145
|
+
const run = await runCommand(command, args, STATUS_TIMEOUT_MS);
|
|
146
|
+
if (!run.failed && !run.timedOut) {
|
|
147
|
+
answer = harness === 'claude' ? parseClaudeAuthStatus(run.output) : parseCodexLoginStatus(run.output);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// An uninstalled harness reads as unknown, and the flow will say why.
|
|
152
|
+
}
|
|
153
|
+
statusCache.set(harness, { at: Date.now(), answer });
|
|
154
|
+
return answer;
|
|
155
|
+
};
|
|
156
|
+
const finishFlow = (harness, flow, status, error = '') => {
|
|
157
|
+
if (!flows.get(harness) || flows.get(harness) !== flow)
|
|
158
|
+
return;
|
|
159
|
+
flow.status = status;
|
|
160
|
+
flow.error = error || '';
|
|
161
|
+
clearTimeout(flow.timer);
|
|
162
|
+
// A finished flow changed (or failed to change) the credential store, so
|
|
163
|
+
// the next status question must ask the CLI rather than the cache.
|
|
164
|
+
statusCache.delete(harness);
|
|
165
|
+
};
|
|
166
|
+
const startFlow = (harness) => {
|
|
167
|
+
const existing = flows.get(harness);
|
|
168
|
+
if (existing && ACTIVE_FLOW_STATUSES.has(existing.status))
|
|
169
|
+
return;
|
|
170
|
+
const command = resolveHarnessCommand(harness);
|
|
171
|
+
const args = harness === 'claude' ? ['auth', 'login'] : ['login', '--device-auth'];
|
|
172
|
+
const flow = {
|
|
173
|
+
status: 'starting',
|
|
174
|
+
url: '',
|
|
175
|
+
code: '',
|
|
176
|
+
needsCode: false,
|
|
177
|
+
error: '',
|
|
178
|
+
tail: '',
|
|
179
|
+
child: null,
|
|
180
|
+
canceled: false,
|
|
181
|
+
timer: null,
|
|
182
|
+
};
|
|
183
|
+
flows.set(harness, flow);
|
|
184
|
+
const child = spawn(command, args, {
|
|
185
|
+
cwd: os.homedir(),
|
|
186
|
+
env: process.env,
|
|
187
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
188
|
+
});
|
|
189
|
+
flow.child = child;
|
|
190
|
+
flow.timer = setTimeout(() => {
|
|
191
|
+
flow.canceled = true;
|
|
192
|
+
try {
|
|
193
|
+
child.kill('SIGKILL');
|
|
194
|
+
}
|
|
195
|
+
catch { }
|
|
196
|
+
finishFlow(harness, flow, 'failed', 'The sign-in flow timed out.');
|
|
197
|
+
}, FLOW_TIMEOUT_MS);
|
|
198
|
+
flow.timer.unref?.();
|
|
199
|
+
const observe = (chunk) => {
|
|
200
|
+
const text = String(chunk);
|
|
201
|
+
flow.tail = (flow.tail + text).slice(-OUTPUT_TAIL_LIMIT);
|
|
202
|
+
if (!flow.url) {
|
|
203
|
+
const url = extractLoginUrl(text);
|
|
204
|
+
if (url) {
|
|
205
|
+
flow.url = url;
|
|
206
|
+
flow.needsCode = harness === 'claude';
|
|
207
|
+
flow.status = flow.needsCode ? 'awaiting_code' : 'awaiting_approval';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (harness === 'codex' && !flow.code) {
|
|
211
|
+
const code = extractDeviceCode(flow.tail);
|
|
212
|
+
if (code)
|
|
213
|
+
flow.code = code;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
child.stdout.on('data', observe);
|
|
217
|
+
child.stderr.on('data', observe);
|
|
218
|
+
child.on('error', (error) => finishFlow(harness, flow, 'failed', error?.message || 'The sign-in command failed.'));
|
|
219
|
+
child.on('close', (exitCode) => {
|
|
220
|
+
if (flow.status === 'failed')
|
|
221
|
+
return;
|
|
222
|
+
if (flow.canceled) {
|
|
223
|
+
flows.delete(harness);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (exitCode === 0)
|
|
227
|
+
finishFlow(harness, flow, 'succeeded');
|
|
228
|
+
else {
|
|
229
|
+
// The last output line is usually the CLI's own explanation. It has
|
|
230
|
+
// secrets stripped by the caller like every other error string.
|
|
231
|
+
const lines = stripAnsi(flow.tail).split('\n').map((line) => line.trim()).filter(Boolean);
|
|
232
|
+
finishFlow(harness, flow, 'failed', lines.at(-1) || 'The sign-in flow ended without succeeding.');
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
const submitCode = (harness, code) => {
|
|
237
|
+
const flow = flows.get(harness);
|
|
238
|
+
if (!flow || flow.status !== 'awaiting_code' || !flow.child?.stdin?.writable) {
|
|
239
|
+
throw new Error('No sign-in flow is waiting for a code.');
|
|
240
|
+
}
|
|
241
|
+
const line = String(code || '').trim();
|
|
242
|
+
if (!line)
|
|
243
|
+
throw new Error('The pasted code was empty.');
|
|
244
|
+
flow.child.stdin.write(`${line}\n`);
|
|
245
|
+
// The harness is now exchanging the code; only its exit says how it went.
|
|
246
|
+
flow.status = 'awaiting_approval';
|
|
247
|
+
flow.needsCode = false;
|
|
248
|
+
};
|
|
249
|
+
const cancelFlow = (harness) => {
|
|
250
|
+
const flow = flows.get(harness);
|
|
251
|
+
if (!flow)
|
|
252
|
+
return;
|
|
253
|
+
if (ACTIVE_FLOW_STATUSES.has(flow.status)) {
|
|
254
|
+
flow.canceled = true;
|
|
255
|
+
clearTimeout(flow.timer);
|
|
256
|
+
try {
|
|
257
|
+
flow.child?.kill('SIGTERM');
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
}
|
|
261
|
+
flows.delete(harness);
|
|
262
|
+
};
|
|
263
|
+
/** Answer one action. Throws with a user-readable message on refusal. */
|
|
264
|
+
const handle = async ({ harness, action, code }) => {
|
|
265
|
+
if (!HARNESS_AUTH_HARNESSES.includes(harness)) {
|
|
266
|
+
throw new Error(`Sign-in is not supported for harness "${harness}".`);
|
|
267
|
+
}
|
|
268
|
+
if (action === 'login')
|
|
269
|
+
startFlow(harness);
|
|
270
|
+
else if (action === 'code')
|
|
271
|
+
submitCode(harness, code);
|
|
272
|
+
else if (action === 'cancel')
|
|
273
|
+
cancelFlow(harness);
|
|
274
|
+
else if (action !== 'status')
|
|
275
|
+
throw new Error(`Unknown harness auth action "${action}".`);
|
|
276
|
+
const flow = flowSnapshot(harness);
|
|
277
|
+
// While a flow is running its own status is the interesting part, and
|
|
278
|
+
// racing the CLI's status command against its login command helps no one.
|
|
279
|
+
const status = ACTIVE_FLOW_STATUSES.has(flow.status)
|
|
280
|
+
? (statusCache.get(harness)?.answer ?? { authenticated: null })
|
|
281
|
+
: await readStatus(harness);
|
|
282
|
+
return { authenticated: status.authenticated, ...(status.account ? { account: status.account } : {}), flow };
|
|
283
|
+
};
|
|
284
|
+
const close = () => {
|
|
285
|
+
for (const harness of [...flows.keys()])
|
|
286
|
+
cancelFlow(harness);
|
|
287
|
+
};
|
|
288
|
+
return { handle, close };
|
|
289
|
+
}
|
package/dist/path-resolution.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
4
5
|
/**
|
|
5
6
|
* Turning the paths an agent writes into things a reader can open.
|
|
6
7
|
*
|
|
@@ -180,6 +181,178 @@ function fileUploadUrl(baseUrl, machine, absolutePath) {
|
|
|
180
181
|
: encoded;
|
|
181
182
|
return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
|
|
182
183
|
}
|
|
184
|
+
/** Kept in step with the explicit-browse transport constants in shared/files.ts. */
|
|
185
|
+
export const MAX_SINGLE_BROWSE_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
186
|
+
export const BROWSE_MULTIPART_PART_BYTES = 32 * 1024 * 1024;
|
|
187
|
+
export const MAX_BROWSE_MULTIPART_PARTS = 64;
|
|
188
|
+
export function filePartRanges(bytes, partSize = BROWSE_MULTIPART_PART_BYTES) {
|
|
189
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0 || !Number.isSafeInteger(partSize) || partSize <= 0)
|
|
190
|
+
return [];
|
|
191
|
+
const ranges = [];
|
|
192
|
+
for (let start = 0, partNumber = 1; start < bytes; start += partSize, partNumber += 1) {
|
|
193
|
+
ranges.push({ partNumber, start, end: Math.min(bytes, start + partSize) });
|
|
194
|
+
}
|
|
195
|
+
return ranges;
|
|
196
|
+
}
|
|
197
|
+
function fileMultipartUrl(baseUrl, machine, absolutePath, suffix = '') {
|
|
198
|
+
const url = new URL(`/api/file-multipart/${encodeURIComponent(machine)}${suffix}`, String(baseUrl));
|
|
199
|
+
url.searchParams.set('path', absolutePath);
|
|
200
|
+
return url;
|
|
201
|
+
}
|
|
202
|
+
function progressStream(absolutePath, start, end, onProgress) {
|
|
203
|
+
const source = fs.createReadStream(absolutePath, { start, end: end - 1 });
|
|
204
|
+
let loaded = 0;
|
|
205
|
+
const body = new Transform({
|
|
206
|
+
transform(chunk, _encoding, callback) {
|
|
207
|
+
loaded += chunk.byteLength;
|
|
208
|
+
onProgress?.(loaded);
|
|
209
|
+
callback(null, chunk);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
source.pipe(body);
|
|
213
|
+
return {
|
|
214
|
+
body,
|
|
215
|
+
destroy() {
|
|
216
|
+
source.destroy();
|
|
217
|
+
body.destroy();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function uploadFailure(response, label) {
|
|
222
|
+
const detail = (await response.text().catch(() => '')).trim();
|
|
223
|
+
return new Error(`${label} (${response.status})${detail ? `: ${detail.slice(0, 500)}` : '.'}`);
|
|
224
|
+
}
|
|
225
|
+
function retryDelay(milliseconds) {
|
|
226
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
227
|
+
}
|
|
228
|
+
async function uploadMultipartFile({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl, }) {
|
|
229
|
+
const createUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
|
|
230
|
+
const createBody = JSON.stringify({
|
|
231
|
+
size: bytes,
|
|
232
|
+
contentType,
|
|
233
|
+
...(modifiedAt ? { modifiedAt } : {}),
|
|
234
|
+
...(contentEtag ? { contentEtag } : {}),
|
|
235
|
+
});
|
|
236
|
+
const created = await fetchImpl(createUrl, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
...uploadHeaders(options),
|
|
240
|
+
'content-type': 'application/json',
|
|
241
|
+
'content-length': String(Buffer.byteLength(createBody)),
|
|
242
|
+
},
|
|
243
|
+
body: createBody,
|
|
244
|
+
});
|
|
245
|
+
if (!created.ok)
|
|
246
|
+
throw await uploadFailure(created, 'Multipart file upload could not start');
|
|
247
|
+
const payload = (await created.json().catch(() => ({})));
|
|
248
|
+
const uploadId = String(payload.upload?.uploadId || '');
|
|
249
|
+
const partSize = Number(payload.upload?.partSize);
|
|
250
|
+
const receiptToken = String(payload.upload?.receiptToken || '');
|
|
251
|
+
if (!uploadId ||
|
|
252
|
+
!receiptToken ||
|
|
253
|
+
!Number.isSafeInteger(partSize) ||
|
|
254
|
+
partSize <= 0 ||
|
|
255
|
+
partSize > BROWSE_MULTIPART_PART_BYTES) {
|
|
256
|
+
throw new Error('Multipart file upload returned an invalid session.');
|
|
257
|
+
}
|
|
258
|
+
const ranges = filePartRanges(bytes, partSize);
|
|
259
|
+
if (!ranges.length || ranges.length > MAX_BROWSE_MULTIPART_PARTS) {
|
|
260
|
+
throw new Error('Multipart file upload returned an invalid part size.');
|
|
261
|
+
}
|
|
262
|
+
const parts = [];
|
|
263
|
+
let completed = 0;
|
|
264
|
+
try {
|
|
265
|
+
for (const range of ranges) {
|
|
266
|
+
const length = range.end - range.start;
|
|
267
|
+
let receipt = null;
|
|
268
|
+
let greatestLoaded = 0;
|
|
269
|
+
let lastError = null;
|
|
270
|
+
for (let attempt = 0; attempt < 3 && !receipt; attempt += 1) {
|
|
271
|
+
const stream = progressStream(absolutePath, range.start, range.end, (loaded) => {
|
|
272
|
+
greatestLoaded = Math.max(greatestLoaded, loaded);
|
|
273
|
+
onProgress?.(Math.min(bytes, completed + greatestLoaded));
|
|
274
|
+
});
|
|
275
|
+
try {
|
|
276
|
+
const partUrl = fileMultipartUrl(baseUrl, machine, absolutePath, `/parts/${range.partNumber}`);
|
|
277
|
+
partUrl.searchParams.set('upload_id', uploadId);
|
|
278
|
+
const response = await fetchImpl(partUrl, {
|
|
279
|
+
method: 'PUT',
|
|
280
|
+
headers: {
|
|
281
|
+
...uploadHeaders(options),
|
|
282
|
+
'content-type': 'application/octet-stream',
|
|
283
|
+
'content-length': String(length),
|
|
284
|
+
},
|
|
285
|
+
body: stream.body,
|
|
286
|
+
duplex: 'half',
|
|
287
|
+
});
|
|
288
|
+
if (!response.ok)
|
|
289
|
+
throw await uploadFailure(response, `Multipart file part ${range.partNumber} failed`);
|
|
290
|
+
const answer = (await response.json().catch(() => ({})));
|
|
291
|
+
if (answer.part?.partNumber !== range.partNumber || !answer.part.etag) {
|
|
292
|
+
throw new Error(`Multipart file part ${range.partNumber} returned no receipt.`);
|
|
293
|
+
}
|
|
294
|
+
receipt = { partNumber: range.partNumber, etag: String(answer.part.etag) };
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
lastError = error;
|
|
298
|
+
if (attempt < 2)
|
|
299
|
+
await retryDelay(250 * 2 ** attempt);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
stream.destroy();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!receipt)
|
|
306
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'File part failed.'));
|
|
307
|
+
parts.push(receipt);
|
|
308
|
+
completed += length;
|
|
309
|
+
onProgress?.(completed);
|
|
310
|
+
}
|
|
311
|
+
const completeUrl = fileMultipartUrl(baseUrl, machine, absolutePath, '/complete');
|
|
312
|
+
completeUrl.searchParams.set('upload_id', uploadId);
|
|
313
|
+
const completeBody = JSON.stringify({ parts, size: bytes, receiptToken });
|
|
314
|
+
onFinalizing?.();
|
|
315
|
+
let completedUpload = false;
|
|
316
|
+
let completionError = null;
|
|
317
|
+
for (let attempt = 0; attempt < 3 && !completedUpload; attempt += 1) {
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetchImpl(completeUrl, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {
|
|
322
|
+
...uploadHeaders(options),
|
|
323
|
+
'content-type': 'application/json',
|
|
324
|
+
'content-length': String(Buffer.byteLength(completeBody)),
|
|
325
|
+
},
|
|
326
|
+
body: completeBody,
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const failure = await uploadFailure(response, 'Multipart file upload could not finish');
|
|
330
|
+
if (response.status < 500 && response.status !== 408 && response.status !== 429)
|
|
331
|
+
throw failure;
|
|
332
|
+
completionError = failure;
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
completedUpload = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
completionError = error;
|
|
340
|
+
}
|
|
341
|
+
if (!completedUpload && attempt < 2)
|
|
342
|
+
await retryDelay(250 * 2 ** attempt);
|
|
343
|
+
}
|
|
344
|
+
if (!completedUpload) {
|
|
345
|
+
throw completionError instanceof Error ? completionError : new Error('Multipart file upload could not finish.');
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const abortUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
|
|
351
|
+
abortUrl.searchParams.set('upload_id', uploadId);
|
|
352
|
+
await fetchImpl(abortUrl, { method: 'DELETE', headers: uploadHeaders(options) }).catch(() => { });
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
183
356
|
/**
|
|
184
357
|
* Put bytes in the account's private prefix, under the path they came from.
|
|
185
358
|
*
|
|
@@ -204,9 +377,31 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
|
|
|
204
377
|
return true;
|
|
205
378
|
}
|
|
206
379
|
/** Stream one on-disk file to R2 without retaining its body in the daemon. */
|
|
207
|
-
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
|
|
380
|
+
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl = fetch, }) {
|
|
381
|
+
if (bytes > MAX_SINGLE_BROWSE_UPLOAD_BYTES) {
|
|
382
|
+
return uploadMultipartFile({
|
|
383
|
+
baseUrl,
|
|
384
|
+
options,
|
|
385
|
+
machine,
|
|
386
|
+
absolutePath,
|
|
387
|
+
contentType,
|
|
388
|
+
bytes,
|
|
389
|
+
modifiedAt,
|
|
390
|
+
contentEtag,
|
|
391
|
+
onProgress,
|
|
392
|
+
onFinalizing,
|
|
393
|
+
fetchImpl,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
208
396
|
const url = fileUploadUrl(baseUrl, machine, absolutePath);
|
|
209
|
-
|
|
397
|
+
let finalizing = false;
|
|
398
|
+
const stream = progressStream(absolutePath, 0, bytes, (uploadedBytes) => {
|
|
399
|
+
onProgress?.(uploadedBytes);
|
|
400
|
+
if (!finalizing && uploadedBytes >= bytes) {
|
|
401
|
+
finalizing = true;
|
|
402
|
+
onFinalizing?.();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
210
405
|
try {
|
|
211
406
|
const response = await fetchImpl(url, {
|
|
212
407
|
method: 'PUT',
|
|
@@ -217,7 +412,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
|
|
|
217
412
|
...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
|
|
218
413
|
...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
|
|
219
414
|
},
|
|
220
|
-
body,
|
|
415
|
+
body: stream.body,
|
|
221
416
|
// Node requires this opt-in for a streaming request body.
|
|
222
417
|
duplex: 'half',
|
|
223
418
|
});
|
|
@@ -226,7 +421,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
|
|
|
226
421
|
return true;
|
|
227
422
|
}
|
|
228
423
|
finally {
|
|
229
|
-
|
|
424
|
+
stream.destroy();
|
|
230
425
|
}
|
|
231
426
|
}
|
|
232
427
|
/** Send one resolved file to the account's private prefix. */
|
package/dist/queue-worker.js
CHANGED
|
@@ -5,11 +5,13 @@ import path from 'node:path';
|
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
|
|
8
|
+
import { createCodexUpdateManager } from "./codex-update.js";
|
|
8
9
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
9
|
-
import { browseMachinePath } from "./file-browsing.js";
|
|
10
|
+
import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
|
|
10
11
|
import { applyFileEdit } from "./file-edits.js";
|
|
11
12
|
import { searchMachinePaths } from "./file-search.js";
|
|
12
13
|
import { readMachineGitBaseBlob, readMachineGitPatch, readMachineGitStatus } from "./git-status.js";
|
|
14
|
+
import { createHarnessAuthManager } from "./harness-auth.js";
|
|
13
15
|
import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
|
|
14
16
|
import { compactRuntimeValue } from "./native-record-compaction.js";
|
|
15
17
|
import { createSecretRedactor } from "./secret-redaction.js";
|
|
@@ -742,6 +744,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
742
744
|
expectedText: claim.prompt.text,
|
|
743
745
|
createSession: creating,
|
|
744
746
|
permissionMode: String(claim.session?.permissionMode || 'bypassPermissions'),
|
|
747
|
+
model: String(claim.session?.model || ''),
|
|
748
|
+
thinkingLevel: String(claim.session?.modelEffort || ''),
|
|
745
749
|
codexHistoryMode: String(claim.session?.codexHistoryMode || ''),
|
|
746
750
|
timeoutMs: QUEUE_TURN_TIMEOUT_MS,
|
|
747
751
|
idleTimeoutMs: QUEUE_WARM_MS,
|
|
@@ -1052,6 +1056,14 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1052
1056
|
baseUrl,
|
|
1053
1057
|
options,
|
|
1054
1058
|
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1059
|
+
onUploadProgress: (progress) => {
|
|
1060
|
+
replyRpc.notify({
|
|
1061
|
+
type: 'session.file.browse.progress',
|
|
1062
|
+
requestId: message.requestId,
|
|
1063
|
+
sessionId,
|
|
1064
|
+
...progress,
|
|
1065
|
+
});
|
|
1066
|
+
},
|
|
1055
1067
|
});
|
|
1056
1068
|
replyRpc.notify({
|
|
1057
1069
|
type: 'session.file.browse.result',
|
|
@@ -1071,6 +1083,57 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1071
1083
|
});
|
|
1072
1084
|
}
|
|
1073
1085
|
};
|
|
1086
|
+
/** Stream a small file window over the control socket. This never writes R2;
|
|
1087
|
+
* range-based viewers can therefore paint before a multi-gigabyte upload. */
|
|
1088
|
+
const handleFileRange = async (message, replyRpc) => {
|
|
1089
|
+
const sessionId = String(message.sessionId || '');
|
|
1090
|
+
let canonicalPath = '';
|
|
1091
|
+
try {
|
|
1092
|
+
await streamMachineFileRange({
|
|
1093
|
+
requested: String(message.path || ''),
|
|
1094
|
+
offset: Number(message.offset),
|
|
1095
|
+
length: Number(message.length),
|
|
1096
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1097
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1098
|
+
onReady: (range) => {
|
|
1099
|
+
canonicalPath = range.path;
|
|
1100
|
+
replyRpc.notify({
|
|
1101
|
+
type: 'session.file.range.ready',
|
|
1102
|
+
requestId: message.requestId,
|
|
1103
|
+
sessionId,
|
|
1104
|
+
...range,
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
onChunk: (offset, bytes) => {
|
|
1108
|
+
replyRpc.notify({
|
|
1109
|
+
type: 'session.file.range.chunk',
|
|
1110
|
+
requestId: message.requestId,
|
|
1111
|
+
sessionId,
|
|
1112
|
+
path: canonicalPath,
|
|
1113
|
+
offset,
|
|
1114
|
+
data: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'),
|
|
1115
|
+
});
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
replyRpc.notify({
|
|
1119
|
+
type: 'session.file.range.result',
|
|
1120
|
+
requestId: message.requestId,
|
|
1121
|
+
sessionId,
|
|
1122
|
+
path: canonicalPath,
|
|
1123
|
+
ok: true,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
replyRpc.notify({
|
|
1128
|
+
type: 'session.file.range.result',
|
|
1129
|
+
requestId: message.requestId,
|
|
1130
|
+
sessionId,
|
|
1131
|
+
...(canonicalPath ? { path: canonicalPath } : {}),
|
|
1132
|
+
ok: false,
|
|
1133
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1074
1137
|
/** Refresh recursive path-name matches for directory typeahead.
|
|
1075
1138
|
*
|
|
1076
1139
|
* Like browsing, this is a machine filesystem operation rather than a
|
|
@@ -1147,6 +1210,74 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1147
1210
|
});
|
|
1148
1211
|
}
|
|
1149
1212
|
};
|
|
1213
|
+
/**
|
|
1214
|
+
* Answer one harness sign-in question or action for this machine.
|
|
1215
|
+
*
|
|
1216
|
+
* Like browsing, sign-in is a property of the machine rather than of a
|
|
1217
|
+
* session: the session only names which machine and harness the viewer
|
|
1218
|
+
* means. The answer's `flow.url` and `flow.code` deliberately skip the
|
|
1219
|
+
* redactor — they exist to be shown to the user, and the redactor would
|
|
1220
|
+
* mangle an OAuth URL's `state` and `code_challenge` parameters — while
|
|
1221
|
+
* error strings stay redacted like every other handler's.
|
|
1222
|
+
*/
|
|
1223
|
+
const harnessAuth = createHarnessAuthManager();
|
|
1224
|
+
const handleHarnessAuth = async (message, replyRpc) => {
|
|
1225
|
+
const sessionId = String(message.sessionId || '');
|
|
1226
|
+
const harness = String(message.harness || '');
|
|
1227
|
+
try {
|
|
1228
|
+
const answer = await harnessAuth.handle({
|
|
1229
|
+
harness,
|
|
1230
|
+
action: String(message.action || 'status'),
|
|
1231
|
+
code: String(message.code || ''),
|
|
1232
|
+
});
|
|
1233
|
+
if (answer.flow?.error)
|
|
1234
|
+
answer.flow.error = redactor.redactText(answer.flow.error);
|
|
1235
|
+
replyRpc.notify({
|
|
1236
|
+
type: 'session.harness.auth.result',
|
|
1237
|
+
requestId: message.requestId,
|
|
1238
|
+
sessionId,
|
|
1239
|
+
harness,
|
|
1240
|
+
ok: true,
|
|
1241
|
+
answer,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
catch (error) {
|
|
1245
|
+
replyRpc.notify({
|
|
1246
|
+
type: 'session.harness.auth.result',
|
|
1247
|
+
requestId: message.requestId,
|
|
1248
|
+
sessionId,
|
|
1249
|
+
harness,
|
|
1250
|
+
ok: false,
|
|
1251
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
/** Upgrade the machine's Codex installation. Only this fixed, resolved
|
|
1256
|
+
* command is exposed; the browser cannot supply a binary, arguments, or
|
|
1257
|
+
* shell input. Errors are redacted before crossing the machine boundary. */
|
|
1258
|
+
const codexUpdate = createCodexUpdateManager();
|
|
1259
|
+
const handleCodexUpdate = async (message, replyRpc) => {
|
|
1260
|
+
const sessionId = String(message.sessionId || '');
|
|
1261
|
+
try {
|
|
1262
|
+
const answer = await codexUpdate.update();
|
|
1263
|
+
replyRpc.notify({
|
|
1264
|
+
type: 'session.codex.update.result',
|
|
1265
|
+
requestId: message.requestId,
|
|
1266
|
+
sessionId,
|
|
1267
|
+
ok: true,
|
|
1268
|
+
answer,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
replyRpc.notify({
|
|
1273
|
+
type: 'session.codex.update.result',
|
|
1274
|
+
requestId: message.requestId,
|
|
1275
|
+
sessionId,
|
|
1276
|
+
ok: false,
|
|
1277
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1150
1281
|
/**
|
|
1151
1282
|
* Apply one queued browser edit, and report how it went.
|
|
1152
1283
|
*
|
|
@@ -1217,10 +1348,16 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1217
1348
|
void handleInteractionAnswer(message, rpc);
|
|
1218
1349
|
if (message.type === 'session.file.browse.request')
|
|
1219
1350
|
void handleFileBrowse(message, rpc);
|
|
1351
|
+
if (message.type === 'session.file.range.request')
|
|
1352
|
+
void handleFileRange(message, rpc);
|
|
1220
1353
|
if (message.type === 'session.file.search.request')
|
|
1221
1354
|
void handleFileSearch(message, rpc);
|
|
1222
1355
|
if (message.type === 'session.git.read.request')
|
|
1223
1356
|
void handleGitRead(message, rpc);
|
|
1357
|
+
if (message.type === 'session.harness.auth.request')
|
|
1358
|
+
void handleHarnessAuth(message, rpc);
|
|
1359
|
+
if (message.type === 'session.codex.update.request')
|
|
1360
|
+
void handleCodexUpdate(message, rpc);
|
|
1224
1361
|
if (message.type === 'session.file.edit.request')
|
|
1225
1362
|
void handleFileEdit(message, rpc);
|
|
1226
1363
|
});
|
|
@@ -1235,7 +1372,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1235
1372
|
type: 'machine.register',
|
|
1236
1373
|
machineId: machine,
|
|
1237
1374
|
machineHostname: os.hostname(),
|
|
1238
|
-
capabilities: { fileSearch: true, git: true },
|
|
1375
|
+
capabilities: { fileSearch: true, git: true, fileRanges: true, harnessAuth: true, codexUpdate: true },
|
|
1239
1376
|
workerId,
|
|
1240
1377
|
workerInstanceId,
|
|
1241
1378
|
anchorSessionIds: [...anchors.keys()],
|
package/dist/upload-sessions.js
CHANGED
|
@@ -946,8 +946,9 @@ function gitAssociationForSession(cwd, redactor) {
|
|
|
946
946
|
* @param {number} [options.maxBatches]
|
|
947
947
|
* @param {() => boolean} [options.shouldYield]
|
|
948
948
|
* @param {boolean} [options.recoverRemoteAhead]
|
|
949
|
+
* @param {boolean} [options.forceReset]
|
|
949
950
|
*/
|
|
950
|
-
export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
|
|
951
|
+
export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, forceReset = false, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
|
|
951
952
|
const workingDirectory = await nativeDescriptorWorkingDirectory(descriptor);
|
|
952
953
|
const agentSessionId = await nativeDescriptorAgentSessionId(descriptor);
|
|
953
954
|
const git = gitAssociationForSession(workingDirectory, redactor);
|
|
@@ -976,7 +977,14 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
976
977
|
const uploadSessionId = String(opened.sessionId || sessionId);
|
|
977
978
|
let nextRow = Number(opened.nextRow) || 0;
|
|
978
979
|
let initialUpload = nextRow === 0 || cursor.initialUpload === true;
|
|
979
|
-
if (
|
|
980
|
+
if (forceReset && nextRow > 0) {
|
|
981
|
+
await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
|
|
982
|
+
nextRow = 0;
|
|
983
|
+
for (const key of Object.keys(cursor))
|
|
984
|
+
delete cursor[key];
|
|
985
|
+
initialUpload = true;
|
|
986
|
+
}
|
|
987
|
+
else if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
|
|
980
988
|
await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
|
|
981
989
|
nextRow = 0;
|
|
982
990
|
for (const key of Object.keys(cursor))
|