@turns/dev 0.0.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/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Turns CLI
2
+
3
+ `turns` uploads native agent transcripts directly from this machine to the
4
+ current user's Durable Object on turns.dev. Transcript rows are secret-filtered
5
+ on the machine immediately before the WebSocket boundary; local transcript
6
+ files are never rewritten.
7
+
8
+ Install the CLI with Node.js 22.18 or newer:
9
+
10
+ ```sh
11
+ npm install --global @turns/dev
12
+ ```
13
+
14
+ ## Commands
15
+
16
+ ```text
17
+ turns upload sessions for the current directory once
18
+ turns upload [directory] watch one directory
19
+ turns install install the all-directory user daemon
20
+ turns status report daemon state
21
+ turns uninstall remove the daemon
22
+ turns login replace saved credentials through the browser
23
+ turns logout remove saved credentials
24
+ ```
25
+
26
+ The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
27
+ origin is saved in `~/.config/turns/credentials.json` and reused by later
28
+ invocations and daemon starts. On Linux the other persistent files are:
29
+
30
+ - `~/.config/turns/machine-id`
31
+ - `~/.local/state/turns/native-upload-state.json`
32
+ - `~/.config/systemd/user/turns.service`
33
+
34
+ The credential file contains a seven-day access token and a signed refresh
35
+ token. The CLI refreshes the access token at least once every 24 hours. A new
36
+ machine opens a one-time turns.dev URL, waits on an authentication WebSocket,
37
+ and saves the resulting pair with mode `0600`. Set `TURNS_NO_BROWSER=1` to only
38
+ print the URL.
39
+
40
+ Rows are scanned on their way out for substrings that look like file paths —
41
+ `out/chart.png`, `` `src/app.tsx` ``, `~/shots/a.png` — and each one the
42
+ filesystem confirms is recorded on the session's own log as a resolution: the
43
+ substring, the absolute path, the size, and whether the contents were sent.
44
+ Paths resolve against the session's working directory and are canonicalised
45
+ before the `--allow-directory` prefixes are enforced, so a symlink out of an
46
+ allowed tree is not a way to read the disk.
47
+
48
+ Only images under 10 MB are uploaded, to `PUT /api/file/{machine}/{path}`. Everything else is recorded as a name, so
49
+ a reader learns what a response was pointing at without the machine shipping
50
+ source files. Nothing is claimed until the filesystem confirms it, which is what
51
+ bounds a deliberately broad matcher: prose that merely looks like a path
52
+ resolves to nothing and is never mentioned to the server, and no directory is
53
+ ever scanned.
54
+
55
+ An explicit file browse may upload up to 199 MB to the same private R2 prefix.
56
+ The daemon streams the file from disk and the Worker hands that request stream
57
+ straight to R2, so neither process retains the complete file in memory.
58
+
59
+ The systemd unit uses `KillMode=process`: restarting the uploader leaves its
60
+ detached agent-anchor processes running so in-flight harness turns continue.
61
+ For a foreground daemon restricted to one or more directory trees, repeat
62
+ `--allow-directory`. Both transcript discovery and agent launches enforce the
63
+ canonical prefix, including protection against symlinks that escape it:
64
+
65
+ ```sh
66
+ turns daemon run --server https://turns.example --allow-directory /srv/agents
67
+ ```
68
+
69
+ Against local Wrangler development, use the configured `DEV_USER` directly:
70
+
71
+ ```sh
72
+ turns daemon run --server http://127.0.0.1:8787 \
73
+ --dev-user antimatter15 --allow-directory /tmp/turns-driver-workspaces
74
+ ```
@@ -0,0 +1,319 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import readline from 'node:readline';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
10
+ function withTimeout(promise, timeoutMs, label) {
11
+ let timer;
12
+ return Promise.race([
13
+ promise,
14
+ new Promise((_, reject) => {
15
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
16
+ timer.unref?.();
17
+ }),
18
+ ]).finally(() => clearTimeout(timer));
19
+ }
20
+ function wait(milliseconds) {
21
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
22
+ }
23
+ export class AgentAnchorConnection {
24
+ socketPath;
25
+ socket;
26
+ lines;
27
+ nextId;
28
+ pending;
29
+ listeners;
30
+ recentMessages;
31
+ lastEventCursor;
32
+ snapshot;
33
+ closed;
34
+ constructor(socketPath) {
35
+ this.socketPath = socketPath;
36
+ this.socket = null;
37
+ this.lines = null;
38
+ this.nextId = 1;
39
+ this.pending = new Map();
40
+ this.listeners = new Set();
41
+ this.recentMessages = [];
42
+ this.lastEventCursor = 0;
43
+ this.snapshot = null;
44
+ this.closed = false;
45
+ }
46
+ async connect(timeoutMs = 2_000) {
47
+ const socket = net.createConnection(this.socketPath);
48
+ this.socket = socket;
49
+ socket.setEncoding('utf8');
50
+ this.lines = readline.createInterface({ input: socket });
51
+ this.lines.on('line', (line) => this.handleLine(line));
52
+ this.lines.on('error', (error) => this.handleDisconnect(error));
53
+ socket.on('error', (error) => this.handleDisconnect(error));
54
+ socket.on('close', () => this.handleDisconnect(new Error(`Agent anchor disconnected: ${this.socketPath}`)));
55
+ await withTimeout(new Promise((resolve, reject) => {
56
+ socket.once('connect', resolve);
57
+ socket.once('error', reject);
58
+ }), timeoutMs, `Agent anchor connection ${this.socketPath}`);
59
+ this.snapshot = await this.request('snapshot', {}, timeoutMs);
60
+ return this;
61
+ }
62
+ handleLine(line) {
63
+ if (!line.trim())
64
+ return;
65
+ let message;
66
+ try {
67
+ message = JSON.parse(line);
68
+ }
69
+ catch {
70
+ return;
71
+ }
72
+ if (message.id !== undefined) {
73
+ const pending = this.pending.get(message.id);
74
+ if (!pending)
75
+ return;
76
+ this.pending.delete(message.id);
77
+ if (message.ok === false)
78
+ pending.reject(new Error(message.error || 'Agent anchor request failed.'));
79
+ else
80
+ pending.resolve(message.result);
81
+ return;
82
+ }
83
+ if (message.type === 'anchor_hello' && message.anchor)
84
+ this.snapshot = message.anchor;
85
+ const cursor = ++this.lastEventCursor;
86
+ this.recentMessages.push({ cursor, message });
87
+ if (this.recentMessages.length > 512)
88
+ this.recentMessages.shift();
89
+ for (const listener of this.listeners)
90
+ listener(message, cursor);
91
+ }
92
+ handleDisconnect(error) {
93
+ if (this.closed)
94
+ return;
95
+ this.closed = true;
96
+ try {
97
+ this.lines?.close();
98
+ }
99
+ catch { }
100
+ try {
101
+ this.socket?.destroy();
102
+ }
103
+ catch { }
104
+ for (const pending of this.pending.values())
105
+ pending.reject(error);
106
+ this.pending.clear();
107
+ for (const listener of this.listeners)
108
+ listener({
109
+ type: 'anchor_disconnect',
110
+ error: error?.message || String(error),
111
+ });
112
+ }
113
+ request(type, params = {}, timeoutMs = 15_000) {
114
+ if (this.closed || !this.socket?.writable)
115
+ return Promise.reject(new Error('Agent anchor is not connected.'));
116
+ const id = this.nextId++;
117
+ return withTimeout(new Promise((resolve, reject) => {
118
+ this.pending.set(id, { resolve, reject });
119
+ this.socket.write(`${JSON.stringify({ id, type, ...params })}\n`, (error) => {
120
+ if (!error)
121
+ return;
122
+ this.pending.delete(id);
123
+ reject(error);
124
+ });
125
+ }), timeoutMs, `Agent anchor request ${type}`).finally(() => this.pending.delete(id));
126
+ }
127
+ onEvent(listener) {
128
+ this.listeners.add(listener);
129
+ return () => this.listeners.delete(listener);
130
+ }
131
+ recentEvents() {
132
+ return this.recentMessages.map((entry) => entry.message);
133
+ }
134
+ eventCursor() {
135
+ return this.lastEventCursor;
136
+ }
137
+ recentEventsAfter(cursor) {
138
+ return this.recentMessages.filter((entry) => entry.cursor > cursor).map((entry) => ({ ...entry }));
139
+ }
140
+ close() {
141
+ if (this.closed)
142
+ return;
143
+ this.closed = true;
144
+ try {
145
+ this.lines?.close();
146
+ }
147
+ catch { }
148
+ try {
149
+ this.socket?.end();
150
+ this.socket?.unref();
151
+ }
152
+ catch { }
153
+ for (const pending of this.pending.values())
154
+ pending.reject(new Error('Agent anchor connection closed.'));
155
+ this.pending.clear();
156
+ this.listeners.clear();
157
+ }
158
+ }
159
+ export function agentAnchorDirectory() {
160
+ return path.resolve(process.env.TURNS_AGENT_ANCHOR_DIR || path.join(os.homedir(), '.local', 'state', 'turns-dev', 'agent-anchors'));
161
+ }
162
+ function anchorEntryPoint() {
163
+ const configured = process.env.TURNS_AGENT_ANCHOR_BIN;
164
+ const extension = path.extname(fileURLToPath(import.meta.url));
165
+ const candidate = path.resolve(configured || path.join(HERE, `agent-anchor${extension}`));
166
+ if (!fs.existsSync(candidate)) {
167
+ throw new Error(`The Turns agent anchor was not found at ${candidate}. Set TURNS_AGENT_ANCHOR_BIN.`);
168
+ }
169
+ return candidate;
170
+ }
171
+ function socketPathFor(sessionId) {
172
+ const directory = agentAnchorDirectory();
173
+ const sessionHash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 12);
174
+ const nonce = crypto.randomUUID().replaceAll('-', '').slice(0, 12);
175
+ const socketPath = path.join(directory, `a-${sessionHash}-${nonce}.sock`);
176
+ if (Buffer.byteLength(socketPath) > 107)
177
+ throw new Error(`Agent anchor socket path is too long: ${socketPath}`);
178
+ return socketPath;
179
+ }
180
+ export function anchorId(connection) {
181
+ return path.basename(connection.socketPath, '.sock');
182
+ }
183
+ export async function launchAgentAnchor(sessionId, timeoutMs = 15_000) {
184
+ const directory = agentAnchorDirectory();
185
+ const logDirectory = path.join(directory, 'logs');
186
+ fs.mkdirSync(logDirectory, { recursive: true, mode: 0o700 });
187
+ const socketPath = socketPathFor(sessionId);
188
+ const logPath = path.join(logDirectory, `${path.basename(socketPath, '.sock')}.log`);
189
+ const logFd = fs.openSync(logPath, 'a', 0o600);
190
+ const entry = anchorEntryPoint();
191
+ const child = spawn(process.execPath, [entry, '--socket', socketPath], {
192
+ cwd: path.dirname(entry),
193
+ env: { ...process.env, TURNS_AGENT_ANCHOR_DIR: directory },
194
+ detached: true,
195
+ stdio: ['ignore', logFd, logFd],
196
+ });
197
+ fs.closeSync(logFd);
198
+ child.unref();
199
+ const deadline = Date.now() + timeoutMs;
200
+ let lastError;
201
+ while (Date.now() < deadline) {
202
+ if (child.exitCode !== null || child.signalCode !== null) {
203
+ throw new Error(`Agent anchor exited before opening its socket (${child.signalCode || child.exitCode}). See ${logPath}`);
204
+ }
205
+ const connection = new AgentAnchorConnection(socketPath);
206
+ try {
207
+ await connection.connect(Math.min(1_500, Math.max(100, deadline - Date.now())));
208
+ return connection;
209
+ }
210
+ catch (error) {
211
+ lastError = error;
212
+ connection.close();
213
+ await wait(100);
214
+ }
215
+ }
216
+ try {
217
+ process.kill(child.pid, 'SIGTERM');
218
+ }
219
+ catch { }
220
+ throw lastError || new Error(`Timed out starting agent anchor. See ${logPath}`);
221
+ }
222
+ export async function connectExistingAgentAnchors() {
223
+ const directory = agentAnchorDirectory();
224
+ let paths = [];
225
+ try {
226
+ paths = fs
227
+ .readdirSync(directory, { withFileTypes: true })
228
+ .filter((entry) => entry.isSocket?.() || entry.name.endsWith('.sock'))
229
+ .map((entry) => path.join(directory, entry.name));
230
+ }
231
+ catch { }
232
+ const anchors = [];
233
+ await Promise.all(paths.map(async (socketPath) => {
234
+ const connection = new AgentAnchorConnection(socketPath);
235
+ try {
236
+ await connection.connect();
237
+ const snapshot = connection.snapshot || {};
238
+ if (snapshot.protocol !== 'turns.agent-anchor.v1' || !snapshot.sessionId || !snapshot.agentSessionId) {
239
+ connection.close();
240
+ return;
241
+ }
242
+ anchors.push({ connection, snapshot });
243
+ }
244
+ catch {
245
+ connection.close();
246
+ try {
247
+ fs.unlinkSync(socketPath);
248
+ }
249
+ catch { }
250
+ }
251
+ }));
252
+ return anchors;
253
+ }
254
+ export function waitForAgentTurn(connection, messageId, timeoutMs = 12 * 60 * 60_000, snapshot = null, afterEventCursor = null) {
255
+ let unsubscribe;
256
+ let settled = false;
257
+ let resolveCompletion;
258
+ let rejectCompletion;
259
+ const resultFromSnapshot = (value) => {
260
+ const turn = value?.currentTurn;
261
+ if (turn?.messageId !== messageId || turn.status === 'running' || value?.status === 'running')
262
+ return null;
263
+ return (turn.result || {
264
+ exitCode: turn.status === 'completed' || turn.status === 'interrupted' ? 0 : 1,
265
+ signal: null,
266
+ timedOut: false,
267
+ error: turn.status === 'completed' || turn.status === 'interrupted'
268
+ ? null
269
+ : { message: 'Anchored agent turn failed.' },
270
+ });
271
+ };
272
+ const settle = (result, error) => {
273
+ if (settled)
274
+ return;
275
+ settled = true;
276
+ if (error)
277
+ rejectCompletion(error);
278
+ else
279
+ resolveCompletion(result);
280
+ };
281
+ const consume = (message, cursor = null) => {
282
+ if (message.type === 'anchor_disconnect')
283
+ return settle(null, new Error(message.error || 'Agent anchor disconnected.'));
284
+ if (afterEventCursor !== null && cursor !== null && cursor <= afterEventCursor)
285
+ return;
286
+ if (String(message.messageId || '') !== String(messageId))
287
+ return;
288
+ if (message.type === 'turn_complete')
289
+ settle(message.result || { exitCode: 0, error: null });
290
+ if (message.type === 'turn_error')
291
+ settle(message.result || { exitCode: 1, error: { message: 'Agent turn failed.' } });
292
+ };
293
+ const completion = new Promise((resolve, reject) => {
294
+ resolveCompletion = resolve;
295
+ rejectCompletion = reject;
296
+ unsubscribe = connection.onEvent(consume);
297
+ if (afterEventCursor === null) {
298
+ for (const message of connection.recentEvents())
299
+ consume(message);
300
+ }
301
+ else {
302
+ for (const entry of connection.recentEventsAfter(afterEventCursor))
303
+ consume(entry.message, entry.cursor);
304
+ }
305
+ const initial = resultFromSnapshot(snapshot);
306
+ if (initial)
307
+ settle(initial);
308
+ if (!settled)
309
+ connection
310
+ .request('snapshot', {}, 5_000)
311
+ .then((latest) => {
312
+ const result = resultFromSnapshot(latest);
313
+ if (result)
314
+ settle(result);
315
+ })
316
+ .catch(() => { });
317
+ });
318
+ return withTimeout(completion, timeoutMs, 'Anchored agent turn').finally(() => unsubscribe?.());
319
+ }