schornai 0.2.0 → 0.4.0

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
@@ -4,8 +4,8 @@ The schorn.ai command-line application. The public npm package and executable ar
4
4
  both named `schornai`; service implementation remains in the separate private
5
5
  workspace package `@schornio/aibox-package-service`.
6
6
 
7
- Requires Node.js 24 or newer. This source adds automatic setup and service commands for the next
8
- CLI release; publishing the new version is a separate release action.
7
+ Requires Node.js 24 or newer. Automatic setup and service commands are included
8
+ in the `schornai@0.4.0` package.
9
9
 
10
10
  ```sh
11
11
  npx schornai
@@ -17,8 +17,8 @@ changing files. Only `y` (case-insensitive) proceeds; `n`, Enter, end of input o
17
17
  Ctrl+C cancels. Retrying an incomplete installation asks again.
18
18
  After confirmation it downloads the latest XRAG setup from
19
19
  `https://schorn.ai/xrag/schornai/latest`, validates the archive and platform,
20
- installs it and starts the service. The endpoint is still being prepared;
21
- an unavailable download exits with an error and can be retried.
20
+ installs it and starts the service. The public bootstrap targets macOS ARM64.
21
+ An unavailable download exits with an error and can be retried.
22
22
  The bootstrap does not send a platform or architecture to that endpoint, so
23
23
  it can currently serve only one target. A macOS ARM64 bundle cannot install
24
24
  Linux; Linux needs a separately built bundle for the matching architecture.
@@ -7,7 +7,7 @@ import { dirname, join, resolve } from 'node:path';
7
7
  import { createInterface } from 'node:readline/promises';
8
8
  import { Transform } from 'node:stream';
9
9
  import { pipeline } from 'node:stream/promises';
10
- import { r as runServiceCLI, e as extractArchive } from './shared/cli-BxEHldVH.mjs';
10
+ import { r as runServiceCLI, e as extractArchive } from './shared/cli-CpgbC8Vv.mjs';
11
11
  import { i as initConfig, w as writeJson, l as loadConfig, r as resolveFrom, o as object, a as literal, d as defaultConfigPath, s as string, c as createLogger, b as run, e as binary, n as number, f as boolean } from './shared/docker-Dj7cdbkw.mjs';
12
12
  import { g as generateSecuritySecrets, h as hasLegacyLayout } from './shared/installer-CWopk_6Z.mjs';
13
13
  import { execFile } from 'node:child_process';
@@ -409,6 +409,7 @@ async function runDefaultCLI() {
409
409
  console.log(`Downloading schorn.ai setup from ${latestSetupUrl}`);
410
410
  const archive = join(temporary, "setup.tar");
411
411
  const id = await downloadSetup(archive, signal);
412
+ console.log("Download complete. Verifying and extracting schorn.ai setup...");
412
413
  const payload = join(temporary, "payload");
413
414
  await mkdir(payload, { mode: 0o700 });
414
415
  const inspected = await extractArchive(archive, payload, id, signal);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runServiceCLI } from './shared/cli-BxEHldVH.mjs';
2
+ import { r as runServiceCLI } from './shared/cli-CpgbC8Vv.mjs';
3
3
  import 'node:util';
4
4
  import 'node:fs';
5
5
  import 'node:fs/promises';
@@ -6,7 +6,7 @@ import 'node:path';
6
6
  import 'node:child_process';
7
7
  import 'node:timers/promises';
8
8
  import './shared/docker-Dj7cdbkw.mjs';
9
- export { r as runServiceCLI } from './shared/cli-BxEHldVH.mjs';
9
+ export { r as runServiceCLI } from './shared/cli-CpgbC8Vv.mjs';
10
10
  import 'node:os';
11
11
  import 'node:crypto';
12
12
  import 'node:http';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { promisify, isDeepStrictEqual, parseArgs } from 'node:util';
3
3
  import u, { createReadStream, existsSync, createWriteStream, openSync, closeSync } from 'node:fs';
4
- import ro, { mkdir, readFile, mkdtemp, rm, cp, writeFile, rename, statfs, readdir, chmod, stat, open } from 'node:fs/promises';
4
+ import ro, { lstat, readdir, realpath, mkdir, symlink, readlink, unlink, readFile, mkdtemp, rm, cp, writeFile, rename, statfs, chmod, stat, open } from 'node:fs/promises';
5
5
  import R, { win32, posix, basename, join, resolve, dirname as dirname$1 } from 'node:path';
6
6
  import { execFile, spawn } from 'node:child_process';
7
7
  import { setTimeout as setTimeout$1 } from 'node:timers/promises';
@@ -151,6 +151,46 @@ function archivePath(value) {
151
151
  }
152
152
  return posix.normalize(value).replace(/^\.\//, "");
153
153
  }
154
+ function assertRelativeLink(target) {
155
+ if (!target ||
156
+ target.startsWith("/") ||
157
+ target.includes("\\") ||
158
+ /^[A-Za-z]:/.test(target))
159
+ throw new Error("XRAG_UNSAFE_LINK");
160
+ }
161
+ async function assertSafeLink(name, link, lookup) {
162
+ assertRelativeLink(link.target);
163
+ const resolved = link.hard
164
+ ? []
165
+ : posix
166
+ .dirname(name)
167
+ .split("/")
168
+ .filter((part) => part !== ".");
169
+ const pending = link.target.split("/");
170
+ let hops = 0;
171
+ while (pending.length) {
172
+ const part = pending.shift();
173
+ if (!part || part === ".")
174
+ continue;
175
+ if (part === "..") {
176
+ if (!resolved.length)
177
+ throw new Error("XRAG_UNSAFE_LINK");
178
+ resolved.pop();
179
+ continue;
180
+ }
181
+ const nested = await lookup([...resolved, part].join("/"));
182
+ if (nested) {
183
+ assertRelativeLink(nested.target);
184
+ if (++hops > 40)
185
+ throw new Error("XRAG_UNSAFE_LINK");
186
+ if (nested.hard)
187
+ resolved.length = 0;
188
+ pending.unshift(...nested.target.split("/"));
189
+ }
190
+ else
191
+ resolved.push(part);
192
+ }
193
+ }
154
194
  /** Stream even very large archives; only small metadata files are held in memory. */
155
195
  async function inspectArchive(source, signal) {
156
196
  const hash = createHash("sha256");
@@ -178,11 +218,7 @@ async function inspectArchive(source, signal) {
178
218
  names.add(name);
179
219
  if (entry.type === "SymbolicLink" || entry.type === "Link") {
180
220
  const target = entry.linkpath ?? "";
181
- if (!target ||
182
- target.startsWith("/") ||
183
- target.includes("\\") ||
184
- /^[A-Za-z]:/.test(target))
185
- throw new Error("XRAG_UNSAFE_LINK");
221
+ assertRelativeLink(target);
186
222
  archivePath(posix.normalize(posix.join(entry.type === "Link" ? "." : posix.dirname(name), target)));
187
223
  links.set(name, { target, hard: entry.type === "Link" });
188
224
  }
@@ -227,37 +263,8 @@ async function inspectArchive(source, signal) {
227
263
  }
228
264
  // Resolve links component by component: normalizing "link/../x" first can hide
229
265
  // an escape when that intermediate link points at an ancestor directory.
230
- for (const [name, link] of links) {
231
- const resolved = link.hard
232
- ? []
233
- : posix
234
- .dirname(name)
235
- .split("/")
236
- .filter((part) => part !== ".");
237
- const pending = link.target.split("/");
238
- let hops = 0;
239
- while (pending.length) {
240
- const part = pending.shift();
241
- if (!part || part === ".")
242
- continue;
243
- if (part === "..") {
244
- if (!resolved.length)
245
- throw new Error("XRAG_UNSAFE_LINK");
246
- resolved.pop();
247
- continue;
248
- }
249
- const nested = links.get([...resolved, part].join("/"));
250
- if (nested) {
251
- if (++hops > 40)
252
- throw new Error("XRAG_UNSAFE_LINK");
253
- if (nested.hard)
254
- resolved.length = 0;
255
- pending.unshift(...nested.target.split("/"));
256
- }
257
- else
258
- resolved.push(part);
259
- }
260
- }
266
+ for (const [name, link] of links)
267
+ await assertSafeLink(name, link, (path) => links.get(path));
261
268
  const xrag = xragSchema.parse(JSON.parse(metadata.get("xrag.json")?.toString("utf8") ?? "null"));
262
269
  const required = xrag.xrag_type === "setup"
263
270
  ? ["setup.sh", "update.mts", "apps/service/cli.mjs"]
@@ -270,21 +277,85 @@ async function inspectArchive(source, signal) {
270
277
  : [];
271
278
  if (required.some((name) => !files.has(name)))
272
279
  throw new Error("XRAG_MISSING_PAYLOAD");
273
- return { id: hash.digest("hex"), size, xrag, metadata };
280
+ return { id: hash.digest("hex"), size, xrag, metadata, links };
274
281
  }
282
+ /** Extract only into a fresh, private directory owned by the caller. */
275
283
  async function extractArchive(file, destination, expectedId, signal) {
284
+ if (!(await lstat(destination)).isDirectory())
285
+ throw new Error("XRAG_UNSAFE_DESTINATION");
286
+ if ((await readdir(destination)).length)
287
+ throw new Error("XRAG_DESTINATION_NOT_EMPTY");
288
+ const root = await realpath(destination);
276
289
  const result = await inspectArchive(createReadStream(file), signal);
277
290
  if (result.id !== expectedId)
278
291
  throw new Error("XRAG_CHECKSUM_MISMATCH");
279
292
  signal?.throwIfAborted();
293
+ let extractionError;
280
294
  await So({
281
295
  file,
282
- cwd: destination,
283
- strict: true,
296
+ cwd: root,
297
+ // tar's strict mode rejects while other entries are still being written.
298
+ // Keep warnings fatal, but wait for those writes before callers clean up.
299
+ strict: false,
300
+ onwarn(code, message, data) {
301
+ extractionError ??= Object.assign(new Error(`${code}: ${message}`), data);
302
+ },
284
303
  preservePaths: false,
285
304
  noChmod: false,
286
305
  noMtime: true,
306
+ // tar rejects even validated in-tree symlink chains. Install these last,
307
+ // unchanged, so extraction never writes through an archive symlink.
308
+ filter: (_path, entry) => !("type" in entry && entry.type === "SymbolicLink"),
287
309
  });
310
+ if (extractionError)
311
+ throw extractionError;
312
+ signal?.throwIfAborted();
313
+ const created = [];
314
+ try {
315
+ for (const [name, link] of result.links) {
316
+ if (link.hard)
317
+ continue;
318
+ signal?.throwIfAborted();
319
+ let parent = root;
320
+ for (const part of posix.dirname(name).split("/")) {
321
+ if (part === ".")
322
+ continue;
323
+ parent = join(parent, part);
324
+ await mkdir(parent).catch((error) => {
325
+ if (error.code !== "EEXIST")
326
+ throw error;
327
+ });
328
+ if (!(await lstat(parent)).isDirectory())
329
+ throw new Error("XRAG_LINK_PARENT");
330
+ }
331
+ const path = join(root, name);
332
+ await symlink(link.target, path);
333
+ created.push(path);
334
+ }
335
+ // Check actual filesystem resolution too: case/Unicode aliases can make
336
+ // distinct archive names refer to the same path on macOS.
337
+ for (const [name, link] of result.links) {
338
+ if (link.hard)
339
+ continue;
340
+ signal?.throwIfAborted();
341
+ await assertSafeLink(name, link, async (path) => {
342
+ try {
343
+ if (!(await lstat(join(root, path))).isSymbolicLink())
344
+ return;
345
+ return { target: await readlink(join(root, path)), hard: false };
346
+ }
347
+ catch (error) {
348
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
349
+ return;
350
+ throw error;
351
+ }
352
+ });
353
+ }
354
+ }
355
+ catch (error) {
356
+ await Promise.allSettled(created.map((path) => unlink(path)));
357
+ throw error;
358
+ }
288
359
  return result;
289
360
  }
290
361
 
@@ -340,6 +411,29 @@ function workerRealtimeUrl(context, environment) {
340
411
  `${context.config.sharing.url.replace(/\/$/, "")}/v2/realtime`));
341
412
  }
342
413
 
414
+ /** Resolve legacy signing configuration and fail before a worker claims tasks.
415
+ * Error messages contain variable names only, never credential values.
416
+ */
417
+ function prepareTranscriberEnvironment(environment) {
418
+ const resolved = {
419
+ ...environment,
420
+ SUPABASE_JWT_SECRET: environment.SUPABASE_JWT_SECRET || environment.JWT_SECRET,
421
+ };
422
+ const required = [
423
+ "SUPABASE_URL",
424
+ "SUPABASE_ANON_KEY",
425
+ "SUPABASE_SERVICE_ROLE_KEY",
426
+ "SUPABASE_JWT_SECRET",
427
+ "REALTIME_SERVER_URL",
428
+ "REALTIME_SERVER_SECRET",
429
+ ];
430
+ const missing = required.filter((key) => !resolved[key]?.trim());
431
+ if (missing.length) {
432
+ throw new Error(`Transcriber configuration error: missing ${missing.join(", ")}. SUPABASE_JWT_SECRET may also be supplied as JWT_SECRET.`);
433
+ }
434
+ return resolved;
435
+ }
436
+
343
437
  function serviceCommands(context) {
344
438
  const { root, config, environment } = context;
345
439
  const workspace = config.development?.workspace;
@@ -390,19 +484,13 @@ function serviceCommands(context) {
390
484
  (localRealtime && existsSync(localCertificate)
391
485
  ? localCertificate
392
486
  : undefined));
393
- if (!supabase ||
394
- !realtime ||
395
- !workerEnv.REALTIME_SERVER_SECRET ||
396
- !workerEnv.SUPABASE_ANON_KEY ||
397
- !workerEnv.SUPABASE_SERVICE_ROLE_KEY)
398
- throw new Error("Transcriber requires Supabase/realtime endpoints and credentials in its environment file");
399
- const transcriberEnvironment = {
487
+ const transcriberEnvironment = prepareTranscriberEnvironment({
400
488
  ...workerEnv,
401
489
  WORKER_IDENTIFIER: `${config.id}:transcriber-mlx`,
402
490
  SUPABASE_URL: supabase,
403
491
  REALTIME_SERVER_URL: realtime,
404
492
  ...(caFile ? { NODE_EXTRA_CA_CERTS: caFile } : {}),
405
- };
493
+ });
406
494
  native.push(workspace
407
495
  ? developmentCommand("transcriber-mac", "transcriber-mlx", transcriberEnvironment)
408
496
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schornai",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The schorn.ai CLI, including machine service commands.",
5
5
  "type": "module",
6
6
  "bin": {