@skrr-ai/cli 0.1.87 → 0.1.89

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.
Files changed (97) hide show
  1. package/dist/commands/agents/chat.js +27 -1
  2. package/dist/commands/computer/index.d.ts +19 -0
  3. package/dist/commands/computer/index.js +37 -0
  4. package/dist/commands/labels/create.d.ts +1 -0
  5. package/dist/commands/labels/create.js +2 -0
  6. package/dist/commands/labels/restore.d.ts +1 -0
  7. package/dist/commands/labels/restore.js +6 -3
  8. package/dist/commands/labels/update.d.ts +1 -0
  9. package/dist/commands/labels/update.js +2 -0
  10. package/dist/commands/machines/computer-adoption.d.ts +16 -0
  11. package/dist/commands/machines/computer-adoption.js +34 -0
  12. package/dist/commands/machines/dedicated/agent-browser.d.ts +25 -0
  13. package/dist/commands/machines/dedicated/agent-browser.js +65 -0
  14. package/dist/commands/machines/dedicated/archive.d.ts +26 -0
  15. package/dist/commands/machines/dedicated/archive.js +113 -0
  16. package/dist/commands/machines/dedicated/audit.d.ts +25 -0
  17. package/dist/commands/machines/dedicated/audit.js +113 -0
  18. package/dist/commands/machines/dedicated/computer.d.ts +23 -0
  19. package/dist/commands/machines/dedicated/computer.js +64 -0
  20. package/dist/commands/machines/dedicated/cp.d.ts +2 -0
  21. package/dist/commands/machines/dedicated/cp.js +74 -2
  22. package/dist/commands/machines/dedicated/git-status.d.ts +15 -0
  23. package/dist/commands/machines/dedicated/git-status.js +56 -0
  24. package/dist/commands/machines/dedicated/index.js +15 -1
  25. package/dist/commands/machines/dedicated/ls.d.ts +27 -0
  26. package/dist/commands/machines/dedicated/ls.js +100 -0
  27. package/dist/commands/machines/dedicated/mkdir.d.ts +18 -0
  28. package/dist/commands/machines/dedicated/mkdir.js +57 -0
  29. package/dist/commands/machines/dedicated/mv.d.ts +24 -0
  30. package/dist/commands/machines/dedicated/mv.js +83 -0
  31. package/dist/commands/machines/dedicated/rm.d.ts +26 -0
  32. package/dist/commands/machines/dedicated/rm.js +100 -0
  33. package/dist/commands/machines/dedicated/search.d.ts +24 -0
  34. package/dist/commands/machines/dedicated/search.js +78 -0
  35. package/dist/commands/machines/dedicated/show.js +17 -2
  36. package/dist/commands/machines/dedicated/stat.d.ts +22 -0
  37. package/dist/commands/machines/dedicated/stat.js +92 -0
  38. package/dist/commands/machines/hosted/index.js +1 -1
  39. package/dist/commands/machines/hosted/list.js +1 -1
  40. package/dist/commands/machines/hosted/start.js +1 -1
  41. package/dist/commands/machines/services/declare.d.ts +24 -0
  42. package/dist/commands/machines/services/declare.js +73 -0
  43. package/dist/commands/machines/services/index.d.ts +15 -0
  44. package/dist/commands/machines/services/index.js +31 -0
  45. package/dist/commands/machines/services/ls.d.ts +17 -0
  46. package/dist/commands/machines/services/ls.js +60 -0
  47. package/dist/commands/machines/services/withdraw.d.ts +19 -0
  48. package/dist/commands/machines/services/withdraw.js +47 -0
  49. package/dist/commands/machines/share.d.ts +29 -0
  50. package/dist/commands/machines/share.js +100 -0
  51. package/dist/commands/machines/shared.d.ts +19 -0
  52. package/dist/commands/machines/shared.js +62 -0
  53. package/dist/commands/machines/shares.d.ts +16 -0
  54. package/dist/commands/machines/shares.js +69 -0
  55. package/dist/commands/machines/unshare.d.ts +19 -0
  56. package/dist/commands/machines/unshare.js +61 -0
  57. package/dist/commands/tasks/labels/create.d.ts +1 -0
  58. package/dist/commands/tasks/labels/create.js +3 -0
  59. package/dist/commands/views/create.d.ts +1 -0
  60. package/dist/commands/views/create.js +5 -0
  61. package/dist/lib/agent-home-workspace.d.ts +7 -0
  62. package/dist/lib/agent-home-workspace.js +147 -0
  63. package/dist/lib/computer-adoption.d.ts +60 -0
  64. package/dist/lib/computer-adoption.js +72 -0
  65. package/dist/lib/computer-consent.d.ts +55 -0
  66. package/dist/lib/computer-consent.js +154 -0
  67. package/dist/lib/computer-files.d.ts +239 -0
  68. package/dist/lib/computer-files.js +707 -0
  69. package/dist/lib/dedicated-copy.d.ts +45 -9
  70. package/dist/lib/dedicated-copy.js +141 -44
  71. package/dist/lib/dedicated-machines.d.ts +80 -0
  72. package/dist/lib/dedicated-machines.js +137 -6
  73. package/dist/lib/exec-runtime-binary.d.ts +3 -1
  74. package/dist/lib/exec-runtime-binary.js +16 -2
  75. package/dist/lib/label-scope.d.ts +12 -0
  76. package/dist/lib/label-scope.js +15 -1
  77. package/dist/lib/machine-audit.d.ts +34 -0
  78. package/dist/lib/machine-audit.js +50 -0
  79. package/dist/lib/machine-grants.d.ts +70 -0
  80. package/dist/lib/machine-grants.js +47 -0
  81. package/dist/lib/machine-services.d.ts +64 -0
  82. package/dist/lib/machine-services.js +60 -0
  83. package/dist/lib/task-view-render.d.ts +2 -0
  84. package/dist/lib/task-view-render.js +1 -1
  85. package/dist/lib/views/vocabulary.d.ts +1 -1
  86. package/dist/lib/views/vocabulary.js +2 -1
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.d.ts +1050 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/computerWire.js +1196 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonToolApproval.js +7 -0
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.d.ts +1050 -0
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/computerWire.js +1175 -0
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonToolApproval.js +7 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  94. package/dist/node_modules/@skrr-ai/data-provider/index.js +22391 -20727
  95. package/dist/node_modules/@skrr-ai/data-provider/package.json +1 -1
  96. package/oclif.manifest.json +14767 -13095
  97. package/package.json +2 -2
@@ -1,3 +1,5 @@
1
+ import type { ComputerFileEntityTag } from '@skrr-ai/data-provider';
2
+ import { statComputerFile } from './computer-files';
1
3
  import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type DedicatedRuntimeScope } from './dedicated-machines';
2
4
  /**
3
5
  * The two copy loops behind `skrr machines dedicated cp`.
@@ -8,6 +10,14 @@ import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type Ded
8
10
  * stages beside its destination on the machine and the final chunk renames it;
9
11
  * a download writes a temporary file beside its local destination and renames.
10
12
  *
13
+ * Every chunk presents the ONE lease generation the command resolved before it
14
+ * started (review C-01), so a machine replaced mid-copy refuses the remaining
15
+ * chunks instead of receiving them. And an upload never replaces a destination
16
+ * it was not told to (review CW-01): it stats the destination first, creates a
17
+ * new file only if the name is still free at commit (`createOnly`), and
18
+ * replaces an existing one only when asked to — and then only the exact version
19
+ * it saw (`replaceTag`).
20
+ *
11
21
  * And neither leaves its partial behind when it is cancelled (OSK-8708).
12
22
  * `listenForCopyInterrupt` turns SIGINT, SIGTERM and SIGHUP into an
13
23
  * AbortSignal the loops honour; a download also removes its temporary file from
@@ -16,12 +26,25 @@ import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type Ded
16
26
  export interface DedicatedCopyIo {
17
27
  readChunk: typeof readDedicatedRuntimeFileChunk;
18
28
  writeChunk: typeof writeDedicatedRuntimeFileChunk;
29
+ /** How an upload learns what its destination is before it sends a byte. */
30
+ statPath: typeof statComputerFile;
19
31
  chunkBytes: number;
20
32
  maxBytes: number;
21
33
  /** Run `fn` if the process exits; returns the unregister. Injectable for tests. */
22
34
  onProcessExit: (fn: () => void) => () => void;
23
35
  }
24
36
  export interface DedicatedCopyOptions extends Partial<DedicatedCopyIo> {
37
+ /**
38
+ * The lease generation the command resolved at its start
39
+ * (`resolveDedicatedFileGeneration`), presented on every call of the copy.
40
+ */
41
+ generation: number;
42
+ /** Upload: replace an existing destination (the version the stat found). */
43
+ overwrite?: boolean;
44
+ /** Upload: replace exactly this version of the destination, and no other. */
45
+ ifTag?: ComputerFileEntityTag;
46
+ /** Upload: told which version is about to be replaced, before any byte is sent. */
47
+ onReplace?: (destination: string, tag: ComputerFileEntityTag) => void;
25
48
  /** Cancels the copy, which then removes its partial file before it rejects. */
26
49
  signal?: AbortSignal;
27
50
  /** Called after every chunk with the bytes copied so far and the file's size. */
@@ -35,8 +58,8 @@ export interface DedicatedCopyOptions extends Partial<DedicatedCopyIo> {
35
58
  * (OSK-8714). Before, these local refusals had no code at all.
36
59
  */
37
60
  export declare class DedicatedCopyError extends Error {
38
- readonly code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED';
39
- constructor(code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED', message: string);
61
+ readonly code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY' | 'DEDICATED_RUNTIME_FILE_EXISTS' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED';
62
+ constructor(code: 'DEDICATED_RUNTIME_FILE_TOO_LARGE' | 'DEDICATED_RUNTIME_FILE_CHANGED' | 'DEDICATED_RUNTIME_FILE_NOT_FOUND' | 'DEDICATED_RUNTIME_PATH_NOT_A_FILE' | 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY' | 'DEDICATED_RUNTIME_FILE_EXISTS' | 'DEDICATED_RUNTIME_FILE_TRANSFER_FAILED', message: string);
40
63
  }
41
64
  declare const SIGNAL_NUMBERS: {
42
65
  readonly SIGHUP: 1;
@@ -76,6 +99,14 @@ export declare function listenForCopyInterrupt({ proc, notice, }?: {
76
99
  export interface DedicatedCopyResult {
77
100
  bytes: number;
78
101
  destination: string;
102
+ /** Upload: true when the copy created the destination, false when it replaced one. */
103
+ created?: boolean;
104
+ /** Upload: the tag of the version that was replaced, as `stat` prints it. */
105
+ replacedTag?: string;
106
+ /** Upload: the committed file's tag, as `stat` prints it. */
107
+ tag?: string;
108
+ /** Upload: the machine answered from the receipt of an earlier, committed attempt. */
109
+ alreadyCommitted?: boolean;
79
110
  }
80
111
  /**
81
112
  * One line of progress for a terminal, redrawn in place: a 60 MiB copy takes
@@ -100,13 +131,18 @@ export declare function intoRemoteDirectory(directory: string, name: string): st
100
131
  * Upload one file. A destination that is a directory — spelled `dir/`, or an
101
132
  * existing directory named without the slash — receives the file under its own
102
133
  * name, as `cp` and `scp` do and as a download into a local directory already
103
- * did. Before, `cp tiny.txt <lease>:repos/scout-a/` sent the whole file and then
104
- * failed the machine's final rename with EISDIR (OSK-8713).
134
+ * did (OSK-8713).
135
+ *
136
+ * The destination is stat'ed before any byte is sent, and what the copy may do
137
+ * to it is decided then and enforced at commit by the machine (review CW-01):
105
138
  *
106
- * `dir/` is resolved here, without asking. A directory named without the slash
107
- * is found out by the machine, which refuses it on the first chunk before
108
- * anything is staged; the copy then goes into it, once.
139
+ * - nothing there → the copy creates it, and commits only if the name is still
140
+ * free (`createOnly`); something that appears meanwhile is never replaced;
141
+ * - a file there → refused before sending anything, unless `overwrite` (replace
142
+ * the version the stat found) or `ifTag` (replace exactly that version) was
143
+ * given; either way the commit replaces only that version (`replaceTag`), and
144
+ * a file changed during the copy is refused `file_changed`, nothing replaced.
109
145
  */
110
- export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope?: DedicatedRuntimeScope, io?: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
111
- export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope?: DedicatedRuntimeScope, io?: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
146
+ export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope: DedicatedRuntimeScope, io: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
147
+ export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope: DedicatedRuntimeScope, io: DedicatedCopyOptions): Promise<DedicatedCopyResult>;
112
148
  export {};
@@ -12,6 +12,7 @@ const node_crypto_1 = require("node:crypto");
12
12
  const node_fs_1 = require("node:fs");
13
13
  const promises_1 = require("node:fs/promises");
14
14
  const node_path_1 = require("node:path");
15
+ const computer_files_1 = require("./computer-files");
15
16
  const dedicated_file_retry_1 = require("./dedicated-file-retry");
16
17
  const dedicated_machines_1 = require("./dedicated-machines");
17
18
  /**
@@ -34,6 +35,7 @@ const changedWhileCopying = (path, where) => new DedicatedCopyError('DEDICATED_R
34
35
  const defaultIo = {
35
36
  readChunk: dedicated_machines_1.readDedicatedRuntimeFileChunk,
36
37
  writeChunk: dedicated_machines_1.writeDedicatedRuntimeFileChunk,
38
+ statPath: computer_files_1.statComputerFile,
37
39
  chunkBytes: dedicated_machines_1.DEDICATED_FILE_CHUNK_BYTES,
38
40
  maxBytes: dedicated_machines_1.DEDICATED_FILE_MAX_BYTES,
39
41
  onProcessExit: (fn) => {
@@ -138,51 +140,107 @@ function intoRemoteDirectory(directory, name) {
138
140
  const parent = directory.replace(/\/\.?$/, '').replace(/^\.$/, '');
139
141
  return parent ? `${parent}/${name}` : name;
140
142
  }
141
- const PATH_IS_DIRECTORY = 'DEDICATED_RUNTIME_PATH_IS_DIRECTORY';
142
- /** The machine refused the destination as a directory before anything was staged. */
143
- class DestinationIsDirectory extends Error {
144
- refusal;
145
- constructor(refusal) {
146
- super('The destination on the machine is a directory');
147
- this.refusal = refusal;
143
+ /**
144
+ * Stat one machine path for an upload. A path that does not exist is `absent`
145
+ * — the copy creates it, and any missing parent with it. Every other refusal
146
+ * (withheld, stale generation, the machine unreachable) is the copy's refusal.
147
+ */
148
+ async function statDestination(statPath, leaseId, remotePath, generation, scope, signal) {
149
+ let result;
150
+ try {
151
+ result = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => statPath(leaseId, { path: remotePath, generation }, scope), signal);
152
+ }
153
+ catch (err) {
154
+ const refusal = (0, computer_files_1.computerFileRefusalInfo)(err);
155
+ // `details.path` (or a path-shaped message) marks a missing PATH; without
156
+ // it the machine itself was not found, which is not a destination to create.
157
+ if (refusal?.code === 'not_found' &&
158
+ (refusal.details?.path !== undefined || /path|file|folder|directory/i.test(refusal.message))) {
159
+ return { kind: 'absent' };
160
+ }
161
+ throw err;
162
+ }
163
+ const type = result.entry.type;
164
+ if (type === 'directory')
165
+ return { kind: 'directory' };
166
+ if (type === 'file' && result.tag) {
167
+ return {
168
+ kind: 'file',
169
+ tag: { inode: result.tag.inode, size: result.tag.size, mtimeNs: result.tag.mtimeNs },
170
+ };
148
171
  }
172
+ return { kind: 'other', type };
149
173
  }
174
+ const sameTag = (a, b) => a.inode === b.inode && a.size === b.size && a.mtimeNs === b.mtimeNs;
150
175
  /**
151
176
  * Upload one file. A destination that is a directory — spelled `dir/`, or an
152
177
  * existing directory named without the slash — receives the file under its own
153
178
  * name, as `cp` and `scp` do and as a download into a local directory already
154
- * did. Before, `cp tiny.txt <lease>:repos/scout-a/` sent the whole file and then
155
- * failed the machine's final rename with EISDIR (OSK-8713).
179
+ * did (OSK-8713).
156
180
  *
157
- * `dir/` is resolved here, without asking. A directory named without the slash
158
- * is found out by the machine, which refuses it on the first chunk before
159
- * anything is staged; the copy then goes into it, once.
181
+ * The destination is stat'ed before any byte is sent, and what the copy may do
182
+ * to it is decided then and enforced at commit by the machine (review CW-01):
183
+ *
184
+ * - nothing there → the copy creates it, and commits only if the name is still
185
+ * free (`createOnly`); something that appears meanwhile is never replaced;
186
+ * - a file there → refused before sending anything, unless `overwrite` (replace
187
+ * the version the stat found) or `ifTag` (replace exactly that version) was
188
+ * given; either way the commit replaces only that version (`replaceTag`), and
189
+ * a file changed during the copy is refused `file_changed`, nothing replaced.
160
190
  */
161
- async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope = {}, io = {}) {
162
- const intoDirectory = (directory) => intoRemoteDirectory(directory, (0, node_path_1.basename)(localPath));
163
- const attempt = async (target) => {
164
- try {
165
- return await uploadFile(localPath, leaseId, target, scope, io);
191
+ async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope, io) {
192
+ const { statPath, signal, generation } = { ...defaultIo, ...io };
193
+ throwIfCancelled(signal);
194
+ const info = await localSourceInfo(localPath, { ...defaultIo, ...io }.maxBytes);
195
+ const shown = (target) => `${leaseId}:${target}`;
196
+ let target = namesRemoteDirectory(remotePath)
197
+ ? intoRemoteDirectory(remotePath, (0, node_path_1.basename)(localPath))
198
+ : remotePath;
199
+ let destination = await statDestination(statPath, leaseId, target, generation, scope, signal);
200
+ if (destination.kind === 'directory' && target === remotePath) {
201
+ // An existing directory named without the slash: into it, once.
202
+ target = intoRemoteDirectory(remotePath, (0, node_path_1.basename)(localPath));
203
+ destination = await statDestination(statPath, leaseId, target, generation, scope, signal);
204
+ }
205
+ if (destination.kind === 'directory') {
206
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_PATH_IS_DIRECTORY', `${shown(target)} is a directory on the machine; nothing was sent.`);
207
+ }
208
+ if (destination.kind === 'other') {
209
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_PATH_NOT_A_FILE', `${shown(target)} is a ${destination.type} on the machine, not a regular file; cp does not replace it. Nothing was sent.`);
210
+ }
211
+ let condition;
212
+ let replacedTag;
213
+ if (destination.kind === 'absent') {
214
+ if (io.ifTag) {
215
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shown(target)} is no longer there, so the version --if-tag ${(0, computer_files_1.computerFileTagString)(io.ifTag)} names cannot be replaced. Nothing was sent; copy again without --if-tag to create it.`);
166
216
  }
167
- catch (err) {
168
- throw err instanceof DestinationIsDirectory ? err.refusal : err;
217
+ condition = { createOnly: true };
218
+ }
219
+ else if (io.ifTag) {
220
+ if (!sameTag(io.ifTag, destination.tag)) {
221
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shown(target)} changed since --if-tag ${(0, computer_files_1.computerFileTagString)(io.ifTag)} was read; its current tag is ${(0, computer_files_1.computerFileTagString)(destination.tag)}. Nothing was sent or replaced.`);
169
222
  }
170
- };
171
- if (namesRemoteDirectory(remotePath)) {
172
- return attempt(intoDirectory(remotePath));
223
+ replacedTag = destination.tag;
224
+ condition = { replaceTag: destination.tag };
173
225
  }
174
- try {
175
- return await uploadFile(localPath, leaseId, remotePath, scope, io);
226
+ else if (io.overwrite) {
227
+ replacedTag = destination.tag;
228
+ condition = { replaceTag: destination.tag };
176
229
  }
177
- catch (err) {
178
- if (err instanceof DestinationIsDirectory)
179
- return attempt(intoDirectory(remotePath));
180
- throw err;
230
+ else {
231
+ throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_EXISTS', `${shown(target)} already exists (tag ${(0, computer_files_1.computerFileTagString)(destination.tag)}). Nothing was sent. Pass --overwrite to replace it, or --if-tag ${(0, computer_files_1.computerFileTagString)(destination.tag)} to replace exactly this version.`);
181
232
  }
233
+ if (replacedTag)
234
+ io.onReplace?.(shown(target), replacedTag);
235
+ const result = await uploadFile(localPath, info, leaseId, target, scope, io, condition);
236
+ return {
237
+ ...result,
238
+ created: replacedTag === undefined,
239
+ ...(replacedTag ? { replacedTag: (0, computer_files_1.computerFileTagString)(replacedTag) } : {}),
240
+ };
182
241
  }
183
- async function uploadFile(localPath, leaseId, remotePath, scope, io) {
184
- const { writeChunk, chunkBytes, maxBytes, signal, onProgress } = { ...defaultIo, ...io };
185
- throwIfCancelled(signal);
242
+ /** The local source of an upload: a regular file within the size limit. */
243
+ async function localSourceInfo(localPath, maxBytes) {
186
244
  const info = await (0, promises_1.stat)(localPath).catch((err) => {
187
245
  // The raw `ENOENT: no such file or directory, stat './x'` said the same
188
246
  // thing in the platform's words rather than ours.
@@ -197,6 +255,34 @@ async function uploadFile(localPath, leaseId, remotePath, scope, io) {
197
255
  if (info.size > maxBytes) {
198
256
  throw new DedicatedCopyError('DEDICATED_RUNTIME_FILE_TOO_LARGE', `${localPath} is ${mib(info.size)}; copy is limited to ${mib(maxBytes)}. Push it through Git or object storage instead.`);
199
257
  }
258
+ return info;
259
+ }
260
+ /**
261
+ * The commit's own refusal of the condition the upload carried, in the copy's
262
+ * words: the destination changed (or appeared) DURING the copy and nothing was
263
+ * replaced. Anything else passes through unchanged.
264
+ */
265
+ function commitConditionRefusal(err, shownTarget) {
266
+ const refusal = (0, computer_files_1.computerFileRefusalInfo)(err);
267
+ if (!refusal)
268
+ return err;
269
+ if (refusal.code === 'file_changed') {
270
+ let now = '';
271
+ if (refusal.currentTag !== undefined) {
272
+ now = ` Its current tag is ${(0, computer_files_1.computerFileTagString)(refusal.currentTag)}.`;
273
+ }
274
+ else if (refusal.body.currentTag === null) {
275
+ now = ' It is no longer there.';
276
+ }
277
+ return new DedicatedCopyError('DEDICATED_RUNTIME_FILE_CHANGED', `${shownTarget} changed on the machine during the copy, so nothing was replaced.${now} Look at it with \`stat\`, then copy again.`);
278
+ }
279
+ if (refusal.code === 'invalid_request' && refusal.details?.reason === 'exists') {
280
+ return new DedicatedCopyError('DEDICATED_RUNTIME_FILE_EXISTS', `${shownTarget} appeared on the machine during the copy, so nothing was replaced. Copy again with --overwrite if you mean to replace it.`);
281
+ }
282
+ return err;
283
+ }
284
+ async function uploadFile(localPath, info, leaseId, remotePath, scope, io, condition) {
285
+ const { writeChunk, chunkBytes, signal, onProgress, generation } = { ...defaultIo, ...io };
200
286
  const transferId = `cp-${Date.now().toString(36)}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
201
287
  const handle = await (0, promises_1.open)(localPath, 'r');
202
288
  let offset = 0;
@@ -217,36 +303,45 @@ async function uploadFile(localPath, leaseId, remotePath, scope, io) {
217
303
  path: remotePath,
218
304
  transferId,
219
305
  offset,
306
+ generation,
220
307
  dataBase64: buffer.subarray(0, bytesRead).toString('base64'),
308
+ // On every chunk, so a machine whose image cannot honour the
309
+ // condition refuses the FIRST chunk, before anything is staged.
310
+ ...condition,
221
311
  // A file that GREW is caught here: the machine refuses to commit
222
312
  // when what it staged is not the size declared at the start.
223
- ...(final ? { final: true, totalSize: info.size, executable: (info.mode & 0o111) !== 0 } : {}),
313
+ ...(final
314
+ ? { final: true, totalSize: info.size, executable: (info.mode & 0o111) !== 0 }
315
+ : {}),
224
316
  }, scope), signal);
225
317
  offset += bytesRead;
226
318
  onProgress?.(offset, info.size);
227
- if (final) {
228
- return { bytes: offset, destination: `${leaseId}:${result.path || remotePath}` };
319
+ // A receipt (`alreadyCommitted`) is the same success: this transfer
320
+ // committed earlier and only its acknowledgement was lost.
321
+ if (final || result.alreadyCommitted === true) {
322
+ return {
323
+ bytes: info.size,
324
+ destination: `${leaseId}:${result.path || remotePath}`,
325
+ ...(result.tag ? { tag: (0, computer_files_1.computerFileTagString)(result.tag) } : {}),
326
+ ...(result.alreadyCommitted === true ? { alreadyCommitted: true } : {}),
327
+ };
229
328
  }
230
329
  }
231
330
  }
232
331
  catch (err) {
233
- if (offset === 0 && err?.code === PATH_IS_DIRECTORY) {
234
- // Refused before the first chunk was staged: there is nothing to abort.
235
- throw new DestinationIsDirectory(err);
236
- }
237
332
  // Best effort: remove the staged partial so it does not sit beside the
238
333
  // destination. The destination itself was never touched. An abort that
239
334
  // never arrives — the process killed, the network gone — is covered by the
240
335
  // machine, which removes a stale staging file on the next upload into the
241
336
  // same directory (daemon/src/tools/file-transfer.ts).
242
- await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true }, scope).catch(() => undefined);
243
- throw err;
337
+ await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true, generation }, scope).catch(() => undefined);
338
+ throw commitConditionRefusal(err, `${leaseId}:${remotePath}`);
244
339
  }
245
340
  finally {
246
341
  await handle.close();
247
342
  }
248
343
  }
249
- async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope = {}, io = {}) {
344
+ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope, io) {
250
345
  const { readChunk, chunkBytes, onProcessExit, signal, onProgress } = { ...defaultIo, ...io };
251
346
  throwIfCancelled(signal);
252
347
  let target = localPath;
@@ -282,14 +377,16 @@ async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scop
282
377
  throwIfCancelled(signal);
283
378
  // Nothing on the machine needs undoing, so an in-flight read is abandoned
284
379
  // at once rather than waited out.
285
- const chunk = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => readChunk(leaseId, { path: remotePath, offset, length: chunkBytes }, scope, signal), signal);
380
+ const chunk = await (0, dedicated_file_retry_1.withFileAdmissionRetry)(() => readChunk(leaseId, { path: remotePath, offset, length: chunkBytes, generation: io.generation }, scope, signal), signal);
286
381
  if (size === -1) {
287
382
  size = chunk.size;
288
383
  mtimeMs = chunk.mtimeMs;
289
384
  executable = chunk.executable;
290
385
  }
291
- else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs ||
292
- (typeof chunk.executable === 'boolean' && typeof executable === 'boolean' &&
386
+ else if (chunk.size !== size ||
387
+ chunk.mtimeMs !== mtimeMs ||
388
+ (typeof chunk.executable === 'boolean' &&
389
+ typeof executable === 'boolean' &&
293
390
  chunk.executable !== executable)) {
294
391
  // Chunks from two versions of a file splice into one that never existed.
295
392
  throw changedWhileCopying(remotePath, 'on the machine');
@@ -285,6 +285,7 @@ export declare function dedicatedRuntimeCreateInput(input: {
285
285
  * billable request is sent.
286
286
  */
287
287
  export declare function isDedicatedRepositoryRef(value: string): boolean;
288
+ export declare function withDedicatedScope(path: string, scope: DedicatedRuntimeScope, query?: Record<string, string>): string;
288
289
  export declare function createDedicatedRuntime(input: CreateDedicatedRuntimeInput): Promise<DedicatedRuntimeResponse>;
289
290
  /** One offered tier as the operator price book checks it. */
290
291
  export interface DedicatedPriceBookPresetCheck {
@@ -357,6 +358,34 @@ export declare function restoreDeletedDedicatedRuntime(leaseId: string, options?
357
358
  * on the next reconciler pass.
358
359
  */
359
360
  export declare function setDedicatedRuntimeSpendingLimit(leaseId: string, spendingLimitCents: number | null, scope?: DedicatedRuntimeScope): Promise<DedicatedRuntimeResponse>;
361
+ /**
362
+ * Whether the agents working on a Dedicated Runtime get a browser (OSK-13809).
363
+ *
364
+ * `state` is the machine's setting; `offered` is whether its CONNECTED daemon
365
+ * offers the browser right now (null: nothing connected, or unreadable). The
366
+ * two differ after a change until the daemon reconnects, and on an image
367
+ * without Chrome, which never offers it.
368
+ */
369
+ export type DedicatedAgentBrowserState = {
370
+ leaseId: string;
371
+ state: 'on' | 'off' | 'pending_first_start';
372
+ enabled: boolean;
373
+ offered: boolean | null;
374
+ capability: string;
375
+ changed?: boolean;
376
+ effectiveOnNextReconnect?: boolean;
377
+ };
378
+ export declare function getDedicatedRuntimeAgentBrowser(leaseId: string, scope?: DedicatedRuntimeScope): Promise<DedicatedAgentBrowserState>;
379
+ /** Turn the agents' browser on or off. An actual change is in the machine's audit record. */
380
+ export declare function setDedicatedRuntimeAgentBrowser(leaseId: string, enabled: boolean, scope?: DedicatedRuntimeScope): Promise<DedicatedAgentBrowserState>;
381
+ /**
382
+ * One line for a person. Says the bad cases out loud: a setting that is on
383
+ * while the connected daemon does not offer it yet, and a setting we could
384
+ * not read at all.
385
+ */
386
+ export declare function describeDedicatedAgentBrowser(value: DedicatedAgentBrowserState | {
387
+ error: string;
388
+ } | null | undefined, leaseId: string): string;
360
389
  export declare function closeDedicatedRuntimeIncidentHold(leaseId: string, options?: {
361
390
  requestId?: string;
362
391
  } & DedicatedRuntimeScope): Promise<DedicatedRuntimeResponse>;
@@ -478,21 +507,69 @@ export interface DedicatedFileWriteResult {
478
507
  staged: number;
479
508
  committed: boolean;
480
509
  aborted?: boolean;
510
+ /** On commit: the committed file's entity tag. */
511
+ tag?: {
512
+ inode: string;
513
+ size: number;
514
+ mtimeNs: string;
515
+ };
516
+ /**
517
+ * This transfer had ALREADY committed (review CW-02): the answer is its
518
+ * receipt, and nothing was written again — a retried final chunk whose first
519
+ * acknowledgement was lost. A success, never a reason to upload again.
520
+ */
521
+ alreadyCommitted?: boolean;
481
522
  }
523
+ /**
524
+ * `lease:<leaseId>` is the machine id the computer file routes address a
525
+ * Dedicated Runtime by (`/api/machines/:machineId/files/*`, authorised by
526
+ * ComputerAccess), the same id the machine inventory uses.
527
+ */
528
+ export declare const computerMachineId: (leaseId: string) => string;
529
+ /**
530
+ * The computer's page for a Dedicated Runtime — `/machines/<machineId>/computer`
531
+ * (the route `paneRouteConfig` serves the computer shell at, design §11).
532
+ * Composed on the `machine` web route rather than concatenated against baseURL,
533
+ * so the `api.`-subdomain → apex origin mapping stays the one in web-url.ts.
534
+ */
535
+ export declare function dedicatedComputerUrl(baseURL: string, leaseId: string): string | null;
536
+ /**
537
+ * One chunk of a download. `generation` is the lease generation the `cp`
538
+ * command resolved at its start, presented on every chunk (review C-01): a
539
+ * machine replaced mid-copy refuses the rest `stale_generation`.
540
+ */
482
541
  export declare function readDedicatedRuntimeFileChunk(leaseId: string, input: {
483
542
  path: string;
484
543
  offset: number;
485
544
  length: number;
545
+ generation: number;
486
546
  }, scope?: DedicatedRuntimeScope, signal?: AbortSignal): Promise<DedicatedFileChunk>;
547
+ /**
548
+ * One call of a chunked upload. `generation` fences every chunk to the machine
549
+ * the command resolved (review C-01). How the commit treats a destination is
550
+ * the caller's explicit choice (review CW-01), never an unconditional replace:
551
+ * `createOnly` commits only if nothing has the name (else 409
552
+ * `invalid_request`/`exists`); `replaceTag` commits only over the file with
553
+ * exactly that tag (else 409 `file_changed` with `currentTag`, null when it is
554
+ * gone). Never both. Each needs `computer_files_fenced_v1` on the machine.
555
+ */
487
556
  export declare function writeDedicatedRuntimeFileChunk(leaseId: string, input: {
488
557
  path: string;
489
558
  transferId: string;
490
559
  offset: number;
560
+ generation: number;
491
561
  dataBase64?: string;
492
562
  final?: boolean;
493
563
  abort?: boolean;
564
+ query?: boolean;
494
565
  totalSize?: number;
495
566
  executable?: boolean;
567
+ createOnly?: boolean;
568
+ replaceTag?: {
569
+ inode: string;
570
+ size: number;
571
+ mtimeNs: string;
572
+ };
496
573
  }, scope?: DedicatedRuntimeScope): Promise<DedicatedFileWriteResult>;
497
574
  export type DedicatedCopyPlan = {
498
575
  direction: 'upload';
@@ -578,6 +655,9 @@ export declare function dedicatedSpendSummary(lease: DedicatedLease): string;
578
655
  export declare function describeDedicatedDeletion(deletion: DedicatedLeaseDeletion, leaseId?: string): string;
579
656
  export declare function describeDedicatedLease(lease: DedicatedLease, harnesses?: readonly Harness[], opts?: {
580
657
  latestDaemonRelease?: LatestDaemonRelease | null;
658
+ agentBrowser?: DedicatedAgentBrowserState | {
659
+ error: string;
660
+ } | null;
581
661
  }): string[];
582
662
  /** "2026-09-30 14:00 UTC (in 17 days)" — a deadline readable without converting it. */
583
663
  export declare function formatDedicatedDeadline(value: string | null | undefined, now?: Date): string;