@stim-cli/server 1.20.0 → 1.20.2

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 CHANGED
@@ -119,7 +119,9 @@ questions, including without a terminal. Without
119
119
  a terminal or `--yes`, setup refuses before installing anything unless every
120
120
  chosen capability already has a matching approval. A person on
121
121
  the worker still approves; an SSH-driven run is not offered. Build and
122
- device-host requests are separate grants. Build never includes read or control.
122
+ device-host requests are separate grants. A build grant carries no `read` or
123
+ `control` capability, but it is full access to this account in practice (see
124
+ [Build access](#build-access)).
123
125
  A rerun before expiry reports matching approved requests as already approved
124
126
  and never grants a second request. After expiry it refuses with exit 2 before
125
127
  any change. Requests themselves lapse after 15 minutes.
@@ -310,17 +312,31 @@ connection sees a new grant after it reconnects. Under Stim Host, an approved
310
312
  `hello` also carries the same `host` grants as `/health`, which `stim doctor`
311
313
  reports for remote Macs.
312
314
 
313
- Every method other than `hello` needs `read`. A device with only `build`
314
- gets `forbidden` for all of them.
315
+ Every method other than `hello` needs `read`, except the methods with their own
316
+ approval: `build.*` needs `build`, the `device-host.*` methods that run a hosted
317
+ session need `device-host`, and `server.update.*` needs the build or device-host
318
+ approval of the requesting Mac (see [Remote update](#remote-update)). A device
319
+ with only `build` gets `forbidden` for every other method.
315
320
 
316
321
  ## Build access
317
322
 
318
323
  `build` lets another Mac on the tailnet run its project's code on this Mac to
319
324
  build for it: config plugins, CocoaPods hooks, Xcode script phases and Gradle
320
- plugins run as the user `stim-server` runs as. It never comes with `read` or
321
- `control`, and `devices grant` never turns a paired device into a build
322
- client. Grant it only to Macs you trust with that. A connection from this Mac,
323
- over loopback, cannot get it: it has no tailnet node to bind the token to.
325
+ plugins run as the user `stim-server` runs as. The offload worker runs with that
326
+ user's real home directory and no sandbox, so a build grant is equivalent to
327
+ full access to the account `stim-server` runs as. In practice it includes
328
+ everything `read` and `control` allow: project code can edit the
329
+ server's `devices.json` (under `~/.stim/server` by default, which the worker's
330
+ real home directory still reaches) to grant itself either, read other build
331
+ clients' checkouts and blobs, and persist through LaunchAgents or shell startup
332
+ files. `devices grant` never turns a paired device into a build client, and the
333
+ protocol does not list `read` or `control` among a build client's capabilities,
334
+ but do not rely on that as a boundary. Grant it only to Macs you trust with
335
+ that. Revoking the grant stops future builds and kills the running job's
336
+ process group. It does not undo anything project code already did. A loopback
337
+ connection from this Mac cannot get it: it has no tailnet node to bind the
338
+ token to. A connection from this Mac to its own tailnet address or `*.ts.net`
339
+ name does carry the Mac's own tailnet identity and can request `build`.
324
340
  Because the server trusts `X-Forwarded-For` on loopback (see
325
341
  [Pairing](#pairing)), a process on this Mac can still claim a tailnet peer's
326
342
  address. Run `stim-server` as a user no one else can run processes as when
@@ -1122,8 +1138,8 @@ A Mac this one approved for builds (`--build`) or device hosting
1122
1138
  (`--device-host`) can update this Mac's `stim-server service` over its tailnet
1123
1139
  connection, so the remote Mac keeps up with the client without ssh. A
1124
1140
  server that offers it lists `server-update` in its `hello` features. A paired
1125
- phone, a `read` or `control` device and a connection from this Mac cannot. The
1126
- methods need that approval, not `read`:
1141
+ phone, a `read` or `control` device and a loopback connection from this Mac
1142
+ cannot. The methods need that approval, not `read`:
1127
1143
 
1128
1144
  - `server.update.status` returns `server` (`version` and `stimBuild`),
1129
1145
  `service` (the label of the LaunchAgent this server runs under, or `null` when
@@ -1136,7 +1152,9 @@ methods need that approval, not `read`:
1136
1152
  - `server.update.start` takes `{ "release": "<version>" }`, an exact version
1137
1153
  that `stim-server service update --release` installs, or
1138
1154
  `{ "packages": [{ "name", "size", "sha256" }] }`, at most 8 `.tgz` packages
1139
- of the client's own build. Packages need `server.acceptClientBuilds` set to
1155
+ of the client's own build. A `release` older than the version this server
1156
+ runs is refused with `bad-request`; run `stim-server service update --release`
1157
+ on this Mac to downgrade it. Packages need `server.acceptClientBuilds` set to
1140
1158
  `true` on this Mac (`stim settings set server.acceptClientBuilds true`); a
1141
1159
  release needs no setting. One update runs at a time.
1142
1160
  - `server.update.chunk` takes `{ id, name, offset, data }`, at most 32 KiB of
@@ -1173,7 +1191,9 @@ A client with `build` runs its iOS simulator builds and Android emulator
1173
1191
  debug builds and macOS SwiftPM Debug builds here with these methods.
1174
1192
  They need `build`, not `read`; a device without `build` gets `forbidden`.
1175
1193
  The server re-reads the build clients on every call, and revoking a client
1176
- closes its connections and cancels its builds.
1194
+ closes its connections and cancels its builds. Once those builds end, it
1195
+ deletes the client's directory under the worker root. A client that has not
1196
+ connected since stim-server started keeps its directory when revoked.
1177
1197
 
1178
1198
  - `build.offer` takes `repo` (the client's name for its repository: letters,
1179
1199
  digits, `.`, `_` and `-`, at most 80), an optional `lockfile` sha256, and an
@@ -1216,7 +1236,13 @@ closes its connections and cancels its builds.
1216
1236
  the sha256, then the next bytes of that blob, one blob after another. A frame
1217
1237
  for a blob that was not asked for, one that overruns its size, or bytes that
1218
1238
  do not match the digest close the connection with 4400. Blobs are kept per
1219
- client and shared by all its repositories.
1239
+ client and shared by all its repositories. `build.sync` fails with
1240
+ `build-busy` while the worker root's volume has less than 10 GiB free, and
1241
+ with `limit-exceeded` once the sizes in one manifest add up to more than
1242
+ 20 GiB. A blob whose size would leave that volume under 10 GiB free also
1243
+ closes the connection with 4400. When a client's build ends and no other
1244
+ build of that client runs, the server deletes the client's blobs that no
1245
+ repository checkout and no open connection's manifest uses.
1220
1246
  - `build.start` takes `repo`, `project` (the app directory in the repository),
1221
1247
  `platform` (`ios`, `android` or `macos`), `configuration`, `scheme`, `runtime` (a
1222
1248
  simulator runtime identifier, required for `ios`, null for `android`),
@@ -2,11 +2,11 @@ import { n as protocol_exports } from "./protocol-CjUChTWJ.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, createDebugLog, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, validRunId, workspaceRecordingsDir } from "@stim-cli/core/state";
5
+ import { DEVICE_HOST_LIMITS, HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, createDebugLog, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, hostedWorkerSettleMs, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, validRunId, workspaceRecordingsDir } from "@stim-cli/core/state";
6
6
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, futimesSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, statfsSync, watch, writeFileSync, writeSync } from "node:fs";
7
7
  import { availableParallelism, cpus, freemem, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
8
8
  import { parseArgs } from "node:util";
9
- import { copyFile, lstat, open, readFile, realpath } from "node:fs/promises";
9
+ import { copyFile, lstat, open, readFile, realpath, rm } from "node:fs/promises";
10
10
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
11
11
  import { compiledHelper, configDir, createMetroBridge, validMacosResourceDestination, withDirLock, workspaceStateDir } from "@stim-cli/core";
12
12
  import { DEFAULT_STUCK_MINUTES, OVERSIGHT_CATEGORIES, inQuietHours, oversee, oversightTitle } from "@stim-cli/core/oversight";
@@ -130,7 +130,7 @@ function validTimeZone(timeZone) {
130
130
  return false;
131
131
  }
132
132
  }
133
- const minuteOfDay$1 = (value) => Number.isInteger(value) && value >= 0 && value < 1440;
133
+ const minuteOfDay$1 = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value < 1440;
134
134
  /** Quiet hours as `push.register` takes them, or null when `value` is not valid. */
135
135
  function parseQuietHours(value) {
136
136
  if (!isJsonObject(value)) return null;
@@ -144,7 +144,7 @@ function parseQuietHours(value) {
144
144
  }
145
145
  /** Whether `value` is a stuck threshold `push.register` takes: whole minutes from 1 to 240. */
146
146
  function validStuckMinutes(value) {
147
- return Number.isInteger(value) && value >= 1 && value <= 240;
147
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 240;
148
148
  }
149
149
  function serverDir() {
150
150
  return join(configDir(), "server");
@@ -260,6 +260,10 @@ function lapsed(client, now) {
260
260
  function readBuildClients(now = Date.now()) {
261
261
  return readRecords(clientsFile("build")).filter((client) => !lapsed(client, now));
262
262
  }
263
+ /** Whether the build client registry parses, so an empty `readBuildClients` means no client rather than a read failure. */
264
+ function buildClientsReadable() {
265
+ return Array.isArray(readJsonObject(clientsFile("build"))?.devices);
266
+ }
263
267
  /** Device-host approvals live apart from read/control pairings and build clients. */
264
268
  function readDeviceHostClients(now = Date.now()) {
265
269
  return readRecords(clientsFile("device-host")).filter((client) => !lapsed(client, now));
@@ -871,6 +875,26 @@ function planServe(route, target, previous) {
871
875
  create: true
872
876
  };
873
877
  }
878
+ const parseVersion = (v) => /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.*)?$/.exec(v);
879
+ function compareVersions(a, b) {
880
+ const aa = parseVersion(a), bb = parseVersion(b);
881
+ if (!aa || !bb) throw new ServiceError(`Cannot compare stim-server versions ${a} and ${b}.`);
882
+ for (let i = 1; i <= 3; i++) {
883
+ const diff = Number(aa[i]) - Number(bb[i]);
884
+ if (diff) return diff;
885
+ }
886
+ if (!aa[4] || !bb[4]) return aa[4] ? -1 : bb[4] ? 1 : 0;
887
+ const ap = aa[4].split("."), bp = bb[4].split(".");
888
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
889
+ const x = ap[i], y = bp[i];
890
+ if (x === y) continue;
891
+ if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
892
+ if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) - Number(y);
893
+ if (/^\d+$/.test(x) !== /^\d+$/.test(y)) return /^\d+$/.test(x) ? -1 : 1;
894
+ return x < y ? -1 : 1;
895
+ }
896
+ return 0;
897
+ }
874
898
  const RELEASE_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
875
899
  function validateRelease(value) {
876
900
  return RELEASE_PATTERN.test(value) ? null : `--release takes an exact stim-server version such as 1.15.0, not a range or tag; got "${value}".`;
@@ -2532,6 +2556,7 @@ function listProcesses(columns = "pid=,command=") {
2532
2556
  const DEFAULT_BUILD_LIMITS = {
2533
2557
  maxJobs: 1,
2534
2558
  minFreeBytes: 10 * 1024 ** 3,
2559
+ maxManifestBytes: 20 * 1024 ** 3,
2535
2560
  timeoutMs: 36e5,
2536
2561
  killGraceMs: 5e3,
2537
2562
  detachGraceMs: 3e5,
@@ -2637,6 +2662,8 @@ var BuildHost = class {
2637
2662
  limits;
2638
2663
  jobs = /* @__PURE__ */ new Set();
2639
2664
  owned = /* @__PURE__ */ new Map();
2665
+ sessions = /* @__PURE__ */ new Map();
2666
+ served = /* @__PURE__ */ new Set();
2640
2667
  retained = /* @__PURE__ */ new Map();
2641
2668
  closed = false;
2642
2669
  draining = null;
@@ -2775,12 +2802,17 @@ var BuildHost = class {
2775
2802
  drain(reason) {
2776
2803
  this.draining = reason;
2777
2804
  }
2805
+ /** Why the worker root's volume cannot take `bytes` more, or null when it can. */
2806
+ lowDisk(bytes = 0) {
2807
+ const free = freeBytes(this.root());
2808
+ return free !== null && free - bytes < this.limits.minFreeBytes ? `${gb(free)} GB free, builds need ${gb(this.limits.minFreeBytes)} GB` : null;
2809
+ }
2778
2810
  capacity() {
2779
2811
  const machine = machineCapacity();
2780
2812
  const running = this.jobs.size;
2781
2813
  const diskFreeBytes = freeBytes(this.root());
2782
2814
  const builds = machine.builds + [...this.jobs].filter((job) => !job.slotted).length;
2783
- const declined = this.draining ?? (running >= this.limits.maxJobs ? `already running ${running} offloaded build(s), its limit` : diskFreeBytes !== null && diskFreeBytes < this.limits.minFreeBytes ? `${gb(diskFreeBytes)} GB free, builds need ${gb(this.limits.minFreeBytes)} GB` : saturation({
2815
+ const declined = this.draining ?? (running >= this.limits.maxJobs ? `already running ${running} offloaded build(s), its limit` : this.lowDisk() ?? saturation({
2784
2816
  ...machine,
2785
2817
  builds
2786
2818
  }));
@@ -2798,7 +2830,57 @@ var BuildHost = class {
2798
2830
  };
2799
2831
  }
2800
2832
  session(client, socket, send) {
2801
- return new BuildSession(this, client, socket, send);
2833
+ const session = new BuildSession(this, client, socket, send);
2834
+ this.sessions.set(session, client);
2835
+ this.served.add(client);
2836
+ return session;
2837
+ }
2838
+ ended(session) {
2839
+ this.sessions.delete(session);
2840
+ }
2841
+ /**
2842
+ * Deletes the blobs of `client` that no `mirror.json` of its repositories and no open connection's manifest
2843
+ * references, unless a build of that client runs.
2844
+ */
2845
+ pruneBlobs(client) {
2846
+ if ([...this.jobs].some((job) => job.client === client)) return;
2847
+ const keep = /* @__PURE__ */ new Set();
2848
+ for (const [session, owner] of this.sessions) if (owner === client) for (const sha256 of session.digests()) keep.add(sha256);
2849
+ try {
2850
+ const repos = join(this.clientDir(client), "repos");
2851
+ const blobs = join(this.clientDir(client), "blobs");
2852
+ for (const repo of readdirSync(repos)) {
2853
+ const mirror = join(repos, repo, "mirror.json");
2854
+ if (!existsSync(mirror)) continue;
2855
+ const record = JSON.parse(readFileSync(mirror, "utf8"));
2856
+ if (typeof record !== "object" || record === null) throw new Error("unreadable mirror record");
2857
+ for (const entry of Object.values(record)) {
2858
+ if (typeof entry?.sha256 !== "string") throw new Error("unreadable mirror record");
2859
+ keep.add(entry.sha256);
2860
+ }
2861
+ }
2862
+ for (const prefix of readdirSync(blobs, { withFileTypes: true })) {
2863
+ if (!prefix.isDirectory() || !/^[0-9a-f]{2}$/.test(prefix.name)) continue;
2864
+ for (const blob of readdirSync(join(blobs, prefix.name), { withFileTypes: true })) if (blob.isFile() && /^[0-9a-f]{64}$/.test(blob.name) && !keep.has(blob.name)) rmSync(join(blobs, prefix.name, blob.name), { force: true });
2865
+ }
2866
+ } catch {}
2867
+ }
2868
+ /** Cancels the builds of `client`, a client that lost `build`, and deletes its area once they end. */
2869
+ async remove(client) {
2870
+ if (!client || client === "." || client === ".." || basename(client) !== client) return;
2871
+ for (const [token, entry] of this.retained) if (entry.client === client) this.drop(token);
2872
+ const jobs = [...this.jobs].filter((job) => job.client === client);
2873
+ for (const job of jobs) job.cancel();
2874
+ await Promise.all(jobs.map((job) => job.done));
2875
+ try {
2876
+ await rm(this.clientDir(client), {
2877
+ recursive: true,
2878
+ force: true,
2879
+ maxRetries: 3
2880
+ });
2881
+ } catch (error) {
2882
+ console.error(`stim-server: could not delete the build area of ${client}: ${String(error)}`);
2883
+ }
2802
2884
  }
2803
2885
  /** Starts the build of one synced manifest; the job belongs to the caller's connection. */
2804
2886
  launch({ client, repo, job, session, send }) {
@@ -3010,6 +3092,7 @@ var BuildHost = class {
3010
3092
  durationMs: Date.now() - started
3011
3093
  });
3012
3094
  resolve();
3095
+ this.pruneBlobs(client);
3013
3096
  };
3014
3097
  settle();
3015
3098
  });
@@ -3108,11 +3191,17 @@ var BuildHost = class {
3108
3191
  }
3109
3192
  /**
3110
3193
  * Cancels the jobs no connection holds, and deletes the retained bundles, of clients `allowed` no longer accepts,
3111
- * such as a revoked one.
3194
+ * such as a revoked one. With `deleteAreas`, the area of each such client this server process served is deleted
3195
+ * once its builds end.
3112
3196
  */
3113
- abandonDetached(allowed) {
3197
+ abandonDetached(allowed, deleteAreas = false) {
3114
3198
  for (const job of this.owned.values()) if (!job.session && !allowed(job.client)) this.abandon(job);
3115
3199
  for (const [token, entry] of this.retained) if (!allowed(entry.client)) this.drop(token);
3200
+ for (const client of this.served) {
3201
+ if (!deleteAreas || allowed(client)) continue;
3202
+ this.served.delete(client);
3203
+ this.remove(client);
3204
+ }
3116
3205
  }
3117
3206
  async close() {
3118
3207
  this.closed = true;
@@ -3155,6 +3244,7 @@ function workerOutcome(value, native) {
3155
3244
  var BuildSession = class {
3156
3245
  repo = null;
3157
3246
  files = /* @__PURE__ */ new Map();
3247
+ bytes = 0;
3158
3248
  done = false;
3159
3249
  expected = /* @__PURE__ */ new Map();
3160
3250
  incoming = null;
@@ -3179,15 +3269,20 @@ var BuildSession = class {
3179
3269
  sync(params) {
3180
3270
  if (!isJsonObject(params) || !validRepo(params.repo) || !Array.isArray(params.files)) return refusal("bad-request", "build.sync needs params.repo, params.files and params.done.");
3181
3271
  if (typeof params.done !== "boolean") return refusal("bad-request", "build.sync needs params.done.");
3272
+ const low = this.host.lowDisk();
3273
+ if (low) return refusal("build-busy", `This Mac declines the upload: ${low}.`);
3182
3274
  if (this.done || this.repo !== params.repo) {
3183
3275
  this.repo = params.repo;
3184
3276
  this.files = /* @__PURE__ */ new Map();
3277
+ this.bytes = 0;
3185
3278
  this.done = false;
3186
3279
  }
3187
3280
  if (this.files.size + params.files.length > MAX_MANIFEST_FILES) return refusal("limit-exceeded", `A manifest holds at most ${MAX_MANIFEST_FILES} files.`);
3188
3281
  const missing = [];
3189
3282
  for (const file of params.files) {
3190
3283
  if (!validFile(file)) return refusal("bad-request", `Invalid manifest entry ${JSON.stringify(file).slice(0, 200)}.`);
3284
+ if (this.bytes + file.size > this.host.limits.maxManifestBytes) return refusal("limit-exceeded", `A manifest holds at most ${gb(this.host.limits.maxManifestBytes)} GB.`);
3285
+ this.bytes += file.size;
3191
3286
  this.files.set(file.path, file);
3192
3287
  if (this.expected.has(file.sha256) || existsSync(this.blobPath(file.sha256))) continue;
3193
3288
  this.expected.set(file.sha256, file.size);
@@ -3196,6 +3291,9 @@ var BuildSession = class {
3196
3291
  this.done = params.done;
3197
3292
  return { result: { missing } };
3198
3293
  }
3294
+ digests() {
3295
+ return [...this.files.values()].map((file) => file.sha256);
3296
+ }
3199
3297
  /** One binary frame: a 32-byte sha256, then the next bytes of that blob. Returns why the frame is refused. */
3200
3298
  blob(frame) {
3201
3299
  if (frame.length < DIGEST_BYTES) return "a blob frame starts with its 32-byte sha256";
@@ -3205,6 +3303,8 @@ var BuildSession = class {
3205
3303
  if (!this.incoming) {
3206
3304
  const size = this.expected.get(sha256);
3207
3305
  if (size === void 0) return `blob ${sha256} was not asked for`;
3306
+ const low = this.host.lowDisk(size);
3307
+ if (low) return `this Mac declines the upload: ${low}`;
3208
3308
  const tmp = join(this.blobs(), `.incoming-${randomUUID()}`);
3209
3309
  mkdirSync(this.blobs(), {
3210
3310
  recursive: true,
@@ -3369,6 +3469,7 @@ var BuildSession = class {
3369
3469
  }
3370
3470
  close(dropped) {
3371
3471
  this.closed = true;
3472
+ this.host.ended(this);
3372
3473
  for (const job of this.jobs.values()) if (dropped) this.host.detach(job);
3373
3474
  else this.host.abandon(job);
3374
3475
  this.jobs.clear();
@@ -3590,26 +3691,6 @@ function selectGrants(records, match) {
3590
3691
  }] : [];
3591
3692
  });
3592
3693
  }
3593
- const parseVersion = (v) => /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.*)?$/.exec(v);
3594
- function compareVersions(a, b) {
3595
- const aa = parseVersion(a), bb = parseVersion(b);
3596
- if (!aa || !bb) throw new SetupRefusal(`Cannot compare stim-server versions ${a} and ${b}.`);
3597
- for (let i = 1; i <= 3; i++) {
3598
- const diff = Number(aa[i]) - Number(bb[i]);
3599
- if (diff) return diff;
3600
- }
3601
- if (!aa[4] || !bb[4]) return aa[4] ? -1 : bb[4] ? 1 : 0;
3602
- const ap = aa[4].split("."), bp = bb[4].split(".");
3603
- for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
3604
- const x = ap[i], y = bp[i];
3605
- if (x === y) continue;
3606
- if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
3607
- if (/^\d+$/.test(x) && /^\d+$/.test(y)) return Number(x) - Number(y);
3608
- if (/^\d+$/.test(x) !== /^\d+$/.test(y)) return /^\d+$/.test(x) ? -1 : 1;
3609
- return x < y ? -1 : 1;
3610
- }
3611
- return 0;
3612
- }
3613
3694
  function setupVersionDecision(current, desired, managed) {
3614
3695
  if (current === null) return "install";
3615
3696
  if (managed && compareVersions(current, desired) < 0) return "update";
@@ -6612,11 +6693,7 @@ var DeviceHost = class {
6612
6693
  this.options = options;
6613
6694
  this.debug = createDebugLog("server", { env: options.env });
6614
6695
  this.limits = {
6615
- prepareMs: 3e5,
6616
- offerMs: 3e4,
6617
- stopMs: 9e4,
6618
- logsMs: 15e3,
6619
- killGraceMs: 5e3,
6696
+ ...DEVICE_HOST_LIMITS,
6620
6697
  ...options.limits
6621
6698
  };
6622
6699
  }
@@ -7384,7 +7461,7 @@ var DeviceHost = class {
7384
7461
  const value = outcome.value;
7385
7462
  const installed = !owned.stopping && this.options.allowed(record.client) && !outcome.notice && outcome.settled && isJsonObject(value) && value.state === "installed" && parseHostedPlatformDevice(value.device, record.platform) !== null && record.device !== null && hostedDeviceId(parseHostedPlatformDevice(value.device, record.platform)) === hostedDeviceId(record.device) && (value.launched === true || value.launched === "unverified");
7386
7463
  if (installed) owned.installedAttempt = attempt;
7387
- if (installed && (record.platform !== "macos" || Number.isSafeInteger(value.pid) && value.pid > 0)) await this.startAgent(record, attempt, value.pid);
7464
+ if (installed && (record.platform !== "macos" || typeof value.pid === "number" && Number.isSafeInteger(value.pid) && value.pid > 0)) await this.startAgent(record, attempt, value.pid);
7388
7465
  const app = changeHostedApp(record.id, attempt, (current) => {
7389
7466
  current.state = installed ? "installed" : "unknown";
7390
7467
  current.launched = installed ? value.launched : null;
@@ -7834,7 +7911,7 @@ var DeviceHost = class {
7834
7911
  notice ??= "Hosted worker was cancelled or exceeded its deadline.";
7835
7912
  signal("SIGTERM");
7836
7913
  killTimer = setTimeout(() => signal("SIGKILL"), this.limits.killGraceMs);
7837
- finishTimer = setTimeout(finish, this.limits.killGraceMs * 2);
7914
+ finishTimer = setTimeout(finish, hostedWorkerSettleMs(this.limits));
7838
7915
  };
7839
7916
  const finishGroup = () => {
7840
7917
  if (finished) return;
@@ -7936,11 +8013,13 @@ function connect$1(url, timeoutMs) {
7936
8013
  } catch {
7937
8014
  return;
7938
8015
  }
7939
- const waiter = typeof message.id === "number" ? pending.get(message.id) : void 0;
8016
+ if (!isJsonObject(message) || typeof message.id !== "number") return;
8017
+ const waiter = pending.get(message.id);
7940
8018
  if (!waiter) return;
7941
8019
  pending.delete(message.id);
7942
- if (message.error) waiter.reject(new Error(message.error.message ?? "DevTools command failed."));
7943
- else waiter.resolve(message.result ?? {});
8020
+ const { error, result } = message;
8021
+ if (error) waiter.reject(new Error(isJsonObject(error) && typeof error.message === "string" ? error.message : "DevTools command failed."));
8022
+ else waiter.resolve(isJsonObject(result) ? result : {});
7944
8023
  });
7945
8024
  socket.on("close", () => {
7946
8025
  for (const waiter of pending.values()) waiter.reject(/* @__PURE__ */ new Error("The DevTools connection closed."));
@@ -9896,7 +9975,7 @@ function parseInput(method, params, targetOf) {
9896
9975
  code: "bad-request",
9897
9976
  message: "input.touch needs phase (down, move or up), and x and y from 0 to 1."
9898
9977
  };
9899
- if (display !== void 0 && (!Number.isInteger(display) || display < 0 || display > 3)) return {
9978
+ if (display !== void 0 && (typeof display !== "number" || !Number.isInteger(display) || display < 0 || display > 3)) return {
9900
9979
  code: "bad-request",
9901
9980
  message: "display must be a display index from 0 to 3."
9902
9981
  };
@@ -11328,7 +11407,7 @@ function parseLogFilter(params) {
11328
11407
  if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return { error: "slot must be 1-64 letters, digits, underscores or hyphens." };
11329
11408
  if (grep !== void 0 && (typeof grep !== "string" || grep.includes("\0") || compileGrep(grep).error)) return { error: "grep must be a valid regular expression without NUL characters." };
11330
11409
  if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
11331
- if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > protocol_exports.MAX_LOG_TAIL)) return { error: `tail must be an integer from 1 to ${protocol_exports.MAX_LOG_TAIL}.` };
11410
+ if (tail !== void 0 && (typeof tail !== "number" || !Number.isInteger(tail) || tail < 1 || tail > protocol_exports.MAX_LOG_TAIL)) return { error: `tail must be an integer from 1 to ${protocol_exports.MAX_LOG_TAIL}.` };
11332
11411
  return { filter: {
11333
11412
  ...typeof params.archive === "string" ? { archive: params.archive } : { workspace: params.workspace },
11334
11413
  ...sources ? { sources } : {},
@@ -12618,6 +12697,7 @@ var ServerUpdates = class {
12618
12697
  async start(by, params) {
12619
12698
  const parsed = parseUpdateStart(params);
12620
12699
  if (typeof parsed === "string") return refused("bad-request", parsed);
12700
+ if ("release" in parsed && compareVersions(parsed.release, this.options.build.version) < 0) return refused("bad-request", `This server runs ${this.options.build.version}; it does not install the older release ${parsed.release} on a client's request. To downgrade it, run \`stim-server service update --release ${parsed.release}\` on the Mac it runs on.`);
12621
12701
  const label = this.options.label;
12622
12702
  if (!label || !await (this.options.runsAsService ?? runsAsService)(label, this.options.port)) return refused("forbidden", "This stim-server does not run as a `stim-server service` LaunchAgent, so it cannot update itself. Update it where it runs.");
12623
12703
  if ("packages" in parsed && !this.options.acceptsClientBuilds()) return refused("forbidden", "This Mac installs only stim-server releases from npm. To take this build, run `stim settings set server.acceptClientBuilds true` on it.");
@@ -13783,7 +13863,7 @@ async function startServer(options) {
13783
13863
  push.refresh();
13784
13864
  for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
13785
13865
  hostedDevices.revoke();
13786
- builds.abandonDetached((client) => paired.get(client)?.capabilities.includes("build") ?? false);
13866
+ builds.abandonDetached((client) => paired.get(client)?.capabilities.includes("build") ?? false, buildClientsReadable());
13787
13867
  builds.sweepDaemons();
13788
13868
  for (const [socket, controller] of controllers) {
13789
13869
  const capabilities = paired.get(controller.device.id)?.capabilities;
@@ -14211,8 +14291,8 @@ async function startServer(options) {
14211
14291
  if (video !== void 0 && (!Array.isArray(video) || !video.every((codec) => typeof codec === "string"))) return error(id, "bad-request", "video must be a list of codec names.");
14212
14292
  const wantsVideo = video?.includes("h264") === true;
14213
14293
  const maxFps = wantsVideo ? protocol_exports.FRAME_FPS.video : protocol_exports.FRAME_FPS.max;
14214
- if (fps !== void 0 && (!Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
14215
- if (maxEdge !== void 0 && (!Number.isInteger(maxEdge) || maxEdge < protocol_exports.FRAME_EDGE.min || maxEdge > protocol_exports.FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${protocol_exports.FRAME_EDGE.min} to ${protocol_exports.FRAME_EDGE.max}.`);
14294
+ if (fps !== void 0 && (typeof fps !== "number" || !Number.isInteger(fps) || fps < 1 || fps > maxFps)) return error(id, "bad-request", `fps must be a whole number from 1 to ${maxFps}.`);
14295
+ if (maxEdge !== void 0 && (typeof maxEdge !== "number" || !Number.isInteger(maxEdge) || maxEdge < protocol_exports.FRAME_EDGE.min || maxEdge > protocol_exports.FRAME_EDGE.max)) return error(id, "bad-request", `maxEdge must be a whole number of pixels from ${protocol_exports.FRAME_EDGE.min} to ${protocol_exports.FRAME_EDGE.max}.`);
14216
14296
  const resolvedDir = hosted ? null : readDir(id, {
14217
14297
  workspace,
14218
14298
  archive
@@ -15224,7 +15304,7 @@ async function startServer(options) {
15224
15304
  if (message.method === "notifications.list") {
15225
15305
  const params = message.params ?? {};
15226
15306
  const since = isJsonObject(params) ? params.since : void 0;
15227
- if (!isJsonObject(params) || since !== void 0 && !(Number.isInteger(since) && since >= 0)) return error(id, "bad-request", "notifications.list takes an optional since, a cursor of 0 or more.");
15307
+ if (!isJsonObject(params) || since !== void 0 && !(typeof since === "number" && Number.isInteger(since) && since >= 0)) return error(id, "bad-request", "notifications.list takes an optional since, a cursor of 0 or more.");
15228
15308
  listeners.add(socket);
15229
15309
  return send(socket, {
15230
15310
  id,
@@ -4,6 +4,6 @@ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e
4
4
  if (encode(process.versions.node) < encode("22.12.0")) {
5
5
  process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
6
6
  process.exitCode = 1;
7
- } else import("./stim-server-C5iKxjba.mjs");
7
+ } else import("./stim-server-FWcuNpW7.mjs");
8
8
  //#endregion
9
9
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.20.0",
3
+ "version": "1.20.2",
4
4
  "description": "Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/core": "^1.20.0",
32
- "stim": "1.20.0",
31
+ "@stim-cli/core": "^1.20.2",
32
+ "stim": "1.20.2",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {