xaq-cli 0.1.0-nightly.92 → 0.1.0-nightly.93

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.
@@ -110,8 +110,14 @@ const PARSE_LIMITS = {
110
110
  /** Characters kept per cell. */
111
111
  cellChars: 2e3
112
112
  };
113
- /** A file the parser refuses to read (zip bomb, XXE, entry outside the archive, …): said in plain words. */
114
- var UnsafeDocumentError = class extends Error {};
113
+ /** A file the parser refuses to read (zip bomb, XXE, entry outside the archive, …): said in plain words, with a code. */
114
+ var UnsafeDocumentError = class extends Error {
115
+ code;
116
+ constructor(code, message) {
117
+ super(message);
118
+ this.code = code;
119
+ }
120
+ };
115
121
  //#endregion
116
122
  //#region server/mail/extract/csv.ts
117
123
  function guessDelimiter(text) {
@@ -7479,12 +7485,12 @@ const ELEMENT = 1;
7479
7485
  const TEXT = 3;
7480
7486
  const CDATA = 4;
7481
7487
  function parseXml(source) {
7482
- if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw new UnsafeDocumentError("The document declares XML entities (a DOCTYPE), which Office files never do; it wasn't read.");
7488
+ if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw new UnsafeDocumentError("hostile", "The document declares XML entities (a DOCTYPE), which Office files never do; it wasn't read.");
7483
7489
  let failed = null;
7484
7490
  const root = new import_lib.DOMParser({ onError: (level, msg) => {
7485
7491
  if (level !== "warning") failed ??= msg;
7486
7492
  } }).parseFromString(source, "text/xml").documentElement;
7487
- if (failed || !root) throw new UnsafeDocumentError("The document has malformed XML.");
7493
+ if (failed || !root) throw new UnsafeDocumentError("corrupt", "The document has malformed XML.");
7488
7494
  return root;
7489
7495
  }
7490
7496
  const local = (n) => n.localName ?? n.nodeName.replace(/^.*:/, "");
@@ -7568,25 +7574,25 @@ var ZipPackage = class {
7568
7574
  inflated = 0;
7569
7575
  constructor(buf) {
7570
7576
  this.buf = buf;
7571
- if (buf.length < 22) throw new UnsafeDocumentError("This isn't a valid zip package.");
7577
+ if (buf.length < 22) throw new UnsafeDocumentError("corrupt", "This isn't a valid zip package.");
7572
7578
  let eocd = -1;
7573
7579
  for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65535); i--) if (buf.readUInt32LE(i) === EOCD) {
7574
7580
  eocd = i;
7575
7581
  break;
7576
7582
  }
7577
- if (eocd < 0) throw new UnsafeDocumentError("This isn't a valid zip package (no central directory).");
7583
+ if (eocd < 0) throw new UnsafeDocumentError("corrupt", "This isn't a valid zip package (no central directory).");
7578
7584
  const disk = buf.readUInt16LE(eocd + 4);
7579
7585
  const cdDisk = buf.readUInt16LE(eocd + 6);
7580
7586
  const count = buf.readUInt16LE(eocd + 10);
7581
7587
  const cdSize = buf.readUInt32LE(eocd + 12);
7582
7588
  const cdOffset = buf.readUInt32LE(eocd + 16);
7583
- if (disk !== 0 || cdDisk !== 0) throw new UnsafeDocumentError("Multi-part zip archives aren't supported.");
7584
- if (count === 65535 || cdOffset === 4294967295) throw new UnsafeDocumentError("ZIP64 archives aren't supported.");
7585
- if (count > PARSE_LIMITS.zipEntries) throw new UnsafeDocumentError(`The package has ${count} entries; at most ${PARSE_LIMITS.zipEntries} are read.`);
7586
- if (cdOffset + cdSize > eocd) throw new UnsafeDocumentError("This zip package is corrupt (central directory out of range).");
7589
+ if (disk !== 0 || cdDisk !== 0) throw new UnsafeDocumentError("unsupported", "Multi-part zip archives aren't supported.");
7590
+ if (count === 65535 || cdOffset === 4294967295) throw new UnsafeDocumentError("unsupported", "ZIP64 archives aren't supported.");
7591
+ if (count > PARSE_LIMITS.zipEntries) throw new UnsafeDocumentError("too_complex", `The package has ${count} entries; at most ${PARSE_LIMITS.zipEntries} are read.`);
7592
+ if (cdOffset + cdSize > eocd) throw new UnsafeDocumentError("corrupt", "This zip package is corrupt (central directory out of range).");
7587
7593
  let p = cdOffset;
7588
7594
  for (let i = 0; i < count; i++) {
7589
- if (p + 46 > eocd || buf.readUInt32LE(p) !== CDH) throw new UnsafeDocumentError("This zip package is corrupt (bad central directory).");
7595
+ if (p + 46 > eocd || buf.readUInt32LE(p) !== CDH) throw new UnsafeDocumentError("corrupt", "This zip package is corrupt (bad central directory).");
7590
7596
  const flags = buf.readUInt16LE(p + 8);
7591
7597
  const method = buf.readUInt16LE(p + 10);
7592
7598
  const compressedSize = buf.readUInt32LE(p + 20);
@@ -7597,10 +7603,10 @@ var ZipPackage = class {
7597
7603
  const localOffset = buf.readUInt32LE(p + 42);
7598
7604
  const name = buf.toString(flags & 2048 ? "utf8" : "latin1", p + 46, p + 46 + nameLen);
7599
7605
  p += 46 + nameLen + extraLen + commentLen;
7600
- if (flags & 1) throw new UnsafeDocumentError("Encrypted documents can't be read.");
7601
- if (compressedSize === 4294967295 || size === 4294967295 || localOffset === 4294967295) throw new UnsafeDocumentError("ZIP64 archives aren't supported.");
7602
- if (!safeEntryName(name)) throw new UnsafeDocumentError("The package has an entry with a path outside the archive; it wasn't read.");
7603
- if (this.entries.has(name)) throw new UnsafeDocumentError("The package lists the same entry twice; it wasn't read.");
7606
+ if (flags & 1) throw new UnsafeDocumentError("encrypted", "Encrypted documents can't be read.");
7607
+ if (compressedSize === 4294967295 || size === 4294967295 || localOffset === 4294967295) throw new UnsafeDocumentError("unsupported", "ZIP64 archives aren't supported.");
7608
+ if (!safeEntryName(name)) throw new UnsafeDocumentError("hostile", "The package has an entry with a path outside the archive; it wasn't read.");
7609
+ if (this.entries.has(name)) throw new UnsafeDocumentError("hostile", "The package lists the same entry twice; it wasn't read.");
7604
7610
  if (name.endsWith("/")) continue;
7605
7611
  this.entries.set(name, {
7606
7612
  name,
@@ -7622,23 +7628,23 @@ var ZipPackage = class {
7622
7628
  const e = this.entries.get(name);
7623
7629
  if (!e) return null;
7624
7630
  const b = this.buf;
7625
- if (e.localOffset + 30 > b.length || b.readUInt32LE(e.localOffset) !== LFH) throw new UnsafeDocumentError("This zip package is corrupt (bad entry header).");
7631
+ if (e.localOffset + 30 > b.length || b.readUInt32LE(e.localOffset) !== LFH) throw new UnsafeDocumentError("corrupt", "This zip package is corrupt (bad entry header).");
7626
7632
  const start = e.localOffset + 30 + b.readUInt16LE(e.localOffset + 26) + b.readUInt16LE(e.localOffset + 28);
7627
- if (start + e.compressedSize > b.length) throw new UnsafeDocumentError("This zip package is corrupt (entry out of range).");
7633
+ if (start + e.compressedSize > b.length) throw new UnsafeDocumentError("corrupt", "This zip package is corrupt (entry out of range).");
7628
7634
  const data = b.subarray(start, start + e.compressedSize);
7629
7635
  const budget = Math.min(PARSE_LIMITS.zipEntryBytes, PARSE_LIMITS.zipTotalBytes - this.inflated);
7630
- if (e.size > 1048576 && e.size / Math.max(1, e.compressedSize) > PARSE_LIMITS.zipRatio) throw new UnsafeDocumentError("The document looks like a zip bomb (it decompresses to far more than its size); it wasn't read.");
7631
- if (e.size > budget) throw new UnsafeDocumentError("The document is too large to read once decompressed.");
7636
+ if (e.size > 1048576 && e.size / Math.max(1, e.compressedSize) > PARSE_LIMITS.zipRatio) throw new UnsafeDocumentError("hostile", "The document looks like a zip bomb (it decompresses to far more than its size); it wasn't read.");
7637
+ if (e.size > budget) throw new UnsafeDocumentError("too_complex", "The document is too large to read once decompressed.");
7632
7638
  let out;
7633
7639
  if (e.method === 0) out = data;
7634
7640
  else if (e.method === 8) try {
7635
7641
  out = inflateRawSync(data, { maxOutputLength: Math.max(1, budget) });
7636
7642
  } catch (err) {
7637
- if (err.code === "ERR_BUFFER_TOO_LARGE") throw new UnsafeDocumentError("The document looks like a zip bomb (it decompresses to far more than its size); it wasn't read.");
7638
- throw new UnsafeDocumentError("This zip package is corrupt (an entry doesn't decompress).");
7643
+ if (err.code === "ERR_BUFFER_TOO_LARGE") throw new UnsafeDocumentError("hostile", "The document looks like a zip bomb (it decompresses to far more than its size); it wasn't read.");
7644
+ throw new UnsafeDocumentError("corrupt", "This zip package is corrupt (an entry doesn't decompress).");
7639
7645
  }
7640
- else throw new UnsafeDocumentError("The package uses a compression method that isn't supported.");
7641
- if (out.length > budget) throw new UnsafeDocumentError("The document is too large to read once decompressed.");
7646
+ else throw new UnsafeDocumentError("unsupported", "The package uses a compression method that isn't supported.");
7647
+ if (out.length > budget) throw new UnsafeDocumentError("too_complex", "The document is too large to read once decompressed.");
7642
7648
  this.inflated += out.length;
7643
7649
  return out;
7644
7650
  }
@@ -7684,7 +7690,7 @@ function extractDocx(input) {
7684
7690
  const pkg = new ZipPackage(input);
7685
7691
  const part = mainPart(pkg, "word/document.xml");
7686
7692
  const xml = pkg.text(part);
7687
- if (xml === null) throw new UnsafeDocumentError("The file isn't a Word document (no word/document.xml).");
7693
+ if (xml === null) throw new UnsafeDocumentError("corrupt", "The file isn't a Word document (no word/document.xml).");
7688
7694
  const rels = relationships(pkg.text(relsPath(part)));
7689
7695
  const byType = (suffix) => {
7690
7696
  for (const r of rels.values()) if (r.type.endsWith(suffix) && !r.external) return resolvePart(part, r.target);
@@ -77743,8 +77749,8 @@ async function extractPdf(input) {
77743
77749
  try {
77744
77750
  doc = await task.promise;
77745
77751
  } catch (err) {
77746
- if (err.name === "PasswordException") throw new UnsafeDocumentError("The PDF is password-protected.");
77747
- throw new UnsafeDocumentError("The file isn't a readable PDF.");
77752
+ if (err.name === "PasswordException") throw new UnsafeDocumentError("password", "The PDF is password-protected.");
77753
+ throw new UnsafeDocumentError("corrupt", "The file isn't a readable PDF.");
77748
77754
  }
77749
77755
  const totalPages = doc.numPages;
77750
77756
  const pages = [];
@@ -77808,7 +77814,7 @@ function extractPptx(input) {
77808
77814
  const pkg = new ZipPackage(input);
77809
77815
  const part = mainPart(pkg, "ppt/presentation.xml");
77810
77816
  const presXml = pkg.text(part);
77811
- if (presXml === null) throw new UnsafeDocumentError("The file isn't a PowerPoint presentation (no ppt/presentation.xml).");
77817
+ if (presXml === null) throw new UnsafeDocumentError("corrupt", "The file isn't a PowerPoint presentation (no ppt/presentation.xml).");
77812
77818
  const rels = relationships(pkg.text(relsPath(part)));
77813
77819
  const pres = parseXml(presXml);
77814
77820
  const ids = [...descendants(firstChild(pres, "sldIdLst") ?? pres, "sldId")];
@@ -77902,7 +77908,7 @@ function extractXlsx(input) {
77902
77908
  const pkg = new ZipPackage(input);
77903
77909
  const part = mainPart(pkg, "xl/workbook.xml");
77904
77910
  const wbXml = pkg.text(part);
77905
- if (wbXml === null) throw new UnsafeDocumentError("The file isn't an Excel workbook (no xl/workbook.xml).");
77911
+ if (wbXml === null) throw new UnsafeDocumentError("corrupt", "The file isn't an Excel workbook (no xl/workbook.xml).");
77906
77912
  const wb = parseXml(wbXml);
77907
77913
  const rels = relationships(pkg.text(relsPath(part)));
77908
77914
  const relOf = (suffix) => {
@@ -78064,6 +78070,7 @@ function cleanParts(r, clean) {
78064
78070
  async function run(input, task) {
78065
78071
  if (input.length > PARSE_LIMITS.inputBytes) return {
78066
78072
  ok: false,
78073
+ code: "too_complex",
78067
78074
  error: "The file is too large to read."
78068
78075
  };
78069
78076
  try {
@@ -78086,6 +78093,7 @@ async function run(input, task) {
78086
78093
  break;
78087
78094
  default: return {
78088
78095
  ok: false,
78096
+ code: "parser_error",
78089
78097
  error: "Unknown format."
78090
78098
  };
78091
78099
  }
@@ -78094,9 +78102,15 @@ async function run(input, task) {
78094
78102
  result: cleanAll(result)
78095
78103
  };
78096
78104
  } catch (err) {
78105
+ if (err instanceof UnsafeDocumentError) return {
78106
+ ok: false,
78107
+ code: err.code,
78108
+ error: err.message
78109
+ };
78097
78110
  return {
78098
78111
  ok: false,
78099
- error: err instanceof UnsafeDocumentError ? err.message : "The file couldn't be parsed."
78112
+ code: "parser_error",
78113
+ error: "The file couldn't be parsed."
78100
78114
  };
78101
78115
  }
78102
78116
  }
@@ -74,7 +74,7 @@ Example:
74
74
  const { openAdminChannel } = await import("./admin-DbJXEo2d.js");
75
75
  const { TerminalPrompter } = await import("./prompt-BEH311I7.js");
76
76
  const { connectAgentMcp } = await import("./agent-B8a-Ivyd.js");
77
- const { SmokeRun } = await import("./run-D3Qmg-MB.js");
77
+ const { SmokeRun } = await import("./run-zakbVgQR.js");
78
78
  const { renderReport } = await import("./report-D2D_6E5O.js");
79
79
  if (!o.scratch) throw new Error("smoke google-workspace runs live on the connected Google account: it reads existing files and mail, and writes only into a new scratch folder and ONE draft to the account itself, which it removes again. Pass --scratch to run it.");
80
80
  const out = safeWriter(o.json ? process.stderr : process.stdout);
@@ -123,4 +123,4 @@ Example:
123
123
  //#endregion
124
124
  export { registerSmokeCommands };
125
125
 
126
- //# sourceMappingURL=cli-yN8ExqrA.js.map
126
+ //# sourceMappingURL=cli-Wp2ejVBT.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-yN8ExqrA.js","names":[],"sources":["../server/smoke/cli.ts"],"sourcesContent":["// `xaq smoke google-workspace --scratch`: run on xaq's machine, through the daemon's admin API over the local admin\n// channel (server/connections-cli/admin.ts) and /mcp as a temporary agent. See run.ts for what it does.\n\nimport type { Command } from \"commander\";\nimport { LEGACY_PRODUCT } from \"../brand.ts\";\nimport { resolveDataDir } from \"../config.ts\";\nimport { NPX } from \"../listen/version.ts\";\nimport type { SmokeFlags } from \"./run.ts\";\n\nconst GROUP = \"MCP connections (on xaq's machine):\";\n\n/**\n * A line printer that can't kill the run: once the stream fails (EIO or EPIPE from a terminal that went away, e.g.\n * after SIGHUP), later lines are dropped instead of throwing or crashing on an unhandled 'error'.\n */\nexport function safeWriter(stream: NodeJS.WritableStream): { print: (line?: string) => void; dispose: () => void } {\n let dead = false;\n const onError = () => {\n dead = true;\n };\n stream.on(\"error\", onError);\n return {\n print(line = \"\") {\n if (dead) return;\n try {\n stream.write(`${line}\\n`, (err) => {\n if (err) dead = true;\n });\n } catch {\n dead = true;\n }\n },\n dispose: () => stream.removeListener(\"error\", onError),\n };\n}\n\nexport interface SmokeSignalOptions {\n print: (line: string) => void;\n exit: (code: number) => void;\n /** The second Ctrl-C: the quick part of cleanup (agent, attachmentDirs), bounded. */\n fastCleanup: () => Promise<void>;\n /** What may still be there, in a sentence. */\n leftovers: () => string;\n /** Upper bound on the second Ctrl-C's cleanup before quitting anyway (default 8 s). */\n quitWithinMs?: number;\n}\n\n/**\n * Signals during a run. SIGTERM and SIGHUP (a closed terminal, a stopped service) always act like the first Ctrl-C:\n * no new step starts and the run's full cleanup runs. The first Ctrl-C does the same; a second one runs the quick\n * cleanup (delete the agent, restore attachmentDirs) with a short timeout, says what may be left and quits.\n * Returns the remover.\n */\nexport function installSmokeSignals(controller: AbortController, opts: SmokeSignalOptions): () => void {\n let interrupts = 0;\n let quitting = false;\n const stop = (why: string) => {\n if (controller.signal.aborted) return;\n opts.print(`\\n${why}: stopping after the current call, then cleaning up.${why === \"Ctrl-C\" ? \" Press Ctrl-C again to quit sooner (only the agent and attachmentDirs are cleaned up then).\" : \"\"}`);\n controller.abort();\n };\n const onInt = () => {\n interrupts++;\n if (interrupts === 1) return stop(\"Ctrl-C\");\n if (quitting) return;\n quitting = true;\n opts.print(\"\\nQuitting: deleting the temporary agent and restoring attachmentDirs first…\");\n const deadline = new Promise<void>((r) => setTimeout(r, opts.quitWithinMs ?? 8000).unref?.());\n void Promise.race([opts.fastCleanup().catch(() => {}), deadline]).then(() => {\n opts.print(`Quit before the cleanup finished. ${opts.leftovers()}`);\n opts.exit(130);\n });\n };\n const onTerm = () => stop(\"SIGTERM\");\n const onHup = () => stop(\"SIGHUP\");\n process.on(\"SIGINT\", onInt);\n process.on(\"SIGTERM\", onTerm);\n process.on(\"SIGHUP\", onHup);\n return () => {\n process.removeListener(\"SIGINT\", onInt);\n process.removeListener(\"SIGTERM\", onTerm);\n process.removeListener(\"SIGHUP\", onHup);\n };\n}\n\nexport function registerSmokeCommands(program: Command): void {\n const smoke = program\n .command(\"smoke\")\n .description(\"live end-to-end checks against the connected accounts, safe by construction\")\n .summary(\"live end-to-end check of a connection\")\n .helpGroup(GROUP);\n\n smoke\n .command(\"google-workspace\")\n .description(\"check Gmail attachments and the google-workspace extension end to end on the real account: reads anything, writes only into a new scratch folder and ONE draft to itself, cleans up\")\n .option(\"--scratch\", \"required: confirms the run may create its scratch folder, its draft and its temporary agent (all removed again)\")\n .option(\"--account <email>\", \"the Gmail account (default: the only one, or the one the Workspace connection is signed in as)\")\n .option(\"--connection <slug>\", \"the google-workspace connection (default: the only one)\")\n .option(\"--json\", \"print the result table as JSON on stdout (progress goes to stderr)\")\n .option(\"-y, --yes\", \"don't ask before running\")\n .option(\"-d, --data <dir>\", `xaq's data directory (default ~/.xaq, or an existing ~/.${LEGACY_PRODUCT}; env XAQ_DATA)`)\n .addHelpText(\n \"after\",\n `\nPrints the plan first and asks (or --yes). Exit code 0 only when every step passes and nothing is left behind.\n\nExample:\n ${NPX} smoke google-workspace --scratch`,\n )\n .action(async (o: SmokeFlags & { data?: string }) => {\n const { openAdminChannel } = await import(\"../connections-cli/admin.ts\");\n const { TerminalPrompter } = await import(\"../connections-cli/prompt.ts\");\n const { connectAgentMcp } = await import(\"./agent.ts\");\n const { SmokeRun } = await import(\"./run.ts\");\n const { renderReport } = await import(\"./report.ts\");\n if (!o.scratch) {\n throw new Error(\n \"smoke google-workspace runs live on the connected Google account: it reads existing files and mail, and writes only into a new scratch folder and ONE draft to the account itself, which it removes again. Pass --scratch to run it.\",\n );\n }\n const out = safeWriter(o.json ? process.stderr : process.stdout);\n const err = o.json ? out : safeWriter(process.stderr);\n const json = o.json ? safeWriter(process.stdout) : null;\n const channel = openAdminChannel({ dataDir: resolveDataDir(o.data, { quiet: true }) });\n const prompter = new TerminalPrompter(!!o.yes, { input: process.stdin, output: o.json ? process.stderr : process.stdout });\n const controller = new AbortController();\n const run = new SmokeRun(o, {\n admin: channel,\n connectMcp: (key) => connectAgentMcp(channel.baseUrl, key),\n print: out.print,\n confirm: (q) => prompter.confirm(q, false),\n signal: controller.signal,\n });\n const remove = installSmokeSignals(controller, {\n print: (l) => err.print(l),\n fastCleanup: () => run.fastCleanup(3000),\n exit: (code) => {\n channel.close();\n process.exit(code);\n },\n leftovers: () => {\n const left = run.pendingLeftovers();\n return left.length ? `These may be left: ${left.join(\"; \")}. The next run removes the agent and the attachmentDirs entry and lists the rest.` : \"Nothing was left.\";\n },\n });\n try {\n const report = await run.run();\n if (json) json.print(JSON.stringify(report, null, 2));\n else for (const l of [\"\", ...renderReport(report)]) out.print(l);\n process.exitCode = report.ok ? 0 : report.interrupted ? 130 : 1;\n } finally {\n remove();\n prompter.close();\n channel.close();\n out.dispose();\n if (err !== out) err.dispose();\n json?.dispose();\n }\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli-Wp2ejVBT.js","names":[],"sources":["../server/smoke/cli.ts"],"sourcesContent":["// `xaq smoke google-workspace --scratch`: run on xaq's machine, through the daemon's admin API over the local admin\n// channel (server/connections-cli/admin.ts) and /mcp as a temporary agent. See run.ts for what it does.\n\nimport type { Command } from \"commander\";\nimport { LEGACY_PRODUCT } from \"../brand.ts\";\nimport { resolveDataDir } from \"../config.ts\";\nimport { NPX } from \"../listen/version.ts\";\nimport type { SmokeFlags } from \"./run.ts\";\n\nconst GROUP = \"MCP connections (on xaq's machine):\";\n\n/**\n * A line printer that can't kill the run: once the stream fails (EIO or EPIPE from a terminal that went away, e.g.\n * after SIGHUP), later lines are dropped instead of throwing or crashing on an unhandled 'error'.\n */\nexport function safeWriter(stream: NodeJS.WritableStream): { print: (line?: string) => void; dispose: () => void } {\n let dead = false;\n const onError = () => {\n dead = true;\n };\n stream.on(\"error\", onError);\n return {\n print(line = \"\") {\n if (dead) return;\n try {\n stream.write(`${line}\\n`, (err) => {\n if (err) dead = true;\n });\n } catch {\n dead = true;\n }\n },\n dispose: () => stream.removeListener(\"error\", onError),\n };\n}\n\nexport interface SmokeSignalOptions {\n print: (line: string) => void;\n exit: (code: number) => void;\n /** The second Ctrl-C: the quick part of cleanup (agent, attachmentDirs), bounded. */\n fastCleanup: () => Promise<void>;\n /** What may still be there, in a sentence. */\n leftovers: () => string;\n /** Upper bound on the second Ctrl-C's cleanup before quitting anyway (default 8 s). */\n quitWithinMs?: number;\n}\n\n/**\n * Signals during a run. SIGTERM and SIGHUP (a closed terminal, a stopped service) always act like the first Ctrl-C:\n * no new step starts and the run's full cleanup runs. The first Ctrl-C does the same; a second one runs the quick\n * cleanup (delete the agent, restore attachmentDirs) with a short timeout, says what may be left and quits.\n * Returns the remover.\n */\nexport function installSmokeSignals(controller: AbortController, opts: SmokeSignalOptions): () => void {\n let interrupts = 0;\n let quitting = false;\n const stop = (why: string) => {\n if (controller.signal.aborted) return;\n opts.print(`\\n${why}: stopping after the current call, then cleaning up.${why === \"Ctrl-C\" ? \" Press Ctrl-C again to quit sooner (only the agent and attachmentDirs are cleaned up then).\" : \"\"}`);\n controller.abort();\n };\n const onInt = () => {\n interrupts++;\n if (interrupts === 1) return stop(\"Ctrl-C\");\n if (quitting) return;\n quitting = true;\n opts.print(\"\\nQuitting: deleting the temporary agent and restoring attachmentDirs first…\");\n const deadline = new Promise<void>((r) => setTimeout(r, opts.quitWithinMs ?? 8000).unref?.());\n void Promise.race([opts.fastCleanup().catch(() => {}), deadline]).then(() => {\n opts.print(`Quit before the cleanup finished. ${opts.leftovers()}`);\n opts.exit(130);\n });\n };\n const onTerm = () => stop(\"SIGTERM\");\n const onHup = () => stop(\"SIGHUP\");\n process.on(\"SIGINT\", onInt);\n process.on(\"SIGTERM\", onTerm);\n process.on(\"SIGHUP\", onHup);\n return () => {\n process.removeListener(\"SIGINT\", onInt);\n process.removeListener(\"SIGTERM\", onTerm);\n process.removeListener(\"SIGHUP\", onHup);\n };\n}\n\nexport function registerSmokeCommands(program: Command): void {\n const smoke = program\n .command(\"smoke\")\n .description(\"live end-to-end checks against the connected accounts, safe by construction\")\n .summary(\"live end-to-end check of a connection\")\n .helpGroup(GROUP);\n\n smoke\n .command(\"google-workspace\")\n .description(\"check Gmail attachments and the google-workspace extension end to end on the real account: reads anything, writes only into a new scratch folder and ONE draft to itself, cleans up\")\n .option(\"--scratch\", \"required: confirms the run may create its scratch folder, its draft and its temporary agent (all removed again)\")\n .option(\"--account <email>\", \"the Gmail account (default: the only one, or the one the Workspace connection is signed in as)\")\n .option(\"--connection <slug>\", \"the google-workspace connection (default: the only one)\")\n .option(\"--json\", \"print the result table as JSON on stdout (progress goes to stderr)\")\n .option(\"-y, --yes\", \"don't ask before running\")\n .option(\"-d, --data <dir>\", `xaq's data directory (default ~/.xaq, or an existing ~/.${LEGACY_PRODUCT}; env XAQ_DATA)`)\n .addHelpText(\n \"after\",\n `\nPrints the plan first and asks (or --yes). Exit code 0 only when every step passes and nothing is left behind.\n\nExample:\n ${NPX} smoke google-workspace --scratch`,\n )\n .action(async (o: SmokeFlags & { data?: string }) => {\n const { openAdminChannel } = await import(\"../connections-cli/admin.ts\");\n const { TerminalPrompter } = await import(\"../connections-cli/prompt.ts\");\n const { connectAgentMcp } = await import(\"./agent.ts\");\n const { SmokeRun } = await import(\"./run.ts\");\n const { renderReport } = await import(\"./report.ts\");\n if (!o.scratch) {\n throw new Error(\n \"smoke google-workspace runs live on the connected Google account: it reads existing files and mail, and writes only into a new scratch folder and ONE draft to the account itself, which it removes again. Pass --scratch to run it.\",\n );\n }\n const out = safeWriter(o.json ? process.stderr : process.stdout);\n const err = o.json ? out : safeWriter(process.stderr);\n const json = o.json ? safeWriter(process.stdout) : null;\n const channel = openAdminChannel({ dataDir: resolveDataDir(o.data, { quiet: true }) });\n const prompter = new TerminalPrompter(!!o.yes, { input: process.stdin, output: o.json ? process.stderr : process.stdout });\n const controller = new AbortController();\n const run = new SmokeRun(o, {\n admin: channel,\n connectMcp: (key) => connectAgentMcp(channel.baseUrl, key),\n print: out.print,\n confirm: (q) => prompter.confirm(q, false),\n signal: controller.signal,\n });\n const remove = installSmokeSignals(controller, {\n print: (l) => err.print(l),\n fastCleanup: () => run.fastCleanup(3000),\n exit: (code) => {\n channel.close();\n process.exit(code);\n },\n leftovers: () => {\n const left = run.pendingLeftovers();\n return left.length ? `These may be left: ${left.join(\"; \")}. The next run removes the agent and the attachmentDirs entry and lists the rest.` : \"Nothing was left.\";\n },\n });\n try {\n const report = await run.run();\n if (json) json.print(JSON.stringify(report, null, 2));\n else for (const l of [\"\", ...renderReport(report)]) out.print(l);\n process.exitCode = report.ok ? 0 : report.interrupted ? 130 : 1;\n } finally {\n remove();\n prompter.close();\n channel.close();\n out.dispose();\n if (err !== out) err.dispose();\n json?.dispose();\n }\n });\n}\n"],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -59,7 +59,7 @@ const SERVER_GROUP = "Run xaq:";
59
59
  const AGENT_GROUP = "On an agent's machine:";
60
60
  withServerOptions(program.command("start", { isDefault: true }).description("run xaq in this terminal and print the setup link (the default command)").summary("run it in this terminal (the default command)").helpGroup(SERVER_GROUP)).option("--no-open", "don't open a browser on first run").option("--dev", "development: API only, the console runs on Vite (:5173)").allowExcessArguments().action(async (o, cmd) => {
61
61
  if (cmd.args.length) throw new Error(unknownCommand(cmd.args[0]));
62
- const { runForeground } = await import("./server-BmxoM9--.js");
62
+ const { runForeground } = await import("./server-0wxSpx6s.js");
63
63
  await runForeground({
64
64
  ...configInput(o),
65
65
  dev: !!o.dev,
@@ -144,7 +144,7 @@ const { registerConnectionsCommands } = await import("./commands-CgmkYCYG.js");
144
144
  registerConnectionsCommands(program);
145
145
  const { registerExtensionCommands } = await import("./cli-B8cuKWiS.js");
146
146
  registerExtensionCommands(program);
147
- const { registerSmokeCommands } = await import("./cli-yN8ExqrA.js");
147
+ const { registerSmokeCommands } = await import("./cli-Wp2ejVBT.js");
148
148
  registerSmokeCommands(program);
149
149
  const { registerSettingsCommands } = await import("./settings-cli-DfWc-yWJ.js");
150
150
  registerSettingsCommands(program);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { r as cleanLine } from "./content-Di_U9ZZ6.js";
2
3
  import { t as NPX } from "./version-BxH7OuWD.js";
3
- import { a as smokeSubjectPrefix, f as PARSE_LIMITS, i as smokeName, n as SMOKE_TEMP_RE, s as attachmentKind, t as SMOKE_NAME_RE } from "./smoke-DA7EXNuS.js";
4
+ import { a as smokeSubjectPrefix, f as PARSE_LIMITS, i as smokeName, n as SMOKE_TEMP_RE, s as attachmentKind, t as SMOKE_NAME_RE } from "./smoke-ZqEGwDQl.js";
4
5
  import "./agent-mYT2noTp.js";
5
6
  import { t as finish } from "./report-BLbtzUlC.js";
6
7
  import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -212,13 +213,14 @@ const MAIL_TOOLS = [
212
213
  "create_draft"
213
214
  ];
214
215
  /**
215
- * What get_attachment (server/mcp/attachments.ts) shows inline instead of saving to disk, from its own rules:
216
- * attachmentKind() on the listed type and name, images under MAX_ATTACHMENT_BYTES, PDF/DOCX/XLSX/PPTX/CSV under
217
- * PARSE_LIMITS.inputBytes, text and HTML at any size (it reads the first part). Everything else is saved, so the run
218
- * never asks for it. The bytes aren't known here: attachmentKind's content sniff (a PDF sent as octet-stream without
219
- * an extension) sees an empty buffer and says "other", which skips the file. That errs on the safe side.
216
+ * What get_attachment (server/mcp/attachments.ts) can show inline, from its own rules: attachmentKind() on the listed
217
+ * type and name, images under MAX_ATTACHMENT_BYTES, PDF/DOCX/XLSX/PPTX/CSV under PARSE_LIMITS.inputBytes, text and
218
+ * HTML at any size (it reads the first part). The run asks with format "inline", which never saves; for anything
219
+ * else that would only download the owner's file to answer "not shown", so the run doesn't ask. The bytes aren't
220
+ * known here: attachmentKind's content sniff (a PDF sent as octet-stream without an extension) sees an empty buffer
221
+ * and says "other", which skips the file.
220
222
  */
221
- const SKIPPED_WOULD_SAVE = "skipped (would be saved to disk)";
223
+ const SKIPPED_NOT_INLINE = "skipped (can't be shown inline)";
222
224
  /** The kind get_attachment would show inline, or null when it would save the file to disk (so the run skips it). */
223
225
  function inlineKind(a) {
224
226
  const kind = attachmentKind(a.mimeType, a.filename, Buffer.alloc(0));
@@ -297,7 +299,7 @@ function planLines(marker, account, connection) {
297
299
  "Reads (existing files and mail, read only; prints names, the subject, kinds and sizes, never content):",
298
300
  " - Drive: search the newest Doc, Sheet and Slides file, list the top of My Drive, get, read and export each",
299
301
  " - Gmail: ONE email with attachments: list its attachments and extract each one get_attachment shows",
300
- " inline (anything it would save to disk is skipped)",
302
+ " inline, asking it never to save a copy (anything it can't show inline is skipped)",
301
303
  `Writes (only inside a new Drive folder "${marker}" at the top of My Drive; every write is checked first):`,
302
304
  " - Docs: create a doc from markdown, append, find and replace (count checked), comment, read back",
303
305
  " - Sheets: create a sheet, write a range, append rows, read back",
@@ -363,6 +365,27 @@ function describeExtraction(out) {
363
365
  if (res?.resource?.blob !== void 0) return `binary, ${res.resource.blob.length} chars (base64)`;
364
366
  return `text, ${blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").split("\n").slice(1).join("\n").length} chars`;
365
367
  }
368
+ /**
369
+ * Parse failures that are the file's own doing: the smoke passes them as "couldn't read (…)". Every other one (corrupt,
370
+ * a parser that's missing, crashed, timed out, busy, …) may be xaq's fault, and this run is what would notice.
371
+ */
372
+ const FILE_OWN_PARSE_FAILURES = /* @__PURE__ */ new Set(["password", "encrypted"]);
373
+ /**
374
+ * A format "inline" answer that didn't show the file (structuredContent.attachment.notShown): its code, the parser's
375
+ * failure code when it couldn't read it, and the reason without a closing period. The reason is xaq's own words (the
376
+ * parser's fixed messages), never the file's content, unless the parser child is subverted (it's cleaned anyway).
377
+ */
378
+ function notShownOf(out) {
379
+ const a = out.structured?.attachment;
380
+ if (!a || typeof a.notShown !== "string" || !a.notShown) return null;
381
+ const reason = typeof a.reason === "string" ? cleanLine(a.reason, 200).replace(/\.$/, "") : "";
382
+ const parseFailure = typeof a.parseFailure === "string" && a.parseFailure ? cleanLine(a.parseFailure, 40) : null;
383
+ return {
384
+ code: cleanLine(a.notShown, 40),
385
+ parseFailure,
386
+ reason: reason || "no reason given"
387
+ };
388
+ }
366
389
  /** A get_attachment answer that saved the file to disk (A2: structuredContent.attachment.savedPath). */
367
390
  function savedPathOf(out) {
368
391
  const a = out.structured?.attachment;
@@ -757,12 +780,13 @@ var SmokeRun = class {
757
780
  });
758
781
  for (const a of attachments.slice(0, 10)) await this.step("get_attachment", `extract "${a.filename}"`, async () => {
759
782
  const kind = inlineKind(a);
760
- if (!kind) return SKIPPED_WOULD_SAVE;
783
+ if (!kind) return SKIPPED_NOT_INLINE;
761
784
  const m = message;
762
785
  const out = await this.rawCall("get_attachment", {
763
786
  account,
764
787
  message_id: m.id,
765
- attachment_id: a.id
788
+ attachment_id: a.id,
789
+ format: "inline"
766
790
  });
767
791
  const saved = savedPathOf(out);
768
792
  if (saved) {
@@ -770,6 +794,12 @@ var SmokeRun = class {
770
794
  throw new Error(`get_attachment saved "${a.filename}" to disk (${saved}) instead of showing it`);
771
795
  }
772
796
  if (out.isError) throw new Error(out.text);
797
+ const not = notShownOf(out);
798
+ if (not?.code === "unreadable") {
799
+ if (not.parseFailure && FILE_OWN_PARSE_FAILURES.has(not.parseFailure)) return `${kind}: couldn't read (${not.reason})`;
800
+ throw new Error(`get_attachment couldn't read "${a.filename}" (${not.parseFailure ?? "no failure code"}: ${not.reason})`);
801
+ }
802
+ if (not) return `${kind}: not shown inline (${not.reason})`;
773
803
  return `${kind}: ${describeExtraction(out)}`;
774
804
  });
775
805
  }
@@ -1222,4 +1252,4 @@ var SmokeRun = class {
1222
1252
  //#endregion
1223
1253
  export { SmokeRun };
1224
1254
 
1225
- //# sourceMappingURL=run-D3Qmg-MB.js.map
1255
+ //# sourceMappingURL=run-zakbVgQR.js.map