@moondeck/station 1.0.0 → 1.0.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/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +21 -0
- package/images/standard/Dockerfile +6 -2
- package/images/standard/container/uai-create-node-data +6 -0
- package/images/standard/container/uai-repair-node-data.py +93 -0
- package/lib/agent-cli.ts +26 -5
- package/lib/browser-testing.ts +5 -0
- package/lib/container-runtime.ts +28 -11
- package/lib/env-schema.ts +70 -24
- package/lib/first-turn-receipts.ts +62 -0
- package/lib/machine-provider-local.ts +2 -0
- package/lib/node-state.ts +17 -0
- package/lib/orchestrator.ts +18 -31
- package/lib/preview-ports.ts +3 -0
- package/lib/preview-upstream.ts +61 -0
- package/lib/runtime-archive.ts +2 -0
- package/lib/task-environment/machine-task-up.ts +27 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +25 -15
- package/src/bridge-diagnostics.ts +39 -0
- package/src/cli.ts +6 -4
- package/src/enrollment-next-steps.ts +24 -0
- package/src/index.ts +31 -13
- package/src/main.ts +46 -10
- package/src/preview-upstream.ts +41 -0
- package/src/protocol.ts +11 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- First-turn dedup: accepted first-turn delivery ids, so a redelivery after a
|
|
2
|
+
-- cloud crash is acknowledged without queueing a second opening turn.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS `host_first_turn_receipts` (
|
|
4
|
+
`task_id` text NOT NULL,
|
|
5
|
+
`first_turn_id` text NOT NULL,
|
|
6
|
+
`created_at` integer NOT NULL DEFAULT (unixepoch() * 1000),
|
|
7
|
+
PRIMARY KEY(`task_id`, `first_turn_id`),
|
|
8
|
+
CONSTRAINT `host_first_turn_receipts_identity` CHECK (length(`task_id`) > 0 AND length(`first_turn_id`) > 0)
|
|
9
|
+
);
|
|
@@ -218,6 +218,13 @@
|
|
|
218
218
|
"when": 1793318400000,
|
|
219
219
|
"tag": "0030_credential_cleanup_restops",
|
|
220
220
|
"breakpoints": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"idx": 31,
|
|
224
|
+
"version": "6",
|
|
225
|
+
"when": 1795910400000,
|
|
226
|
+
"tag": "0031_first_turn_receipts",
|
|
227
|
+
"breakpoints": true
|
|
221
228
|
}
|
|
222
229
|
]
|
|
223
230
|
}
|
package/db/schema.ts
CHANGED
|
@@ -848,3 +848,24 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
|
|
|
848
848
|
|
|
849
849
|
export type EngineAccountRow = typeof engineAccounts.$inferSelect;
|
|
850
850
|
export type NewEngineAccountRow = typeof engineAccounts.$inferInsert;
|
|
851
|
+
|
|
852
|
+
// First-turn dedup: ids of first-turn deliveries this station has accepted, so
|
|
853
|
+
// a cloud that crashed before recording its own ack can redeliver without a
|
|
854
|
+
// second opening turn. Written only after the turn was accepted.
|
|
855
|
+
export const hostFirstTurnReceipts = sqliteTable(
|
|
856
|
+
"host_first_turn_receipts",
|
|
857
|
+
{
|
|
858
|
+
taskId: text("task_id").notNull(),
|
|
859
|
+
firstTurnId: text("first_turn_id").notNull(),
|
|
860
|
+
createdAt: integer("created_at", { mode: "number" })
|
|
861
|
+
.notNull()
|
|
862
|
+
.default(sql`(unixepoch() * 1000)`),
|
|
863
|
+
},
|
|
864
|
+
(t) => ({
|
|
865
|
+
pk: primaryKey({ columns: [t.taskId, t.firstTurnId] }),
|
|
866
|
+
identity: check(
|
|
867
|
+
"host_first_turn_receipts_identity",
|
|
868
|
+
sql`length(${t.taskId}) > 0 AND length(${t.firstTurnId}) > 0`,
|
|
869
|
+
),
|
|
870
|
+
}),
|
|
871
|
+
);
|
|
@@ -51,6 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|
|
51
51
|
RUN apt-get update \
|
|
52
52
|
&& apt-get install -y --no-install-recommends \
|
|
53
53
|
build-essential \
|
|
54
|
+
avahi-utils \
|
|
54
55
|
ca-certificates \
|
|
55
56
|
curl \
|
|
56
57
|
direnv \
|
|
@@ -71,6 +72,7 @@ RUN apt-get update \
|
|
|
71
72
|
openssh-client \
|
|
72
73
|
pkg-config \
|
|
73
74
|
procps \
|
|
75
|
+
python3-minimal \
|
|
74
76
|
ripgrep \
|
|
75
77
|
tk-dev \
|
|
76
78
|
tmux \
|
|
@@ -89,7 +91,7 @@ RUN apt-get update \
|
|
|
89
91
|
# uai-playwright volume (downloaded once per host); these are its deps.
|
|
90
92
|
RUN apt-get update \
|
|
91
93
|
&& apt-get install -y --no-install-recommends \
|
|
92
|
-
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
94
|
+
libnss3 libnss3-tools libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
93
95
|
libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \
|
|
94
96
|
libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
|
95
97
|
fonts-liberation fonts-unifont \
|
|
@@ -119,7 +121,9 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
|
|
|
119
121
|
# ---------------------------------------------------------------------------
|
|
120
122
|
|
|
121
123
|
RUN groupadd --gid 1000 node \
|
|
122
|
-
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/node --shell /bin/bash node
|
|
124
|
+
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/node --shell /bin/bash node \
|
|
125
|
+
&& install -d -o node -g node -m 0755 /home/node/.local \
|
|
126
|
+
&& install -d -o node -g node -m 0700 /home/node/.local/state
|
|
123
127
|
|
|
124
128
|
# ---------------------------------------------------------------------------
|
|
125
129
|
# 3. asdf.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Run only as node. Only allowlisted legacy root-owned children can be repaired separately.
|
|
3
|
+
umask 077
|
|
4
|
+
for path in /home/node/.local /home/node/.local/share /home/node/.local/share/opencode /home/node/.local/state; do
|
|
5
|
+
/bin/mkdir -p -m 0700 -- "$path" || printf 'node data setup: could not create %s; checking existing directory metadata\n' "$path" >&2
|
|
6
|
+
done
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Root only: never create directories or follow a task-controlled symlink.
|
|
2
|
+
import errno
|
|
3
|
+
import os
|
|
4
|
+
import pwd
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
9
|
+
node = pwd.getpwnam("node")
|
|
10
|
+
|
|
11
|
+
def repair(parent, name, display, mode):
|
|
12
|
+
try:
|
|
13
|
+
fd = os.open(name, flags, dir_fd=parent)
|
|
14
|
+
except OSError as error:
|
|
15
|
+
if error.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT):
|
|
16
|
+
print("node data repair: skipped " + display + " (missing, symlink or not a directory)", file=sys.stderr)
|
|
17
|
+
return None
|
|
18
|
+
raise
|
|
19
|
+
try:
|
|
20
|
+
current = os.fstat(fd)
|
|
21
|
+
if current.st_uid != node.pw_uid:
|
|
22
|
+
owner = os.fstat(parent)
|
|
23
|
+
# Node already controls entries in this parent. Transfer only
|
|
24
|
+
# these legacy application directories, never their contents.
|
|
25
|
+
legacy = (
|
|
26
|
+
current.st_uid == 0
|
|
27
|
+
and owner.st_uid == node.pw_uid
|
|
28
|
+
and current.st_dev == owner.st_dev
|
|
29
|
+
and display in (
|
|
30
|
+
"/home/node/.local/state",
|
|
31
|
+
"/home/node/.local/share",
|
|
32
|
+
"/home/node/.local/share/opencode",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
if not legacy:
|
|
36
|
+
print("node data repair: skipped " + display + " (directory ownership or filesystem is not eligible; operator inspection required)", file=sys.stderr)
|
|
37
|
+
os.close(fd)
|
|
38
|
+
return None
|
|
39
|
+
os.fchown(fd, node.pw_uid, node.pw_gid)
|
|
40
|
+
os.fchmod(fd, mode)
|
|
41
|
+
except BaseException:
|
|
42
|
+
os.close(fd)
|
|
43
|
+
raise
|
|
44
|
+
return fd
|
|
45
|
+
|
|
46
|
+
fds = []
|
|
47
|
+
try:
|
|
48
|
+
root = os.open("/", flags)
|
|
49
|
+
fds.append(root)
|
|
50
|
+
# Pin the root-owned home chain too; no absolute-path traversal below it.
|
|
51
|
+
parent = root
|
|
52
|
+
for component in ("home", "node"):
|
|
53
|
+
try:
|
|
54
|
+
parent = os.open(component, flags, dir_fd=parent)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
if error.errno not in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT):
|
|
57
|
+
raise
|
|
58
|
+
print("node data repair: skipped home anchor (missing or redirected)", file=sys.stderr)
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
fds.append(parent)
|
|
61
|
+
local = repair(parent, ".local", "/home/node/.local", 0o755)
|
|
62
|
+
if local is not None:
|
|
63
|
+
fds.append(local)
|
|
64
|
+
share = repair(local, "share", "/home/node/.local/share", 0o755)
|
|
65
|
+
if share is not None:
|
|
66
|
+
fds.append(share)
|
|
67
|
+
opencode = repair(share, "opencode", "/home/node/.local/share/opencode", 0o755)
|
|
68
|
+
if opencode is not None:
|
|
69
|
+
fds.append(opencode)
|
|
70
|
+
# docker cp creates root-owned credential files. Repair only
|
|
71
|
+
# these regular files through the pinned directory, never -R.
|
|
72
|
+
for name in ("auth.json", "config.json"):
|
|
73
|
+
try:
|
|
74
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=opencode)
|
|
75
|
+
except OSError as error:
|
|
76
|
+
if error.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT, errno.ENXIO):
|
|
77
|
+
continue
|
|
78
|
+
raise
|
|
79
|
+
try:
|
|
80
|
+
current = os.fstat(fd)
|
|
81
|
+
if stat.S_ISREG(current.st_mode) and current.st_nlink == 1 and current.st_uid in (0, node.pw_uid):
|
|
82
|
+
os.fchown(fd, node.pw_uid, node.pw_gid)
|
|
83
|
+
os.fchmod(fd, 0o600)
|
|
84
|
+
else:
|
|
85
|
+
print("node data repair: skipped OpenCode " + name + " (not a single-link root/node regular file)", file=sys.stderr)
|
|
86
|
+
finally:
|
|
87
|
+
os.close(fd)
|
|
88
|
+
state = repair(local, "state", "/home/node/.local/state", 0o700)
|
|
89
|
+
if state is not None:
|
|
90
|
+
fds.append(state)
|
|
91
|
+
finally:
|
|
92
|
+
for fd in reversed(fds):
|
|
93
|
+
os.close(fd)
|
package/lib/agent-cli.ts
CHANGED
|
@@ -1272,12 +1272,30 @@ async function main() {
|
|
|
1272
1272
|
out(action + " #" + t.shortId + ": " + t.text);
|
|
1273
1273
|
break;
|
|
1274
1274
|
}
|
|
1275
|
-
case "preview add":
|
|
1275
|
+
case "preview add":
|
|
1276
|
+
case "preview update": {
|
|
1276
1277
|
const name = pos[0] || flags.name;
|
|
1277
1278
|
const port = Number(pos[1] || flags.port);
|
|
1278
|
-
if (!name || !Number.isInteger(port)) { console.error("moondeck: preview
|
|
1279
|
-
|
|
1280
|
-
|
|
1279
|
+
if (!name || !Number.isInteger(port) || port < 1 || port > 65535) { console.error("moondeck: preview " + action + " <name> <containerPort>"); process.exit(1); }
|
|
1280
|
+
if (Object.keys(flags).some(k => !["name", "port", "upstream-host", "https"].includes(k)) || (flags.https !== undefined && flags.https !== "true") || (flags["upstream-host"] !== undefined && !flagValue(flags["upstream-host"]))) {
|
|
1281
|
+
console.error("moondeck: preview " + action + " <name> <port> [--upstream-host <hostname>] [--https]"); process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
const upstream = {
|
|
1284
|
+
...(flags["upstream-host"] !== undefined ? { upstreamHost: flags["upstream-host"] } : {}),
|
|
1285
|
+
...(flags.https === "true" ? { upstreamTls: true } : {}),
|
|
1286
|
+
};
|
|
1287
|
+
const r = action === "add"
|
|
1288
|
+
? await api("POST", "/api/agent/previews", { name, containerPort: port, ...upstream })
|
|
1289
|
+
: await api("PATCH", "/api/agent/previews/" + encodeURIComponent(name), { containerPort: port, ...upstream });
|
|
1290
|
+
out("preview '" + r.name + "' on port " + r.containerPort + (action === "add" ? " — turn it on with: moondeck preview enable " + r.name : " — updated"));
|
|
1291
|
+
if (upstream.upstreamHost || upstream.upstreamTls) console.error("Portless users: outside self tasks, start with portless proxy start --lan (or PORTLESS_LAN=1; enables mDNS). Check for LAN mode disabled; if printed, use an updated Moondeck image with avahi-utils, or ask the operator to install it and fix any reported LAN-IP problem, then restart. Use its printed LAN hostname (usually mta.local) and proxy port: HTTPS 443, HTTP 80, or your explicit -p port (e.g. 1355).");
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
case "preview remove": {
|
|
1295
|
+
const name = pos[0] || flags.name;
|
|
1296
|
+
if (!name) { console.error("moondeck: preview remove <name>"); process.exit(1); }
|
|
1297
|
+
await api("DELETE", "/api/agent/previews/" + encodeURIComponent(name));
|
|
1298
|
+
out("removed preview '" + name + "'");
|
|
1281
1299
|
break;
|
|
1282
1300
|
}
|
|
1283
1301
|
case "preview enable":
|
|
@@ -1465,7 +1483,10 @@ async function main() {
|
|
|
1465
1483
|
" moondeck review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
|
|
1466
1484
|
" moondeck crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
|
|
1467
1485
|
" Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
|
|
1468
|
-
" moondeck preview add <name> <containerPort>",
|
|
1486
|
+
" moondeck preview add <name> <containerPort> [--upstream-host <h>] [--https]",
|
|
1487
|
+
" moondeck preview update <name> <containerPort> [--upstream-host <h>] [--https]",
|
|
1488
|
+
" moondeck preview remove <name>",
|
|
1489
|
+
" Portless outside self: portless proxy start --lan or PORTLESS_LAN=1 (mDNS); use printed *.local Host and proxy port 443/80, or explicit -p 1355. Check for LAN mode disabled; update the image/install avahi-utils and resolve the reported LAN issue, then restart.",
|
|
1469
1490
|
" moondeck preview enable|disable <name> (turn it on; prints its URL)",
|
|
1470
1491
|
" moondeck preview public|private <name> (public: a link anyone can open)",
|
|
1471
1492
|
" moondeck project list | show <id|slug>",
|
package/lib/browser-testing.ts
CHANGED
|
@@ -1472,6 +1472,9 @@ export async function setupBrowserTesting(
|
|
|
1472
1472
|
|
|
1473
1473
|
const rootWorkerEnv = {
|
|
1474
1474
|
...authorityEnv,
|
|
1475
|
+
// HOME remains node's for asdf resolution; root-run tools must keep
|
|
1476
|
+
// their XDG state out of the task user's home.
|
|
1477
|
+
XDG_STATE_HOME: "/root/.local/state",
|
|
1475
1478
|
NPM_CONFIG_CACHE: "/tmp/uai-root-npm-cache",
|
|
1476
1479
|
npm_config_cache: "/tmp/uai-root-npm-cache",
|
|
1477
1480
|
NPM_CONFIG_LOGS_DIR: "/tmp/uai-root-npm-cache/_logs",
|
|
@@ -1505,6 +1508,8 @@ export async function setupBrowserTesting(
|
|
|
1505
1508
|
// both cases aligned so npm's case-insensitive config lookup cannot
|
|
1506
1509
|
// fall back to node's private cache or logs directory.
|
|
1507
1510
|
"-e",
|
|
1511
|
+
"XDG_STATE_HOME=/root/.local/state",
|
|
1512
|
+
"-e",
|
|
1508
1513
|
"NPM_CONFIG_CACHE=/tmp/uai-root-npm-cache",
|
|
1509
1514
|
"-e",
|
|
1510
1515
|
"npm_config_cache=/tmp/uai-root-npm-cache",
|
package/lib/container-runtime.ts
CHANGED
|
@@ -1099,14 +1099,12 @@ async function detectPinnedRuntime(
|
|
|
1099
1099
|
// id; a wiped or replaced toolchain state must fail closed exactly like a
|
|
1100
1100
|
// replaced Docker daemon at the same socket.
|
|
1101
1101
|
const probe = await probeAppleSystem(deps);
|
|
1102
|
-
if (
|
|
1103
|
-
probe === null ||
|
|
1104
|
-
probe.endpoint !== host ||
|
|
1105
|
-
pinnedDockerEngineId === null ||
|
|
1106
|
-
probe.installationId !== pinnedDockerEngineId
|
|
1107
|
-
) {
|
|
1102
|
+
if (probe === null || pinnedDockerEngineId === null) {
|
|
1108
1103
|
return pinnedRuntimeUnavailable(preference);
|
|
1109
1104
|
}
|
|
1105
|
+
if (probe.endpoint !== host || probe.installationId !== pinnedDockerEngineId) {
|
|
1106
|
+
return pinnedRuntimeUnavailable(preference, "replaced");
|
|
1107
|
+
}
|
|
1110
1108
|
applyPinnedAppleRuntimeEnvironment(pinnedAppleCliPath);
|
|
1111
1109
|
return {
|
|
1112
1110
|
status: "ready",
|
|
@@ -1117,13 +1115,12 @@ async function detectPinnedRuntime(
|
|
|
1117
1115
|
|
|
1118
1116
|
applyPinnedDockerHost(host);
|
|
1119
1117
|
const proof = await probeDocker({ host }, deps);
|
|
1120
|
-
if (
|
|
1121
|
-
proof === null ||
|
|
1122
|
-
pinnedDockerEngineId === null ||
|
|
1123
|
-
proof.engineId !== pinnedDockerEngineId
|
|
1124
|
-
) {
|
|
1118
|
+
if (proof === null || pinnedDockerEngineId === null) {
|
|
1125
1119
|
return pinnedRuntimeUnavailable(preference);
|
|
1126
1120
|
}
|
|
1121
|
+
if (proof.engineId !== pinnedDockerEngineId) {
|
|
1122
|
+
return pinnedRuntimeUnavailable(preference, "replaced");
|
|
1123
|
+
}
|
|
1127
1124
|
return {
|
|
1128
1125
|
status: "ready",
|
|
1129
1126
|
preference: preference === "auto" ? "auto" : "docker",
|
|
@@ -1328,9 +1325,29 @@ function isLocalDockerEndpoint(host: string): boolean {
|
|
|
1328
1325
|
return host.slice("unix://".length).startsWith("/");
|
|
1329
1326
|
}
|
|
1330
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* The pinned runtime can't be used. "unresponsive": it didn't answer. "replaced":
|
|
1330
|
+
* it answers, but as a different engine (Docker's daemon id, the Apple
|
|
1331
|
+
* system's installation id or state root) than the one this process pinned
|
|
1332
|
+
* at start, e.g. Docker or OrbStack was reset. That one used to read "not
|
|
1333
|
+
* responding" too, while `docker info` answered fine (the MacBook Air
|
|
1334
|
+
* station, 2026-11-03); a recheck can't adopt a new engine, only a restart.
|
|
1335
|
+
*/
|
|
1331
1336
|
function pinnedRuntimeUnavailable(
|
|
1332
1337
|
preference: RuntimePreference,
|
|
1338
|
+
cause: "unresponsive" | "replaced" = "unresponsive",
|
|
1333
1339
|
): ContainerRuntimeState {
|
|
1340
|
+
if (cause === "replaced") {
|
|
1341
|
+
const what = pinnedRuntimeProvider === "apple-container" ? "Apple container runtime" : "Docker engine";
|
|
1342
|
+
return {
|
|
1343
|
+
status: "no-runtime",
|
|
1344
|
+
preference,
|
|
1345
|
+
message:
|
|
1346
|
+
`A different ${what} now answers than the one this Moondeck Station process started with ` +
|
|
1347
|
+
`(it was restarted, reset or replaced). Run \`${RUNTIME_RESTART_COMMAND}\` to use it.`,
|
|
1348
|
+
action: RUNTIME_RESTART_COMMAND,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1334
1351
|
return {
|
|
1335
1352
|
status: "no-runtime",
|
|
1336
1353
|
preference,
|
package/lib/env-schema.ts
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* first item belongs to the document header.
|
|
17
17
|
* - `@name` is `@name=true`. Value decorators are single-use; function calls
|
|
18
18
|
* (`@name(...)`) accumulate.
|
|
19
|
+
* - Calls and literals in decorators may continue on following `#` lines;
|
|
20
|
+
* quoted arguments stay on one line. Continuations are bounded and reject
|
|
21
|
+
* a new directive or block boundary before the structure closes.
|
|
19
22
|
*
|
|
20
23
|
* Fail closed (ADR-153): an @proxy Uai cannot fully interpret makes its key
|
|
21
24
|
* `unsupported`, so the key is withheld; it never reads as no directive, which
|
|
@@ -61,6 +64,11 @@ type Failure = KnownProjectEnvSchemaUnavailableReason;
|
|
|
61
64
|
|
|
62
65
|
/** A schema file larger than this is not read: no repository needs one. */
|
|
63
66
|
export const MAX_ENV_SCHEMA_BYTES = 256 * 1024;
|
|
67
|
+
/** Bounds for one continued decorator comment: physical lines, and UTF-8
|
|
68
|
+
* bytes from its initial @ through the end of its closing line, after
|
|
69
|
+
* normalizing CRLF to LF as upstream does. */
|
|
70
|
+
export const MAX_ENV_SCHEMA_DECORATOR_LINES = 128;
|
|
71
|
+
export const MAX_ENV_SCHEMA_DECORATOR_BYTES = 16 * 1024;
|
|
64
72
|
/** A minted placeholder fills at least this many characters at random. */
|
|
65
73
|
export const PLACEHOLDER_RANDOM_CHARACTERS = 32;
|
|
66
74
|
|
|
@@ -102,11 +110,13 @@ const MAX_NESTING = 16;
|
|
|
102
110
|
|
|
103
111
|
class SyntaxFailure extends Error {}
|
|
104
112
|
|
|
105
|
-
/**
|
|
113
|
+
/** A comment's decorators, or a value's call, list or object. Throws
|
|
106
114
|
* SyntaxFailure on anything it cannot read. */
|
|
107
115
|
class DecoratorReader {
|
|
108
116
|
private pos: number;
|
|
109
117
|
private depth = 0;
|
|
118
|
+
private readonly start: number;
|
|
119
|
+
private decoratorLines = 1;
|
|
110
120
|
/** A value's structure: inside it, line breaks and `#` comments are blanks. */
|
|
111
121
|
private readonly multiline: boolean;
|
|
112
122
|
|
|
@@ -115,18 +125,19 @@ class DecoratorReader {
|
|
|
115
125
|
options: { start?: number; value?: boolean } = {},
|
|
116
126
|
) {
|
|
117
127
|
this.pos = options.start ?? 0;
|
|
128
|
+
this.start = this.pos;
|
|
118
129
|
this.multiline = options.value === true;
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
/** Decorators up to a comment. */
|
|
122
|
-
readAll(into: Decorator[]):
|
|
133
|
+
readAll(into: Decorator[]): number {
|
|
123
134
|
for (;;) {
|
|
124
135
|
this.skipSpaces();
|
|
125
|
-
if (this.done() || this.peek() === "#") return;
|
|
136
|
+
if (this.done() || this.peek() === "#" || this.peek() === "\n" || this.atCrLf()) return this.pos;
|
|
126
137
|
if (this.peek() !== "@") {
|
|
127
138
|
// Stray text, as upstream reads it: skipped, and the decorators after
|
|
128
139
|
// it still count. It may not hold an `@` (upstream refuses that).
|
|
129
|
-
while (!this.done() && !isBlankChar(this.peek())) {
|
|
140
|
+
while (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "\n" && !this.atCrLf()) {
|
|
130
141
|
if (this.peek() === "@") throw new SyntaxFailure();
|
|
131
142
|
this.pos += 1;
|
|
132
143
|
}
|
|
@@ -148,7 +159,7 @@ class DecoratorReader {
|
|
|
148
159
|
decorator.source = this.text.slice(start, this.pos);
|
|
149
160
|
into.push(decorator);
|
|
150
161
|
// Decorators end at whitespace or a comment; anything glued on is junk.
|
|
151
|
-
if (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "#") throw new SyntaxFailure();
|
|
162
|
+
if (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "#" && this.peek() !== "\n" && !this.atCrLf()) throw new SyntaxFailure();
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
165
|
|
|
@@ -182,6 +193,31 @@ class DecoratorReader {
|
|
|
182
193
|
this.pos += char === "\n" ? 1 : 2;
|
|
183
194
|
} else if (this.multiline && this.depth > 0 && char === "#") {
|
|
184
195
|
while (!this.done() && this.peek() !== "\n" && !this.atCrLf()) this.pos += 1;
|
|
196
|
+
} else if (!this.multiline && this.depth > 0) {
|
|
197
|
+
// env-spec's _decWs (packages/env-spec-parser/grammar.peggy):
|
|
198
|
+
// line breaks require a fresh #, and an
|
|
199
|
+
// interior # comment must end with such a continuation. #tag is
|
|
200
|
+
// an argument, not a comment. Never strip hashes inside values.
|
|
201
|
+
if (char === "#" && !/[A-Za-z0-9]/.test(this.text[this.pos + 1] ?? "")) {
|
|
202
|
+
while (!this.done() && this.peek() !== "\n" && !this.atCrLf()) this.pos += 1;
|
|
203
|
+
}
|
|
204
|
+
if (this.peek() !== "\n" && !this.atCrLf()) return;
|
|
205
|
+
this.pos += this.peek() === "\n" ? 1 : 2;
|
|
206
|
+
// Deliberately stricter than upstream: no indented continuation
|
|
207
|
+
// comments, consistent with our standalone comment rule.
|
|
208
|
+
if (this.peek() !== "#") throw new SyntaxFailure();
|
|
209
|
+
this.pos += 1;
|
|
210
|
+
this.skipBlanks();
|
|
211
|
+
// Deliberately stricter than upstream: a later directive or
|
|
212
|
+
// divider must not become an argument of an unfinished structure.
|
|
213
|
+
// Scoped plugin names (@varlock/...) remain valid arguments.
|
|
214
|
+
const rest = this.text.slice(this.pos);
|
|
215
|
+
if ((/^@[A-Za-z]/.test(rest) && !/^@[A-Za-z0-9_-]+\//.test(rest)) || /^[-=*#]{3}/.test(rest)) throw new SyntaxFailure();
|
|
216
|
+
this.decoratorLines += 1;
|
|
217
|
+
const newline = this.text.indexOf("\n", this.pos);
|
|
218
|
+
const lineEnd = newline === -1 ? this.text.length : newline - (this.text[newline - 1] === "\r" ? 1 : 0);
|
|
219
|
+
if (this.decoratorLines > MAX_ENV_SCHEMA_DECORATOR_LINES ||
|
|
220
|
+
Buffer.byteLength(this.text.slice(this.start, lineEnd).replaceAll("\r\n", "\n"), "utf8") > MAX_ENV_SCHEMA_DECORATOR_BYTES) throw new SyntaxFailure();
|
|
185
221
|
} else {
|
|
186
222
|
return;
|
|
187
223
|
}
|
|
@@ -253,12 +289,11 @@ class DecoratorReader {
|
|
|
253
289
|
for (;;) {
|
|
254
290
|
if (this.done()) throw new SyntaxFailure();
|
|
255
291
|
const char = this.peek();
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
out += escaped === quote || escaped === "\\" ? escaped : `\\${escaped}`;
|
|
292
|
+
// Upstream escapes only the wrapping quote. A backslash before any
|
|
293
|
+
// other character is literal, including another backslash: the next
|
|
294
|
+
// backslash may itself escape a quote and keep this string open.
|
|
295
|
+
if (char === "\\" && this.text[this.pos + 1] === quote) {
|
|
296
|
+
out += quote;
|
|
262
297
|
this.pos += 2;
|
|
263
298
|
continue;
|
|
264
299
|
}
|
|
@@ -345,11 +380,14 @@ class DecoratorReader {
|
|
|
345
380
|
}
|
|
346
381
|
|
|
347
382
|
/** A decorator comment's decorators, or null if any of it is unreadable. */
|
|
348
|
-
function readDecorators(content: string): Decorator[] | null {
|
|
383
|
+
function readDecorators(source: Source, index: number, content: string): { decorators: Decorator[]; lastLine: number } | null {
|
|
349
384
|
const decorators: Decorator[] = [];
|
|
350
385
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
386
|
+
const start = source.lineStarts[index]! + source.lines[index]!.length - content.length;
|
|
387
|
+
const end = new DecoratorReader(source.text, { start }).readAll(decorators);
|
|
388
|
+
let lastLine = index;
|
|
389
|
+
while (lastLine + 1 < source.lines.length && source.lineStarts[lastLine + 1]! < end) lastLine += 1;
|
|
390
|
+
return { decorators, lastLine };
|
|
353
391
|
} catch (error) {
|
|
354
392
|
if (error instanceof SyntaxFailure) return null;
|
|
355
393
|
throw error;
|
|
@@ -451,8 +489,8 @@ function endOfValue(
|
|
|
451
489
|
let i = 0;
|
|
452
490
|
while (i < text.length) {
|
|
453
491
|
// An escaped delimiter (\" in "...", \""" in """...""") does not close.
|
|
454
|
-
if (text[i] === "\\" &&
|
|
455
|
-
i += 1 +
|
|
492
|
+
if (text[i] === "\\" && text.startsWith(fence, i + 1)) {
|
|
493
|
+
i += 1 + fence.length;
|
|
456
494
|
continue;
|
|
457
495
|
}
|
|
458
496
|
if (lineIndex > index || fence.length === 1) {
|
|
@@ -507,13 +545,13 @@ function read(text: string): Reading | Failure {
|
|
|
507
545
|
if (block.decorators.length > 0) detached.push(block);
|
|
508
546
|
block = emptyBlock();
|
|
509
547
|
};
|
|
510
|
-
/**
|
|
548
|
+
/** Null when the decorators cannot be read: upstream refuses the file,
|
|
511
549
|
* and what they say about routing is unknown. */
|
|
512
|
-
const addDecorators = (into: Block, content: string):
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
into.decorators.push(...decorators);
|
|
516
|
-
return
|
|
550
|
+
const addDecorators = (into: Block, index: number, content: string): number | null => {
|
|
551
|
+
const result = readDecorators(source, index, content);
|
|
552
|
+
if (result === null) return null;
|
|
553
|
+
into.decorators.push(...result.decorators);
|
|
554
|
+
return result.lastLine;
|
|
517
555
|
};
|
|
518
556
|
|
|
519
557
|
for (let index = 0; index < lines.length; index += 1) {
|
|
@@ -526,7 +564,11 @@ function read(text: string): Reading | Failure {
|
|
|
526
564
|
const content = raw.slice(1).replace(/^[ \t]+/, "");
|
|
527
565
|
// A divider, as upstream has it: three of - = * #, in any mix.
|
|
528
566
|
if (/^[-=*#]{3}/.test(content)) endBlock();
|
|
529
|
-
else if (content.startsWith("@")
|
|
567
|
+
else if (content.startsWith("@")) {
|
|
568
|
+
const lastLine = addDecorators(block, index, content);
|
|
569
|
+
if (lastLine === null) return "schema_malformed";
|
|
570
|
+
index = lastLine;
|
|
571
|
+
}
|
|
530
572
|
continue;
|
|
531
573
|
}
|
|
532
574
|
// Only an item may be indented, and only with spaces and tabs: upstream
|
|
@@ -549,7 +591,11 @@ function read(text: string): Reading | Failure {
|
|
|
549
591
|
const comment = end.comment.replace(/^[ \t]+/, "");
|
|
550
592
|
// Upstream refuses a divider after a value.
|
|
551
593
|
if (/^[-=*#]{3}/.test(comment)) return "schema_malformed";
|
|
552
|
-
if (comment.startsWith("@")
|
|
594
|
+
if (comment.startsWith("@")) {
|
|
595
|
+
const lastLine = addDecorators(attached, index, comment);
|
|
596
|
+
if (lastLine === null) return "schema_malformed";
|
|
597
|
+
index = lastLine;
|
|
598
|
+
}
|
|
553
599
|
}
|
|
554
600
|
items.push({ key: item[1]!, block: attached });
|
|
555
601
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import { getDb, schema } from "./db";
|
|
4
|
+
|
|
5
|
+
/** Has this station already accepted the first-turn delivery `firstTurnId`? */
|
|
6
|
+
export function hasFirstTurnReceipt(taskId: string, firstTurnId: string): boolean {
|
|
7
|
+
return (
|
|
8
|
+
getDb()
|
|
9
|
+
.select({ taskId: schema.hostFirstTurnReceipts.taskId })
|
|
10
|
+
.from(schema.hostFirstTurnReceipts)
|
|
11
|
+
.where(
|
|
12
|
+
and(
|
|
13
|
+
eq(schema.hostFirstTurnReceipts.taskId, taskId),
|
|
14
|
+
eq(schema.hostFirstTurnReceipts.firstTurnId, firstTurnId),
|
|
15
|
+
),
|
|
16
|
+
)
|
|
17
|
+
.get() !== undefined
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Remember an ACCEPTED first-turn delivery (call only after acceptance). */
|
|
22
|
+
export function recordFirstTurnReceipt(taskId: string, firstTurnId: string): void {
|
|
23
|
+
getDb()
|
|
24
|
+
.insert(schema.hostFirstTurnReceipts)
|
|
25
|
+
.values({ taskId, firstTurnId })
|
|
26
|
+
.onConflictDoNothing()
|
|
27
|
+
.run();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run `deliver` at most once per (task, firstTurnId): a repeat of an accepted
|
|
34
|
+
* id is acknowledged without delivering, and a repeat that arrives while the
|
|
35
|
+
* first is still being accepted waits for it, then re-checks. A failed
|
|
36
|
+
* delivery records nothing, so the cloud's retry delivers for real.
|
|
37
|
+
*/
|
|
38
|
+
export async function deliverFirstTurnOnce<R extends { ok: boolean }>(
|
|
39
|
+
taskId: string,
|
|
40
|
+
firstTurnId: string,
|
|
41
|
+
deliver: () => Promise<R>,
|
|
42
|
+
ackedResult: () => R,
|
|
43
|
+
): Promise<R> {
|
|
44
|
+
const key = `${taskId}\u0000${firstTurnId}`;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const pending = inFlight.get(key);
|
|
47
|
+
if (!pending) break;
|
|
48
|
+
await pending.catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
if (hasFirstTurnReceipt(taskId, firstTurnId)) return ackedResult();
|
|
51
|
+
const run = (async () => {
|
|
52
|
+
const result = await deliver();
|
|
53
|
+
if (result.ok) recordFirstTurnReceipt(taskId, firstTurnId);
|
|
54
|
+
return result;
|
|
55
|
+
})();
|
|
56
|
+
inFlight.set(key, run);
|
|
57
|
+
try {
|
|
58
|
+
return await run;
|
|
59
|
+
} finally {
|
|
60
|
+
inFlight.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// Task-up.sh loads these same packaged assets: no inline repair copies.
|
|
4
|
+
export const NODE_STATE_SETUP_SCRIPT = readFileSync(new URL("../images/standard/container/uai-repair-node-data.py", import.meta.url), "utf8");
|
|
5
|
+
export const NODE_DATA_CREATE_SCRIPT = readFileSync(new URL("../images/standard/container/uai-create-node-data", import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
/** Normalize node-owned parents, create as node, then normalize new metadata. */
|
|
8
|
+
export async function prepareNodeData(exec: (user: "root" | "node", argv: [string, ...string[]]) => Promise<number | null>): Promise<boolean> {
|
|
9
|
+
for (const [user, argv] of [
|
|
10
|
+
["root", ["/usr/bin/python3", "-I", "-c", NODE_STATE_SETUP_SCRIPT]],
|
|
11
|
+
["node", ["/bin/sh", "-c", NODE_DATA_CREATE_SCRIPT]],
|
|
12
|
+
["root", ["/usr/bin/python3", "-I", "-c", NODE_STATE_SETUP_SCRIPT]],
|
|
13
|
+
] as const) {
|
|
14
|
+
if (await exec(user, [...argv]) !== 0) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|