@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.
@@ -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 add <name> <containerPort>"); process.exit(1); }
1279
- const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
1280
- out("preview '" + r.name + "' on port " + r.containerPort + " — turn it on with: moondeck preview enable " + r.name);
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>",
@@ -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",
@@ -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
- /** One line's decorators, or a value's call, list or object. Throws
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[]): void {
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
- if (char === "\\" && this.pos + 1 < this.text.length) {
257
- const escaped = this.text[this.pos + 1]!;
258
- // Not even escaped: a string never spans lines. (A lone CR is text;
259
- // the LF of an escaped CRLF fails below, as any line break does.)
260
- if (escaped === "\n") throw new SyntaxFailure();
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
- new DecoratorReader(content).readAll(decorators);
352
- return decorators;
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] === "\\" && (fence.length === 1 || text.startsWith(fence, i + 1))) {
455
- i += 1 + (fence.length === 1 ? 1 : fence.length);
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
- /** False when the decorators cannot be read: upstream refuses the file,
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): boolean => {
513
- const decorators = readDecorators(content);
514
- if (decorators === null) return false;
515
- into.decorators.push(...decorators);
516
- return true;
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("@") && !addDecorators(block, content)) return "schema_malformed";
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("@") && !addDecorators(attached, comment)) return "schema_malformed";
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
+ }
@@ -197,6 +197,8 @@ export function createLocalMachineProvider(
197
197
  const args = [
198
198
  "run",
199
199
  "-d",
200
+ "--sysctl",
201
+ "net.ipv4.ip_unprivileged_port_start=80",
200
202
  "--name",
201
203
  name,
202
204
  "--label",
@@ -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
+ }