glasspane-mcp 1.4.0 → 1.6.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.
@@ -26,7 +26,7 @@ export declare const MCP_PROTOCOL_VERSION: "2025-06-18";
26
26
  export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly string[];
27
27
  export declare const SERVER_INFO: {
28
28
  readonly name: "glasspane-mcp";
29
- readonly version: "1.4.0";
29
+ readonly version: "1.6.0";
30
30
  };
31
31
  export declare const PARSE_ERROR = -32700;
32
32
  export declare const INVALID_REQUEST = -32600;
@@ -1300,6 +1300,12 @@ function sortKeys(value) {
1300
1300
  return value;
1301
1301
  }
1302
1302
 
1303
+ // src/update-state.ts
1304
+ var CA_ROOTS_STATUSES = ["ok", "probe-failed", "empty", "unavailable", "write-unverified"];
1305
+ var KNOWN_CA_ROOTS_STATUSES = new Set(CA_ROOTS_STATUSES);
1306
+ var RUNTIME_STATUSES = ["refreshed", "failed", "kept", "skipped"];
1307
+ var KNOWN_RUNTIME_STATUSES = new Set(RUNTIME_STATUSES);
1308
+
1303
1309
  // src/project-registry.ts
1304
1310
  import fs2 from "node:fs";
1305
1311
  import os2 from "node:os";
@@ -1290,6 +1290,12 @@ function sortKeys(value) {
1290
1290
  return value;
1291
1291
  }
1292
1292
 
1293
+ // src/update-state.ts
1294
+ var CA_ROOTS_STATUSES = ["ok", "probe-failed", "empty", "unavailable", "write-unverified"];
1295
+ var KNOWN_CA_ROOTS_STATUSES = new Set(CA_ROOTS_STATUSES);
1296
+ var RUNTIME_STATUSES = ["refreshed", "failed", "kept", "skipped"];
1297
+ var KNOWN_RUNTIME_STATUSES = new Set(RUNTIME_STATUSES);
1298
+
1293
1299
  // src/project-registry.ts
1294
1300
  import fs2 from "node:fs";
1295
1301
  import os from "node:os";
package/dist/index.js CHANGED
@@ -304,6 +304,167 @@ var KNOWN_STATUSES = /* @__PURE__ */ new Set([
304
304
  "disabled",
305
305
  "installer-pointer-stale"
306
306
  ]);
307
+ var CA_ROOTS_STATUSES = ["ok", "probe-failed", "empty", "unavailable", "write-unverified"];
308
+ var KNOWN_CA_ROOTS_STATUSES = new Set(CA_ROOTS_STATUSES);
309
+ function isCaRootsStatus(value) {
310
+ return KNOWN_CA_ROOTS_STATUSES.has(value);
311
+ }
312
+ var CA_BUNDLE_FILE_NAME = "ca-roots.pem";
313
+ function decodeCaRoots(value, bundlePath) {
314
+ if (value === void 0 || value === null) {
315
+ return { ok: true, ca: { recorded: false, bundlePath } };
316
+ }
317
+ if (typeof value !== "object" || Array.isArray(value)) {
318
+ return {
319
+ ok: false,
320
+ reason: `caRoots is ${Array.isArray(value) ? "an array" : typeof value} instead of the object the exporter writes`
321
+ };
322
+ }
323
+ const record = value;
324
+ const rawStatus = record.status;
325
+ if (typeof rawStatus !== "string" || !isCaRootsStatus(rawStatus)) {
326
+ return {
327
+ ok: false,
328
+ reason: `caRoots.status ${JSON.stringify(rawStatus ?? null)} is not one of ${CA_ROOTS_STATUSES.join(", ")}`
329
+ };
330
+ }
331
+ const rawCerts = record.certs;
332
+ let certs = null;
333
+ if (typeof rawCerts === "number") {
334
+ if (!Number.isInteger(rawCerts) || rawCerts < 0) {
335
+ return { ok: false, reason: `caRoots.certs ${JSON.stringify(rawCerts)} is not a certificate count` };
336
+ }
337
+ certs = rawCerts;
338
+ } else if (rawCerts !== void 0 && rawCerts !== null) {
339
+ return { ok: false, reason: `caRoots.certs is ${typeof rawCerts} instead of a count or null` };
340
+ }
341
+ if (rawStatus === "ok" && (certs === null || certs < 1)) {
342
+ return {
343
+ ok: false,
344
+ reason: `caRoots.status says ok but it records ${JSON.stringify(rawCerts ?? null)} certificates`
345
+ };
346
+ }
347
+ const rawPath = record.path;
348
+ let recordedPath = null;
349
+ if (typeof rawPath === "string") {
350
+ if (rawPath.trim() === "") {
351
+ return { ok: false, reason: "caRoots.path is blank, so the bundle it points at cannot be named" };
352
+ }
353
+ recordedPath = rawPath;
354
+ } else if (rawPath !== void 0 && rawPath !== null) {
355
+ return { ok: false, reason: `caRoots.path is ${typeof rawPath} instead of a path or null` };
356
+ }
357
+ return {
358
+ ok: true,
359
+ ca: {
360
+ recorded: true,
361
+ status: rawStatus,
362
+ certs,
363
+ exportedAt: typeof record.exportedAt === "string" ? record.exportedAt : null,
364
+ detail: typeof record.detail === "string" && record.detail.trim() !== "" ? record.detail : null,
365
+ bundlePath,
366
+ recordedPath
367
+ }
368
+ };
369
+ }
370
+ var RUNTIME_STATUSES = ["refreshed", "failed", "kept", "skipped"];
371
+ var KNOWN_RUNTIME_STATUSES = new Set(RUNTIME_STATUSES);
372
+ function isRuntimeStatus(value) {
373
+ return KNOWN_RUNTIME_STATUSES.has(value);
374
+ }
375
+ var RUNTIME_KEYS = [
376
+ "status",
377
+ "version",
378
+ "cliPath",
379
+ "previousCliPath",
380
+ "agentVerified",
381
+ "at",
382
+ "code",
383
+ "detail"
384
+ ];
385
+ var RUNTIME_VERSION_RE = /^\d+\.\d+\.\d+$/;
386
+ var RUNTIME_CODE_RE = /^[a-z0-9][a-z0-9.-]*$/;
387
+ function runtimeTextSlot(record, field, purpose) {
388
+ const raw = record[field];
389
+ if (raw === void 0 || raw === null) return { ok: true, value: null };
390
+ if (typeof raw !== "string") {
391
+ return { ok: false, reason: `runtime.${field} is ${typeof raw} instead of ${purpose} or null` };
392
+ }
393
+ if (raw.trim() === "") {
394
+ return { ok: false, reason: `runtime.${field} is blank, so ${purpose} cannot be named` };
395
+ }
396
+ return { ok: true, value: raw };
397
+ }
398
+ function decodeRuntime(value) {
399
+ if (value === void 0 || value === null) {
400
+ return { ok: true, runtime: { recorded: false, writtenAsNull: value === null } };
401
+ }
402
+ if (typeof value !== "object" || Array.isArray(value)) {
403
+ return {
404
+ ok: false,
405
+ reason: `runtime is ${Array.isArray(value) ? "an array" : typeof value} instead of the object the updater writes`
406
+ };
407
+ }
408
+ const record = value;
409
+ const stray = Object.keys(record).filter((key) => !RUNTIME_KEYS.includes(key));
410
+ if (stray.length > 0) {
411
+ return {
412
+ ok: false,
413
+ reason: `runtime carries ${stray.map((key) => JSON.stringify(key)).join(", ")}, a key the state schema does not declare (the object is closed with additionalProperties: false)`
414
+ };
415
+ }
416
+ const rawStatus = record.status;
417
+ if (typeof rawStatus !== "string" || !isRuntimeStatus(rawStatus)) {
418
+ return {
419
+ ok: false,
420
+ reason: `runtime.status ${JSON.stringify(rawStatus ?? null)} is not one of ${RUNTIME_STATUSES.join(", ")}`
421
+ };
422
+ }
423
+ const version = runtimeTextSlot(record, "version", "a version");
424
+ if (!version.ok) return version;
425
+ if (version.value !== null && !RUNTIME_VERSION_RE.test(version.value)) {
426
+ return { ok: false, reason: `runtime.version ${JSON.stringify(version.value)} is not a version this shell can name` };
427
+ }
428
+ const cliPath = runtimeTextSlot(record, "cliPath", "the installed copy");
429
+ if (!cliPath.ok) return cliPath;
430
+ const previousCliPath = runtimeTextSlot(record, "previousCliPath", "the copy it came from");
431
+ if (!previousCliPath.ok) return previousCliPath;
432
+ const at = runtimeTextSlot(record, "at", "an instant");
433
+ if (!at.ok) return at;
434
+ const code = runtimeTextSlot(record, "code", "a gate code");
435
+ if (!code.ok) return code;
436
+ if (code.value !== null && !RUNTIME_CODE_RE.test(code.value)) {
437
+ return { ok: false, reason: `runtime.code ${JSON.stringify(code.value)} is not a gate code this shell can quote` };
438
+ }
439
+ const rawAgent = record.agentVerified;
440
+ let agentVerified = null;
441
+ if (typeof rawAgent === "boolean") {
442
+ agentVerified = rawAgent;
443
+ } else if (rawAgent !== void 0 && rawAgent !== null) {
444
+ return { ok: false, reason: `runtime.agentVerified is ${typeof rawAgent} instead of true, false or null` };
445
+ }
446
+ const rawDetail = record.detail;
447
+ return {
448
+ ok: true,
449
+ runtime: {
450
+ recorded: true,
451
+ status: rawStatus,
452
+ version: version.value,
453
+ cliPath: cliPath.value,
454
+ previousCliPath: previousCliPath.value,
455
+ agentVerified,
456
+ at: at.value,
457
+ code: code.value,
458
+ detail: typeof rawDetail === "string" && rawDetail.trim() !== "" ? rawDetail : null
459
+ }
460
+ };
461
+ }
462
+ function runtimeIsUsable(runtime) {
463
+ return runtime.recorded && runtime.status === "refreshed" && runtime.agentVerified === true;
464
+ }
465
+ function stateRootOf(stateFile) {
466
+ return path.dirname(stateFile);
467
+ }
307
468
  function readUpdateState(env = process.env, stateRootOverride) {
308
469
  const stateFile = updateStateFile(env, stateRootOverride);
309
470
  if (stateFile === null) {
@@ -348,6 +509,22 @@ function readUpdateState(env = process.env, stateRootOverride) {
348
509
  summary: `update state: ${stateFile} carries status ${JSON.stringify(parsed.status ?? null)}, which is not a status this shell knows; the updater and this shell disagree about the contract, so freshness is unknown`
349
510
  };
350
511
  }
512
+ const ca = decodeCaRoots(parsed.caRoots, path.join(path.dirname(stateFile), CA_BUNDLE_FILE_NAME));
513
+ if (!ca.ok) {
514
+ return {
515
+ kind: "unreadable",
516
+ stateFile,
517
+ summary: `update state: ${stateFile} carries a CA trust bundle record this shell cannot read (${ca.reason}); whether this install can reach the release endpoint at all is unknown, so nothing below it is trusted either \u2014 re-run the installer or \`node updater/cli.js enable\` to write the record again`
518
+ };
519
+ }
520
+ const runtime = decodeRuntime(parsed.runtime);
521
+ if (!runtime.ok) {
522
+ return {
523
+ kind: "unreadable",
524
+ stateFile,
525
+ summary: `update state: ${stateFile} carries an updater self-update record this shell cannot read (${runtime.reason}); which copy of the updater this machine's daily job will run next is unknown, so nothing below it is trusted either \u2014 re-run the GlassPane installer, or \`node updater/cli.js enable\`, to write the record again`
526
+ };
527
+ }
351
528
  const bits = [
352
529
  `status=${status}`,
353
530
  `current=${String(parsed.current ?? "unknown")}`,
@@ -363,7 +540,7 @@ function readUpdateState(env = process.env, stateRootOverride) {
363
540
  if (staged && typeof staged.version === "string") {
364
541
  bits.push(`staged=${staged.version}${staged.digest ? ` (${String(staged.digest).slice(0, 12)}\u2026)` : ""}`);
365
542
  }
366
- return { kind: "read", stateFile, summary: `update state: ${bits.join("; ")}` };
543
+ return { kind: "read", stateFile, summary: `update state: ${bits.join("; ")}`, ca: ca.ca, runtime: runtime.runtime };
367
544
  }
368
545
 
369
546
  // src/io.ts
@@ -2838,7 +3015,13 @@ async function runValidatedTool(spec, value, engine, session, turn) {
2838
3015
  }
2839
3016
  const content = [{ type: "text", text: canonicalJson(raw) }];
2840
3017
  if (spec.name === "gp_diagnose") {
2841
- content.push({ type: "text", text: readUpdateState().summary });
3018
+ const reading = readUpdateState();
3019
+ content.push({
3020
+ type: "text",
3021
+ text: reading.kind === "read" ? `${reading.summary}
3022
+ ${caBundleDiagnosis(reading.ca)}
3023
+ ${runtimeSelfDiagnosis(reading.runtime, reading.stateFile)}` : reading.summary
3024
+ });
2842
3025
  }
2843
3026
  return {
2844
3027
  content,
@@ -2865,6 +3048,88 @@ async function runValidatedTool(spec, value, engine, session, turn) {
2865
3048
  };
2866
3049
  }
2867
3050
  }
3051
+ function caBundleDiagnosis(ca) {
3052
+ const enableCmd = "`node updater/cli.js enable`";
3053
+ if (!ca.recorded) {
3054
+ return `ca trust bundle: this install has never recorded exporting the machine's trusted roots (the state file carries no caRoots), which is NOT the same as "the bundle is fine" \u2014 an install predating the export step looks exactly like this. node then verifies only against the CA list inside its own binary, so on a machine whose HTTPS is re-signed locally every auto-update run fails certificate verification and nothing on the surface says so. Remedy: re-run the GlassPane installer, or ${enableCmd}, either of which writes the roots to ${ca.bundlePath} and points the launchd agent and the panel at it. For a check run by hand in a terminal: export NODE_EXTRA_CA_CERTS=${ca.bundlePath} \u2014 but only once that file exists, and set before node starts, because a running process never picks the variable up.`;
3055
+ }
3056
+ const target = ca.recordedPath ?? ca.bundlePath;
3057
+ const countClause = ca.certs === null ? "an unrecorded number of certificates" : `${ca.certs} certificates`;
3058
+ let diagnosis;
3059
+ switch (ca.status) {
3060
+ case "ok":
3061
+ diagnosis = `ca trust bundle: ok \u2014 node will use this exported root bundle (${countClause} at ${target}) for the updater's HTTPS, so a locally re-signed certificate chain on this machine does not stop a check. Nothing to do; the bundle is refreshed by the installer, by ${enableCmd}, and after every successful apply.`;
3062
+ break;
3063
+ case "probe-failed":
3064
+ diagnosis = `ca trust bundle: probe-failed \u2014 ${countClause} were written to ${target}, and reaching the release endpoint through them still failed. This is the machine-still-intercepted case: the exported bundle does not contain the root that re-signs this network's TLS, so the scheduled updater is dead on every run exactly as it was before the bundle existed. Remedy: get that root into the System keychain (/Library/Keychains/System.keychain), then refresh the export with ${enableCmd} or a re-run of the installer. Pointing NODE_EXTRA_CA_CERTS at ${target} changes nothing while the signing root is missing.`;
3065
+ break;
3066
+ case "empty": {
3067
+ const kept = ca.recordedPath !== null;
3068
+ diagnosis = `ca trust bundle: empty \u2014 "security find-certificate" ran and produced ${countClause}, so this export published nothing new` + (kept ? `; the bundle already at ${target} was kept and is still what the agent and the panel hand to node, so a check can pass today while this machine's current roots go unrecorded.` : `; nothing is at ${target} either, so on a machine whose HTTPS is re-signed locally every check fails certificate verification from here.`) + ` Remedy: check that both keychains are readable (\`security dump-keychain\` lists them), then re-run the GlassPane installer or ${enableCmd} to export again.`;
3069
+ break;
3070
+ }
3071
+ case "unavailable":
3072
+ diagnosis = `ca trust bundle: unavailable \u2014 the \`security\` tool was not found on this host, so this machine's trusted roots could not be read and no bundle was written or refreshed at ${target}. Unless something else already put a bundle there, the updater carries only node's built-in list, so on a re-signing network it cannot reach a release at all. Remedy: place a root bundle you trust at a path of your choosing and give that same path to both consumers \u2014 \`export NODE_EXTRA_CA_CERTS=<that path>\` for runs from this terminal, the launchd environment for the scheduled one \u2014 then re-run the GlassPane installer so the agent and the panel record it.`;
3073
+ break;
3074
+ case "write-unverified":
3075
+ diagnosis = `ca trust bundle: write-unverified \u2014 the bytes read back from ${target} were not the bytes the exporter wrote, and the export counted ${countClause}, so whatever sits at that path is not the bundle this install believes in. Verification can fail at any point, including after a partial write. Remedy: re-run the GlassPane installer or ${enableCmd}, which exports again and reads the file back; if it repeats, ${target} sits on a volume that will not hold a private regular file, and auto-update must stay off until that is fixed.`;
3076
+ break;
3077
+ default: {
3078
+ const unhandled = ca.status;
3079
+ diagnosis = `ca trust bundle: the state file records ${JSON.stringify(unhandled)}, a state this shell has no sentence for; treat the CA trust bundle as unverified and re-run the GlassPane installer`;
3080
+ }
3081
+ }
3082
+ const provenance = [
3083
+ ca.exportedAt === null ? null : `exported ${ca.exportedAt}`,
3084
+ ca.detail === null ? null : `recorded reason: ${ca.detail}`
3085
+ ].filter((part) => part !== null);
3086
+ return provenance.length === 0 ? diagnosis : `${diagnosis} The record itself says: ${provenance.join("; ")}.`;
3087
+ }
3088
+ function runtimeSelfDiagnosis(runtime, stateFile) {
3089
+ const installerCmd = "`node installer/cli.js` (or the `install.sh` this machine was installed with)";
3090
+ const enableCmd = (cliPath) => cliPath === null ? null : `node ${cliPath} enable --state-dir ${stateRootOf(stateFile)} --json`;
3091
+ const copy = (version, cliPath) => {
3092
+ if (cliPath === null) {
3093
+ return version === null ? "the record names neither a version nor a path for it" : `version ${version}, and the record names no path for it`;
3094
+ }
3095
+ return version === null ? `${cliPath} (no version recorded)` : `${version} at ${cliPath}`;
3096
+ };
3097
+ if (!runtime.recorded) {
3098
+ return `updater self-update: this install has never recorded moving the updater's own copy \u2014 the state file ${runtime.writtenAsNull ? 'carries "runtime": null' : 'carries no "runtime" record at all'}, which is NOT the same as "the installed updater is current". An install that predates the self-swap looks exactly like this, and its daily job still runs the copy the installer first cloned: a release whose content is "fix the updater" can never reach this machine by itself. Remedy: re-run the GlassPane installer once, ${installerCmd}. That is a manual step this install needs before self-updating is possible; after it, a successful apply records this line as refreshed.`;
3099
+ }
3100
+ const location = copy(runtime.version, runtime.cliPath);
3101
+ let diagnosis;
3102
+ switch (runtime.status) {
3103
+ case "refreshed": {
3104
+ if (!runtimeIsUsable(runtime)) {
3105
+ diagnosis = `updater self-update: refreshed, but not confirmed \u2014 the record claims the updater's own copy (${location}) is installed, yet the loaded launchd job was never read back going through that entry, so this is not evidence the new copy runs: tomorrow's check may still execute the previous one. Remedy: ` + (enableCmd(runtime.cliPath) === null ? `the record names no script to register, so re-run the GlassPane installer, ${installerCmd}` : `have that copy register itself and read the job back with \`${enableCmd(runtime.cliPath)}\`, or re-run the GlassPane installer, ${installerCmd}`) + ". Until this line says refreshed with the job read back, do not treat a release that fixes the updater as delivered here.";
3106
+ break;
3107
+ }
3108
+ diagnosis = `updater self-update: refreshed \u2014 the updater's own installed copy (${location}) is in place and the loaded launchd job was read back and seen to go through the stable entry that resolves this pointer, so this machine's daily check runs the same code the last release shipped. Nothing to do: a future release that changes the updater reaches this install without anyone reinstalling.`;
3109
+ break;
3110
+ }
3111
+ case "failed":
3112
+ diagnosis = `updater self-update: failed \u2014 the update itself installed (the .app bundles and the npm packages did change), but the updater's own copy did not move: the copy this swap tried to install (${location}) is not what the job runs, and the previous copy still does. This is NOT "the update failed", and it is not harmless either: the scheduled job keeps running the previous updater with that version's gates, so a release that fixes the updater cannot land here while the line above still reads up-to-date. Remedy: re-run the GlassPane installer, ${installerCmd}, which rewrites the installer pointer and puts a current copy in place` + (enableCmd(runtime.cliPath) === null ? " \u2014 this record names no new copy at all, so the installer is the only way out" : `; if the tree already on disk is the one you want running, \`${enableCmd(runtime.cliPath)}\` registers that copy directly`) + ". Do not count the next check as running new code until this line says refreshed.";
3113
+ break;
3114
+ case "kept":
3115
+ diagnosis = `updater self-update: kept \u2014 automatic update is switched off on this machine, so the updater's code and the installer pointer moved (${location}) but no launchd job was registered. This is the switch doing what it was set to, not a failure, and not a self-updating install either: nothing will move here by itself, so the settings panel and a hand-run check are the only ways this copy advances. Remedy: switch automatic ` + (enableCmd(runtime.cliPath) === null ? `update back on in the settings panel, or re-run the GlassPane installer, ${installerCmd}, which registers the job as it installs` : `update back on in the settings panel, or let that copy register itself with \`${enableCmd(runtime.cliPath)}\`; the installer, ${installerCmd}, does the same as it installs`) + ".";
3116
+ break;
3117
+ case "skipped":
3118
+ diagnosis = "updater self-update: skipped \u2014 the updater's code, the installer pointer and the stable agent entry all moved, but the registered launchd job still names a versioned script instead of that entry, so this machine owes one migration. Every scheduled apply stops here until it is done, because re-registering from inside the daily job would unregister the job that is running it" + (runtime.cliPath === null ? "" : `; the copy that would run next is ${location}`) + `. So treat the updater as whatever the loaded job actually runs, rather than as current. Remedy (one time): switch automatic update off and on again in the settings panel, or run \`updater enable\` from a terminal \u2014 after that a version move is a single file write and no future release will ask for this step; if a machine has only ever shown skipped, re-run the GlassPane installer, ${installerCmd}, once to put a self-updating copy down.`;
3119
+ break;
3120
+ default: {
3121
+ const unhandled = runtime.status;
3122
+ diagnosis = `updater self-update: the state file records ${JSON.stringify(unhandled)}, a state this shell has no sentence for; treat the updater's own copy as unverified and re-run the GlassPane installer`;
3123
+ }
3124
+ }
3125
+ const provenance = [
3126
+ runtime.at === null ? null : `recorded at ${runtime.at}`,
3127
+ runtime.code === null ? null : `gate code: ${runtime.code}`,
3128
+ runtime.previousCliPath === null ? null : `previous copy: ${runtime.previousCliPath}`,
3129
+ runtime.detail === null ? null : `recorded reason: ${runtime.detail}`
3130
+ ].filter((part) => part !== null);
3131
+ return provenance.length === 0 ? diagnosis : `${diagnosis} The record itself says: ${provenance.join("; ")}.`;
3132
+ }
2868
3133
  var EvidenceFrameShapeError = class extends Error {
2869
3134
  constructor(detail) {
2870
3135
  super(`engine returned a last_evidence frame without an evidencePack object: ${detail}`);
@@ -3325,7 +3590,7 @@ var SUPPORTED_PROTOCOL_VERSIONS = [
3325
3590
  "2025-03-26",
3326
3591
  MCP_PROTOCOL_VERSION
3327
3592
  ];
3328
- var SERVER_INFO = { name: "glasspane-mcp", version: "1.4.0" };
3593
+ var SERVER_INFO = { name: "glasspane-mcp", version: "1.6.0" };
3329
3594
  var PARSE_ERROR = -32700;
3330
3595
  var INVALID_REQUEST = -32600;
3331
3596
  var METHOD_NOT_FOUND = -32601;
@@ -11,11 +11,101 @@ export declare const STATE_DIR_ENV = "GLASSPANE_STATE_DIR";
11
11
  export declare function recordHome(): string | null;
12
12
  /** Where `update-state.json` lives for this process, or null when it cannot be named. */
13
13
  export declare function updateStateFile(env?: NodeJS.ProcessEnv, stateRootOverride?: string): string | null;
14
- export interface UpdateStateReading {
15
- kind: "read" | "absent" | "unreadable" | "unresolvable";
14
+ export type UpdateStateReading = {
15
+ kind: "read";
16
+ stateFile: string;
17
+ summary: string;
18
+ /** Decoded `caRoots`, present exactly when the file was read and understood. */
19
+ ca: CaRootsReading;
20
+ /** Decoded `runtime`, present exactly when the file was read and understood. */
21
+ runtime: RuntimeReading;
22
+ } | {
23
+ kind: "absent" | "unreadable" | "unresolvable";
16
24
  stateFile: string | null;
17
25
  summary: string;
18
- }
26
+ };
27
+ /**
28
+ * The five states the exporter can record for the machine's root bundle, as a closed
29
+ * set: `ok` (a non-empty bundle landed and a probe through it reached the release
30
+ * endpoint), `probe-failed` (bundle landed, endpoint still unreachable with it),
31
+ * `empty` (the keychain dump produced no certificates), `unavailable` (no keychain
32
+ * tool on this host), `write-unverified` (the file read back different from what was
33
+ * written). Only `ok` means usable; the other four each fail in their own way, so
34
+ * collapsing any of them into `ok` hands an agent a false all-clear.
35
+ */
36
+ export declare const CA_ROOTS_STATUSES: readonly ["ok", "probe-failed", "empty", "unavailable", "write-unverified"];
37
+ export type CaRootsStatus = (typeof CA_ROOTS_STATUSES)[number];
38
+ /** Name the exporter gives the bundle inside the state root; used to name it when the record does not. */
39
+ export declare const CA_BUNDLE_FILE_NAME = "ca-roots.pem";
40
+ export type CaRootsReading = {
41
+ recorded: true;
42
+ status: CaRootsStatus;
43
+ /** Certificate count as recorded; `null` only for the non-`ok` states, which may not have one. */
44
+ certs: number | null;
45
+ exportedAt: string | null;
46
+ /** The exporter's own reason text, when it wrote one. */
47
+ detail: string | null;
48
+ /** Where this state root's bundle lives, resolved the same way the state file was. */
49
+ bundlePath: string;
50
+ /** The path the exporter recorded, when it recorded one. */
51
+ recordedPath: string | null;
52
+ } | {
53
+ recorded: false;
54
+ bundlePath: string;
55
+ };
56
+ /**
57
+ * The four states the updater can record for **its own installed copy**, as a closed set:
58
+ * `refreshed` (the new copy landed, the pointer names it, and the loaded launchd job was read back and
59
+ * seen to go through the stable agent entry that resolves that pointer — with the entry in place the job
60
+ * no longer names a version, so `agentVerified` is a claim about the entry, not about this version),
61
+ * `failed` (the updater's own copy did not move — the `.app`/npm swap may still have
62
+ * succeeded, so this is a stated degradation and emphatically not "the update failed"),
63
+ * `kept` (automatic update is switched off on this machine, so the code and the pointer
64
+ * moved but no job was registered: neither a failure nor something to brag about),
65
+ * `skipped` (the code, the pointer and the entry all moved, but the registered job still names a
66
+ * versioned script, so this machine owes the one migration that puts it on the entry — after which no
67
+ * version asks again). Only `refreshed` with `agentVerified === true` is
68
+ * usable; folding any other state into it is how a machine that has run the same stale
69
+ * updater since install reads as one that fixes itself.
70
+ */
71
+ export declare const RUNTIME_STATUSES: readonly ["refreshed", "failed", "kept", "skipped"];
72
+ export type RuntimeStatus = (typeof RUNTIME_STATUSES)[number];
73
+ export type RuntimeReading = {
74
+ recorded: true;
75
+ status: RuntimeStatus;
76
+ /** Version the updater's own copy was moved to; `null` when the record did not say. */
77
+ version: string | null;
78
+ /** The installed copy this record is about — a real path, never a template. */
79
+ cliPath: string | null;
80
+ /** The copy it came from, when the record kept one (what a rollback points back at). */
81
+ previousCliPath: string | null;
82
+ /** `true` only after the loaded job was read back and names `cliPath`. */
83
+ agentVerified: boolean | null;
84
+ at: string | null;
85
+ /** Stable code of the gate that refused (`runtime-stale`, `auto-disabled`, …), or null. */
86
+ code: string | null;
87
+ /** The updater's own reason text, when it wrote one. */
88
+ detail: string | null;
89
+ } | {
90
+ recorded: false;
91
+ /**
92
+ * Which route left nothing recorded: `true` is a file that carries `runtime: null`,
93
+ * `false` is a file with no such key at all — the shape an install predating the
94
+ * feature has. Both mean "this swap never tried", and they stay apart because a
95
+ * reader says them differently.
96
+ */
97
+ writtenAsNull: boolean;
98
+ };
99
+ /**
100
+ * Does this record say the updater's own copy is current? Only `refreshed` *with*
101
+ * `agentVerified === true` qualifies — that pair is the one moment where the code on disk
102
+ * and the code launchd has loaded were both looked at. `kept` is deliberately not usable:
103
+ * the machine is switched off for automatic updates, so nothing was registered and nothing
104
+ * will move by itself.
105
+ */
106
+ export declare function runtimeIsUsable(runtime: RuntimeReading): boolean;
107
+ /** The state root a state file lives in: one author, so a remedy command never names a directory that is not the root. */
108
+ export declare function stateRootOf(stateFile: string): string;
19
109
  /**
20
110
  * Read and render. Never throws: `gp_diagnose` answers about an operation, and a
21
111
  * missing or broken update file is a fact about the machine, not a reason to fail
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "GlassPane MCP stdio shell: JSON-RPC 2.0 over stdio bridging an AI agent to the glasspane engine daemon",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",