@turns/dev 0.0.6 → 0.0.7

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.
@@ -173,7 +173,7 @@ export function readDirectory(directory) {
173
173
  // clock inside the body would make every read produce different bytes.
174
174
  return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
175
175
  }
176
- export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
176
+ export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', fetchImpl = fetch, }) {
177
177
  const resolved = resolveBrowsePath(requested, {
178
178
  workingDirectory,
179
179
  allowedDirectories: options.allowedDirectories || [],
@@ -209,6 +209,7 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
209
209
  contentType,
210
210
  bytes: resolved.stat.size,
211
211
  modifiedAt,
212
+ ...(contentEtag ? { contentEtag } : {}),
212
213
  fetchImpl,
213
214
  });
214
215
  return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
@@ -0,0 +1,82 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { workingDirectoryIsAllowed } from "./directory-policy.js";
7
+ /**
8
+ * Applying a browser edit to a file this machine holds.
9
+ *
10
+ * The server's queue row says what the edit expects (`beforeHash`) and what it
11
+ * produces (`afterHash`); the bytes themselves are downloaded from the
12
+ * account's bucket only once the file on disk has been shown to still match
13
+ * what the editor started from. The replacement is written beside the target
14
+ * and renamed over it, so a crash mid-write leaves the original untouched, and
15
+ * the downloaded bytes are verified against `afterHash` before the rename —
16
+ * corrupt or truncated downloads never reach the file.
17
+ *
18
+ * A redelivered edit is settled by content: a file that already hashes to
19
+ * `afterHash` reports success without touching the disk, which is what lets
20
+ * the server re-offer an edit whose earlier answer was lost.
21
+ */
22
+ /** SHA-256 of one file's bytes, streamed so large files cost no memory. */
23
+ export async function hashFileSha256(file) {
24
+ const hash = crypto.createHash('sha256');
25
+ await pipeline(fs.createReadStream(file), hash);
26
+ return hash.digest('hex');
27
+ }
28
+ export async function applyFileEdit({ edit, baseUrl, options, fetchImpl = fetch, }) {
29
+ const requested = String(edit.path || '').trim();
30
+ if (!requested || !path.isAbsolute(requested)) {
31
+ throw new Error(`File edits need an absolute path: ${requested || '(empty)'}`);
32
+ }
33
+ let canonical;
34
+ let stat;
35
+ try {
36
+ canonical = fs.realpathSync(requested);
37
+ stat = fs.statSync(canonical);
38
+ }
39
+ catch {
40
+ throw new Error(`No such file: ${requested}`);
41
+ }
42
+ if (!stat.isFile())
43
+ throw new Error(`Not an editable file: ${canonical}`);
44
+ // The same allowlist that bounds browsing bounds writing: a file is editable
45
+ // exactly when the directory holding it could be browsed.
46
+ if (!workingDirectoryIsAllowed(path.dirname(canonical), options.allowedDirectories || [])) {
47
+ throw new Error(`Path is outside the daemon allowlist: ${canonical}`);
48
+ }
49
+ const current = await hashFileSha256(canonical);
50
+ if (current === edit.afterHash)
51
+ return { path: canonical, alreadyApplied: true };
52
+ if (current !== edit.beforeHash) {
53
+ throw new Error('The file changed on this machine after the edit was made, so it was not applied.');
54
+ }
55
+ const url = new URL(`/api/file-edits/${encodeURIComponent(String(edit.id || ''))}`, String(baseUrl));
56
+ const response = await fetchImpl(url, {
57
+ headers: { authorization: `Bearer ${options.token}` },
58
+ });
59
+ if (!response.ok || !response.body) {
60
+ throw new Error(`The edited file could not be downloaded (${response.status}).`);
61
+ }
62
+ const temporary = path.join(path.dirname(canonical), `.${path.basename(canonical)}.turns-edit-${process.pid}.tmp`);
63
+ try {
64
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(temporary, { mode: 0o600 }));
65
+ const written = fs.statSync(temporary);
66
+ if (Number(edit.bytes) !== written.size) {
67
+ throw new Error('The downloaded edit did not match its recorded size.');
68
+ }
69
+ if ((await hashFileSha256(temporary)) !== edit.afterHash) {
70
+ throw new Error('The downloaded edit did not match its recorded content hash.');
71
+ }
72
+ // The temporary file was created 0600 regardless of umask; give the
73
+ // replacement the permissions the original had before it takes its name.
74
+ fs.chmodSync(temporary, stat.mode & 0o7777);
75
+ fs.renameSync(temporary, canonical);
76
+ }
77
+ catch (error) {
78
+ fs.rmSync(temporary, { force: true });
79
+ throw error;
80
+ }
81
+ return { path: canonical, alreadyApplied: false };
82
+ }
@@ -193,7 +193,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
193
193
  return true;
194
194
  }
195
195
  /** Stream one on-disk file to R2 without retaining its body in the daemon. */
196
- export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, fetchImpl = fetch, }) {
196
+ export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
197
197
  const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
198
198
  const body = fs.createReadStream(absolutePath);
199
199
  try {
@@ -204,6 +204,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
204
204
  'content-type': contentType,
205
205
  'content-length': String(bytes),
206
206
  ...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
207
+ ...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
207
208
  },
208
209
  body,
209
210
  // Node requires this opt-in for a streaming request body.
@@ -7,6 +7,7 @@ import { pipeline } from 'node:stream/promises';
7
7
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
8
8
  import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
9
9
  import { browseMachinePath } from "./file-browsing.js";
10
+ import { applyFileEdit } from "./file-edits.js";
10
11
  import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
11
12
  import { compactRuntimeValue } from "./native-record-compaction.js";
12
13
  import { createSecretRedactor } from "./secret-redaction.js";
@@ -1068,6 +1069,55 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1068
1069
  });
1069
1070
  }
1070
1071
  };
1072
+ /**
1073
+ * Apply one queued browser edit, and report how it went.
1074
+ *
1075
+ * Edits are serialized through one chain: the server sends every pending
1076
+ * edit for this machine in queue order, and applying them strictly in
1077
+ * arrival order is what lets several edits to one file chain — each one's
1078
+ * before-hash is the previous one's after-hash. After a real application the
1079
+ * file is re-browsed so the stored copy readers open reflects the new bytes.
1080
+ */
1081
+ let fileEditChain = Promise.resolve();
1082
+ const handleFileEdit = (message, replyRpc) => {
1083
+ fileEditChain = fileEditChain.then(async () => {
1084
+ const sessionId = String(message.sessionId || '');
1085
+ const edit = message.edit || {};
1086
+ try {
1087
+ const applied = await applyFileEdit({ edit, baseUrl, options });
1088
+ if (!applied.alreadyApplied) {
1089
+ await browseMachinePath({
1090
+ requested: applied.path,
1091
+ machine,
1092
+ baseUrl,
1093
+ options,
1094
+ // The applied bytes were verified against this hash, so store it
1095
+ // as their identity: a pane already showing them recognizes the
1096
+ // refresh instead of being offered its own save as a new version.
1097
+ contentEtag: String(edit.afterHash || ''),
1098
+ }).catch((error) => process.stderr.write(`Post-edit refresh failed for ${applied.path}: ${error?.message || error}\n`));
1099
+ }
1100
+ replyRpc.notify({
1101
+ type: 'session.file.edit.result',
1102
+ requestId: message.requestId,
1103
+ sessionId,
1104
+ editId: String(edit.id || ''),
1105
+ ok: true,
1106
+ });
1107
+ }
1108
+ catch (error) {
1109
+ replyRpc.notify({
1110
+ type: 'session.file.edit.result',
1111
+ requestId: message.requestId,
1112
+ sessionId,
1113
+ editId: String(edit.id || ''),
1114
+ ok: false,
1115
+ error: redactor.redactText(error?.message || String(error)),
1116
+ });
1117
+ }
1118
+ });
1119
+ return fileEditChain;
1120
+ };
1071
1121
  const heartbeat = setInterval(() => {
1072
1122
  for (const [sessionId, entry] of anchors) {
1073
1123
  if (entry.connection.closed)
@@ -1089,6 +1139,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1089
1139
  void handleInteractionAnswer(message, rpc);
1090
1140
  if (message.type === 'session.file.browse.request')
1091
1141
  void handleFileBrowse(message, rpc);
1142
+ if (message.type === 'session.file.edit.request')
1143
+ void handleFileEdit(message, rpc);
1092
1144
  });
1093
1145
  const registered = new WeakSet();
1094
1146
  const registering = new WeakSet();
@@ -1100,6 +1152,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1100
1152
  const ready = await control.request({
1101
1153
  type: 'machine.register',
1102
1154
  machineId: machine,
1155
+ machineHostname: os.hostname(),
1103
1156
  workerId,
1104
1157
  workerInstanceId,
1105
1158
  anchorSessionIds: [...anchors.keys()],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",