@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.
|
|
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
|
|
314
|
-
|
|
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.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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.
|
|
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` :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7943
|
-
|
|
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,
|
package/dist/stim-server.mjs
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
32
|
-
"stim": "1.20.
|
|
31
|
+
"@stim-cli/core": "^1.20.2",
|
|
32
|
+
"stim": "1.20.2",
|
|
33
33
|
"ws": "^8.21.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|