glasspane-mcp 1.20.2 → 1.21.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/dist/dispatch.d.ts +1 -1
- package/dist/errors.d.ts +10 -0
- package/dist/http-gateway-cli.js +216 -32
- package/dist/http-gateway.js +216 -32
- package/dist/index.js +139 -22
- package/dist/project-registry.d.ts +23 -2
- package/dist/stdio-session.js +139 -22
- package/dist/tools.d.ts +56 -0
- package/package.json +1 -1
package/dist/dispatch.d.ts
CHANGED
|
@@ -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.
|
|
29
|
+
readonly version: "1.21.0";
|
|
30
30
|
};
|
|
31
31
|
export declare const PARSE_ERROR = -32700;
|
|
32
32
|
export declare const INVALID_REQUEST = -32600;
|
package/dist/errors.d.ts
CHANGED
|
@@ -76,6 +76,16 @@ export declare const GP_E_NO_USER_RECORD = "GP_E_NO_USER_RECORD";
|
|
|
76
76
|
* the filesystem root, the account's home directory itself, or a value that is not
|
|
77
77
|
* an absolute path at all.
|
|
78
78
|
*
|
|
79
|
+
* Since 1.20.1 this code also answers the *other* un-checked root in this shell: a
|
|
80
|
+
* `GLASSPANE_PROJECTS_FILE` override that names a directory `gp_project_set` would
|
|
81
|
+
* otherwise create and bring to 0700 before publishing the registry into it
|
|
82
|
+
* (`project-registry.ts`'s `overrideProjectsPath`). Same authority, same consequence, and
|
|
83
|
+
* no new vocabulary: both are "a root this process was told to write into, judged and
|
|
84
|
+
* refused before the first syscall". The two differ in who said it — one is the service's
|
|
85
|
+
* self-report, the other this process's own environment — and each frame carries its own
|
|
86
|
+
* remedy (`ProjectRegistryError.remedy`) so the reader is never told to restart the daemon
|
|
87
|
+
* for a variable it exported, or to edit a file for a refusal that names a directory.
|
|
88
|
+
*
|
|
79
89
|
* The daemon refuses the same shapes for its own `--state-dir` (`StateRoot.classify`
|
|
80
90
|
* → `StateRoot.notADedicatedRoot`) and then `tightenPermissions` that root to 0700,
|
|
81
91
|
* so the argument parse is the only thing standing between `--state-dir ~` and a
|
package/dist/http-gateway-cli.js
CHANGED
|
@@ -378,8 +378,12 @@ function clientClosingRemedy(surface) {
|
|
|
378
378
|
const readRoute = readInsteadRoute(surface);
|
|
379
379
|
return `this shell's own engine client is ending, which measures nothing about the daemon: nothing here authorises touching the daemon's lifecycle, and no restart is authorised by this answer. What is unknown stays unknown: whether the daemon finished this request cannot be told from a client that stopped asking. The reply may still land, under the bound this shell already states \u2014 ${lateReplyRoute(surface)} \u2014 and this shutdown is one of the endings that bound names: the server log line written at this moment lists the requests this client stopped tracking. Read what happened rather than performing it a second time: ${readRoute}. Ask through a server process that still has a live engine client.`;
|
|
380
380
|
}
|
|
381
|
-
|
|
381
|
+
var NEVER_LEFT_TRANSPORT = "the frame this request wrote never left this process \u2014 its bytes were still in this client's own write queue when the transport went, so nothing reached the daemon and nothing was performed there";
|
|
382
|
+
function transportDeathRemedy(method, surface, handedOff) {
|
|
382
383
|
const restore = daemonRestoreCommand();
|
|
384
|
+
if (!handedOff) {
|
|
385
|
+
return daemonUnreachableRemedy();
|
|
386
|
+
}
|
|
383
387
|
const unsafe = REPLAY_UNSAFE_REASONS[method];
|
|
384
388
|
if (unsafe !== void 0) {
|
|
385
389
|
return `${restore}. Do NOT re-issue '${method}' on the strength of this answer: this request had already left this shell when its transport died, and whether the daemon carried it out before that cannot be told from here \u2014 a re-issue can perform it a second time, because ${unsafe}. Read what happened instead of performing it a second time: ${readInsteadRoute(surface)}.`;
|
|
@@ -433,10 +437,23 @@ var EngineJsonRpcClient = class {
|
|
|
433
437
|
this.failOversizedReply(error);
|
|
434
438
|
return;
|
|
435
439
|
}
|
|
436
|
-
this.teardown(
|
|
440
|
+
this.teardown(
|
|
441
|
+
`engine transport error: ${error.message}`,
|
|
442
|
+
(method, handedOff) => transportDeathRemedy(method, this.surface, handedOff),
|
|
443
|
+
NEVER_LEFT_TRANSPORT
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
this.io.onClose(() => this.teardown(
|
|
447
|
+
"engine transport closed",
|
|
448
|
+
(method, handedOff) => transportDeathRemedy(method, this.surface, handedOff),
|
|
449
|
+
NEVER_LEFT_TRANSPORT
|
|
450
|
+
));
|
|
451
|
+
this.io.onOpen?.(() => {
|
|
452
|
+
for (const entry of this.pending.values()) {
|
|
453
|
+
entry.handedOff = true;
|
|
454
|
+
}
|
|
455
|
+
this.handshake();
|
|
437
456
|
});
|
|
438
|
-
this.io.onClose(() => this.teardown("engine transport closed", (method) => transportDeathRemedy(method, this.surface)));
|
|
439
|
-
this.io.onOpen?.(() => this.handshake());
|
|
440
457
|
}
|
|
441
458
|
io;
|
|
442
459
|
timeoutMs;
|
|
@@ -548,7 +565,7 @@ var EngineJsonRpcClient = class {
|
|
|
548
565
|
id,
|
|
549
566
|
method,
|
|
550
567
|
correlation,
|
|
551
|
-
|
|
568
|
+
handedOff: true,
|
|
552
569
|
deadlineMs,
|
|
553
570
|
methodDeadlineMs,
|
|
554
571
|
timer: null,
|
|
@@ -568,8 +585,14 @@ var EngineJsonRpcClient = class {
|
|
|
568
585
|
return;
|
|
569
586
|
}
|
|
570
587
|
this.pending.set(id, entry);
|
|
588
|
+
const queuedBefore = this.io.bufferedBytes?.() ?? null;
|
|
571
589
|
try {
|
|
572
|
-
|
|
590
|
+
const frameText = JSON.stringify(frame);
|
|
591
|
+
this.io.writeLine(frameText);
|
|
592
|
+
if (queuedBefore !== null) {
|
|
593
|
+
const queuedAfter = this.io.bufferedBytes?.() ?? 0;
|
|
594
|
+
entry.handedOff = queuedAfter < queuedBefore + frameText.length + 1;
|
|
595
|
+
}
|
|
573
596
|
} catch (error) {
|
|
574
597
|
this.pending.delete(id);
|
|
575
598
|
this.reject(entry, new EngineCallError(
|
|
@@ -844,13 +867,14 @@ var EngineJsonRpcClient = class {
|
|
|
844
867
|
* and one remedy for the whole batch is how "…, then retry" reached an
|
|
845
868
|
* outstanding `act` at all. See {@link transportDeathRemedy}.
|
|
846
869
|
*/
|
|
847
|
-
teardown(message, remedyFor) {
|
|
870
|
+
teardown(message, remedyFor, neverLeftNote = null) {
|
|
848
871
|
const entries = [...this.pending.values()];
|
|
849
872
|
const tracked = entries.filter((entry) => entry.settled);
|
|
850
873
|
this.pending.clear();
|
|
851
874
|
for (const entry of entries) {
|
|
852
875
|
if (!entry.settled) {
|
|
853
|
-
|
|
876
|
+
const said = entry.handedOff || neverLeftNote === null ? message : `${message}; ${neverLeftNote}`;
|
|
877
|
+
this.reject(entry, new EngineCallError(GP_E_ENGINE_UNREACHABLE, said, remedyFor(entry.method, entry.handedOff)));
|
|
854
878
|
} else if (entry.timer !== null) {
|
|
855
879
|
clearTimeout(entry.timer);
|
|
856
880
|
entry.timer = null;
|
|
@@ -893,6 +917,10 @@ var EngineJsonRpcClient = class {
|
|
|
893
917
|
if (version !== "") {
|
|
894
918
|
this.helloVersion = version;
|
|
895
919
|
}
|
|
920
|
+
const reportedRoot = typeof report.stateRoot === "string" ? report.stateRoot.trim() : "";
|
|
921
|
+
if (reportedRoot !== "") {
|
|
922
|
+
this.helloStateRoot = reportedRoot;
|
|
923
|
+
}
|
|
896
924
|
const protocol = typeof report.protocolVersion === "string" ? report.protocolVersion : null;
|
|
897
925
|
if (protocol === null) {
|
|
898
926
|
this.report(`engine self-report has no protocolVersion (engine=${jsonOrString(report.engine)}); expected '${expected}' \u2014 compatibility unverified`);
|
|
@@ -903,10 +931,6 @@ var EngineJsonRpcClient = class {
|
|
|
903
931
|
return;
|
|
904
932
|
}
|
|
905
933
|
const reportedVersion = version === "" ? null : version;
|
|
906
|
-
const reportedRoot = typeof report.stateRoot === "string" ? report.stateRoot.trim() : "";
|
|
907
|
-
if (reportedRoot !== "") {
|
|
908
|
-
this.helloStateRoot = reportedRoot;
|
|
909
|
-
}
|
|
910
934
|
const wantVersion = this.identity?.version;
|
|
911
935
|
if (reportedVersion !== null && wantVersion !== void 0 && reportedVersion !== wantVersion) {
|
|
912
936
|
this.report(`version mismatch: engine self-reports '${reportedVersion}', this MCP shell ships '${wantVersion}' (single version line) \u2014 one side is stale`);
|
|
@@ -2151,9 +2175,52 @@ var ProjectSetArgs = z9.strictObject({
|
|
|
2151
2175
|
});
|
|
2152
2176
|
function projectsPath(daemonStateRoot) {
|
|
2153
2177
|
const envPath = process.env[PROJECTS_FILE_ENV];
|
|
2154
|
-
if (envPath) return envPath;
|
|
2178
|
+
if (envPath) return overrideProjectsPath(envPath);
|
|
2155
2179
|
return daemonProjectsPath(daemonStateRoot);
|
|
2156
2180
|
}
|
|
2181
|
+
function homeDefaultStateRoot() {
|
|
2182
|
+
return stripFirmlink(path3.posix.normalize(systemHome() + "/.glasspane"));
|
|
2183
|
+
}
|
|
2184
|
+
function overrideProjectsPath(raw) {
|
|
2185
|
+
const trimmed = raw.trim();
|
|
2186
|
+
const normalized = foldSlashTerminators(stripFirmlink(path3.posix.normalize(trimmed)));
|
|
2187
|
+
if (!normalized.startsWith("/")) {
|
|
2188
|
+
throw refusedProjectsFileOverride(
|
|
2189
|
+
trimmed,
|
|
2190
|
+
`is not an absolute path (${trimmed}), so which projects file this write reaches moves with the working directory of whoever started this MCP server`
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
for (const component of normalized.split("/")) {
|
|
2194
|
+
if (component === "." || component === "..") {
|
|
2195
|
+
throw refusedProjectsFileOverride(
|
|
2196
|
+
trimmed,
|
|
2197
|
+
`carries a "." or ".." component (${trimmed}), so the file it names is not the file this call was asked to write`
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
const directory = path3.dirname(normalized);
|
|
2202
|
+
if (directory === homeDefaultStateRoot()) {
|
|
2203
|
+
return normalized;
|
|
2204
|
+
}
|
|
2205
|
+
const shape = stateRootRefusal(directory);
|
|
2206
|
+
if (shape !== null) {
|
|
2207
|
+
throw refusedProjectsFileOverride(trimmed, `its directory ${shape} (${directory})`);
|
|
2208
|
+
}
|
|
2209
|
+
const resolved = stripFirmlink(resolveExistingAncestor(directory));
|
|
2210
|
+
const refusal = strongestRefusal(resolved === directory ? [directory] : [directory, resolved], resolved);
|
|
2211
|
+
if (refusal !== null) {
|
|
2212
|
+
throw refusedProjectsFileOverride(trimmed, `its directory ${refusalPhrase(refusal, resolved)}`);
|
|
2213
|
+
}
|
|
2214
|
+
return normalized;
|
|
2215
|
+
}
|
|
2216
|
+
function refusedProjectsFileOverride(raw, defect) {
|
|
2217
|
+
const defaultFile = `${homeDefaultStateRoot()}/projects.json`;
|
|
2218
|
+
return new ProjectRegistryError(
|
|
2219
|
+
GP_E_STATE_ROOT_REFUSED,
|
|
2220
|
+
`the ${PROJECTS_FILE_ENV} override this shell was started with ${defect}, so this shell refuses to use it: ${quoteShellArg(raw)}. This is the override slot of **this process**, not a root the background service reported about itself \u2014 the daemon never reads ${PROJECTS_FILE_ENV} (R7-14), so the value being judged is one an environment, a wrapper, or an agent set for the MCP shell alone. It is not a harmless pointer either: \`gp_project_set\` would have created that directory and re-permissioned it to 0700, then written the project registry \u2014 bundle ids, pids and storage paths \u2014 into it, which is the same authority this module refuses a self-reported root over (\`StateRoot.notADedicatedRoot\`) and refuses an agent-named evidence archive over (\`refuseCandidate\`). Nothing was read and nothing was written by this call: no directory was created, no permission was changed, no projects.json was opened, listed or saved.`,
|
|
2221
|
+
`this shell's own override is what is configured, not your arguments and not a damaged file: \`unset ${PROJECTS_FILE_ENV}\` for this MCP server process and repeat gp_project_set \u2014 it then writes the projects file of the root the running daemon named in its hello, or the home default \`${quoteShellArg(defaultFile)}\` when it named none. Alternatively point the override at one of those two paths; do not point it at a tree this shell will not create or re-permission, and do not repair a projects file for this refusal \u2014 the refusal names a directory, and no path under it was touched. If the service really is running on a root this shell cannot accept, move the service instead: \`glasspaned --state-dir <a directory this install owns>\`, then reconnect so a fresh hello reports it`
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2157
2224
|
function loadRegistry(filePath) {
|
|
2158
2225
|
let bytes;
|
|
2159
2226
|
try {
|
|
@@ -2353,13 +2420,22 @@ function saveProjects(filePath, entries) {
|
|
|
2353
2420
|
if (error instanceof ProjectRegistryError) {
|
|
2354
2421
|
throw new ProjectRegistryError(
|
|
2355
2422
|
error.code,
|
|
2356
|
-
`${error.message}; projects.json at ${filePath} was therefore not published as owner-only, and gp_project_set refuses to report a write the daemon's own modes did not hold. Bring the state root to them yourself (chmod 700 ${quoteShellArg(dir)}, chmod 600 ${quoteShellArg(filePath)}) and retry \u2014 a volume that ignores chmod (read-only mount, ACL, immutable flag) needs the daemon's --state-dir moved to one that honours permissions; until then the registry on disk and this shell disagree about who can read it
|
|
2423
|
+
`${error.message}; projects.json at ${filePath} was therefore not published as owner-only, and gp_project_set refuses to report a write the daemon's own modes did not hold. Bring the state root to them yourself (chmod 700 ${quoteShellArg(dir)}, chmod 600 ${quoteShellArg(filePath)}) and retry \u2014 a volume that ignores chmod (read-only mount, ACL, immutable flag) needs the daemon's --state-dir moved to one that honours permissions; until then the registry on disk and this shell disagree about who can read it`,
|
|
2424
|
+
// The refusal above already names the two commands that make the modes true, so the
|
|
2425
|
+
// remedy transcribes them instead of sending the reader to repair a file nobody
|
|
2426
|
+
// called damaged (B5). Nothing here is a judgement about the registry's contents.
|
|
2427
|
+
`the mode this shell asked for did not stick, so nothing was published: bring it to the daemon's own modes yourself \u2014 \`chmod 700 ${quoteShellArg(dir)}\` and \`chmod 600 ${quoteShellArg(filePath)}\` \u2014 read them back with \`stat -f '%Sp %N' ${quoteShellArg(dir)} ${quoteShellArg(filePath)}\`, and repeat gp_project_set once they say 700 and 600. If chmod answers success and the mode stays loose, the volume ignores permissions: find out with \`ls -lO ${quoteShellArg(dir)}\` (an immutable flag) and \`diskutil info -live ${quoteShellArg(dir)}\` (a read-only or remote mount), and move the service onto a volume that honours them \`glasspaned --state-dir <a directory this install owns>\`). Do not repair or overwrite projects.json for this one, and do not set ${FORCE_OVERWRITE_ENV}: the fault is the mode, not the bytes in the file`
|
|
2357
2428
|
);
|
|
2358
2429
|
}
|
|
2359
2430
|
const errno = error?.code ?? "unknown";
|
|
2360
2431
|
throw new ProjectRegistryError(
|
|
2361
2432
|
GP_E_INTERNAL,
|
|
2362
|
-
`projects.json at ${filePath} was not published: committing the bytes to the device failed (${errno}). gp_project_set answers "not saved" here rather than "saved", because a registration a power loss can drop is not a registration. This is the write's own failure, not a verdict about the file's contents: check what the volume will do (\`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount), fix that, then retry the same call
|
|
2433
|
+
`projects.json at ${filePath} was not published: committing the bytes to the device failed (${errno}). gp_project_set answers "not saved" here rather than "saved", because a registration a power loss can drop is not a registration. This is the write's own failure, not a verdict about the file's contents: check what the volume will do (\`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount), fix that, then retry the same call.`,
|
|
2434
|
+
// Carried by the error rather than derived from `GP_E_INTERNAL` in `tools.ts` (B5):
|
|
2435
|
+
// that code's arm reads "the projects file is the problem … repair or restore the
|
|
2436
|
+
// damaged entry", which is the opposite of the sentence above and would send an agent
|
|
2437
|
+
// to edit a registry that this call never failed to decode.
|
|
2438
|
+
`nothing was published by this call, and the file's contents are not in question: check what the volume will do \u2014 \`ls -lO ${quoteShellArg(dir)}\` for an immutable flag, \`diskutil info -live ${quoteShellArg(dir)}\` for a read-only or remote mount, \`df -h ${quoteShellArg(dir)}\` for a full one \u2014 fix that, and repeat the same gp_project_set once the volume commits. Do not repair or restore projects.json for this fault, and do not set ${FORCE_OVERWRITE_ENV}: it moves an unreadable registry aside, which is a different problem from a write the device refused`
|
|
2363
2439
|
);
|
|
2364
2440
|
}
|
|
2365
2441
|
}
|
|
@@ -2478,7 +2554,7 @@ function daemonRestartNotice(filePath, daemonStateRoot) {
|
|
|
2478
2554
|
return `Saved in ${filePath}, the projects file under the home record this user's state root defaults to. If this is the root the service was started on \u2014 which is not knowable from here, because this daemon's hello reported no stateRoot \u2014 then the running background service loaded that file once when it started, so gp_attach will keep returning GP_E_NOT_FOUND for this projectId until the service restarts: ${restartCommand()}. ${rootDiscriminator()}`;
|
|
2479
2555
|
}
|
|
2480
2556
|
function rootDiscriminator() {
|
|
2481
|
-
return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
|
|
2557
|
+
return 'Tell the two cases apart with the service\'s own answer: gp_project_list reads the file this call wrote, so an entry it lists is on disk, and gp_attach {projectId} is answered by the running service \u2014 an entry the list shows plus an attach that answers GP_E_NOT_FOUND means the service is loading a different projects.json than the one this write reached, not that the write failed. Which file the running service loads is not something this shell guesses from $HOME: `glasspaned --state-dir <root>` moves it to <root>/projects.json, and the daemon reports the root it resolved \u2014 `hello` names the state root under its `stateRoot` key. A daemon predating 1.15.0 omits the key, and an omitted key means "that daemon does not report it", never "so it must be the home default": for those binaries the discriminator below is the only read. Cross-check either way \u2014 `launchctl print gui/' + currentUid() + "/com.glasspane.daemon` prints the arguments the job was started with. If those arguments name a --state-dir, the entry has to go into that root's projects.json (repeat gp_project_set with " + PROJECTS_FILE_ENV + " pointed at <that root>/projects.json \u2014 this shell judges that override with the same path gates it judges every root it writes, because writing there means creating the directory and bringing it to 0700, so a shared scratch tree, a directory this user does not own, or a world-writable one is refused with " + GP_E_STATE_ROOT_REFUSED + " and nothing is written; such a service root is not a place this surface can register into, and the fix is to run the service on a root both sides accept): no restart of the service will make it read anywhere else, so restarting again changes nothing. If they name no --state-dir, the service is on the default root and simply predates this write, and one restart with the command in this notice is what surfaces it.";
|
|
2482
2558
|
}
|
|
2483
2559
|
function restartCommand() {
|
|
2484
2560
|
return `run \`launchctl kickstart -k gui/${currentUid()}/com.glasspane.daemon\` \u2014 if that reports the service was not found, the launchd job was never bootstrapped, so ${daemonUnreachableRemedy()}`;
|
|
@@ -2654,10 +2730,22 @@ function describeError(error) {
|
|
|
2654
2730
|
}
|
|
2655
2731
|
var ProjectRegistryError = class extends Error {
|
|
2656
2732
|
code;
|
|
2657
|
-
|
|
2733
|
+
/**
|
|
2734
|
+
* The registry layer's own next move, when this fault has one that the code cannot
|
|
2735
|
+
* state. `tools.ts` transcribes it instead of deriving a remedy from the code alone
|
|
2736
|
+
* (B5): a durability fault and a damaged registry both answer `GP_E_INTERNAL`, and the
|
|
2737
|
+
* sentence that is right about the second — "the projects file is the problem … repair
|
|
2738
|
+
* or restore the damaged entry" — is a lie about the first, whose own message says "This
|
|
2739
|
+
* is the write's own failure, not a verdict about the file's contents". Absent means
|
|
2740
|
+
* "the code's arm is the honest answer here", which is what the read-side faults below
|
|
2741
|
+
* rely on.
|
|
2742
|
+
*/
|
|
2743
|
+
remedy;
|
|
2744
|
+
constructor(code, message, remedy) {
|
|
2658
2745
|
super(message);
|
|
2659
2746
|
this.name = "ProjectRegistryError";
|
|
2660
2747
|
this.code = code;
|
|
2748
|
+
this.remedy = remedy;
|
|
2661
2749
|
}
|
|
2662
2750
|
};
|
|
2663
2751
|
|
|
@@ -3179,6 +3267,23 @@ function oversizedReplyAdvice(spec, params) {
|
|
|
3179
3267
|
}
|
|
3180
3268
|
return head + floorNote + `retry with ${shrinkable.map(describeKnob).join(" and ")}; the reply body has to fit one frame, and a retried identical request will be dropped identically; the connection is intact and the daemon keeps answering other requests`;
|
|
3181
3269
|
}
|
|
3270
|
+
var RING_WRITE_REASON = "it is not a cheaper re-read of the first: it appends another entry to the daemon's bounded snapshot ring, and one more entry can evict the baseline an earlier snapshot returned \u2014 the one a later restore needs";
|
|
3271
|
+
function narrowingKnobPhrase(spec, params) {
|
|
3272
|
+
const knobs = narrowingKnobsFor(spec, params);
|
|
3273
|
+
const shrinkable = replyShrinkingKnobs(knobs).filter((knob) => !atAdvertisedFloor(knob));
|
|
3274
|
+
const atFloor = knobs.filter(atAdvertisedFloor);
|
|
3275
|
+
if (shrinkable.length > 0) {
|
|
3276
|
+
return `the parameter that would make such a reply smaller is ${shrinkable.map(describeKnob).join(" and ")}`;
|
|
3277
|
+
}
|
|
3278
|
+
if (atFloor.length > 0) {
|
|
3279
|
+
return `the only parameter of that kind this tool advertises is already spent (${atFloor.map(describeKnob).join("; ")})`;
|
|
3280
|
+
}
|
|
3281
|
+
return "this tool advertises no parameter that shrinks a reply";
|
|
3282
|
+
}
|
|
3283
|
+
function payloadOverflowArm(method) {
|
|
3284
|
+
if (isReplayUnsafeMethod(method)) return "replay-unsafe";
|
|
3285
|
+
return isNarrowableByRetryMethod(method) ? "narrowable" : "ring-writing";
|
|
3286
|
+
}
|
|
3182
3287
|
var NOT_NARROWABLE_BY_RETRY = /* @__PURE__ */ new Set([
|
|
3183
3288
|
...REPLAY_UNSAFE_METHOD_NAMES,
|
|
3184
3289
|
...STATE_CHANGING_ON_RETRY_METHOD_NAMES
|
|
@@ -3380,8 +3485,9 @@ async function projectGetTool(args, context) {
|
|
|
3380
3485
|
function mapProjectError(error) {
|
|
3381
3486
|
const passed = passedThroughRegistryError(error);
|
|
3382
3487
|
if (passed !== null) {
|
|
3488
|
+
const remedy = passed.remedy ?? projectErrorRemedy(passed.code);
|
|
3383
3489
|
return {
|
|
3384
|
-
content: [{ type: "text", text: formatToolError(passed.code, passed.message,
|
|
3490
|
+
content: [{ type: "text", text: formatToolError(passed.code, passed.message, remedy) }],
|
|
3385
3491
|
isError: true
|
|
3386
3492
|
};
|
|
3387
3493
|
}
|
|
@@ -3400,7 +3506,8 @@ function passedThroughRegistryError(error) {
|
|
|
3400
3506
|
return null;
|
|
3401
3507
|
}
|
|
3402
3508
|
const message = typeof candidate.message === "string" ? candidate.message : "";
|
|
3403
|
-
|
|
3509
|
+
const remedy = typeof candidate.remedy === "string" && candidate.remedy.trim() !== "" ? candidate.remedy : null;
|
|
3510
|
+
return { code: candidate.code, message, remedy };
|
|
3404
3511
|
}
|
|
3405
3512
|
function projectErrorRemedy(code) {
|
|
3406
3513
|
if (code === GP_E_PROJECT_LIMIT) {
|
|
@@ -3657,10 +3764,12 @@ async function recentReports(args, context) {
|
|
|
3657
3764
|
};
|
|
3658
3765
|
}
|
|
3659
3766
|
if (packs.length === 0) {
|
|
3767
|
+
const asked = skipped.length;
|
|
3768
|
+
const rejectClause = rejected.length === 0 ? "" : `. ${rejectedClause(rejected.length)}${renderTrailIds(rejected, format, provenanceOf)} were never sent, so the daemon has no opinion about them and nothing here reports one`;
|
|
3660
3769
|
return {
|
|
3661
3770
|
content: [{ type: "text", text: formatToolError(
|
|
3662
3771
|
GP_E_NO_EVIDENCE,
|
|
3663
|
-
`no pack is reachable in the daemon history for any of the ${
|
|
3772
|
+
`no pack is reachable in the daemon history for any of the ${asked} ${asked === 1 ? "operation" : "operations"} this shell sent: each was answered GP_E_NO_EVIDENCE (${renderTrailIds(skipped, format, provenanceOf)})${rejectClause}`,
|
|
3664
3773
|
"these operations are recorded in this session's trail, and the daemon holding no pack for them is a fact about its bounded history, not evidence that they did not run \u2014 this call measured nothing about a restart, so no cause is claimed here. Read one of them by id with gp_last_evidence {operationId} / gp_export_evidence {operationId}, or ask again with a smaller limit (gp_recent_reports advertises 1\u202620). Do not re-run gp_act to regenerate what the trail already holds"
|
|
3665
3774
|
) }],
|
|
3666
3775
|
isError: true
|
|
@@ -4049,6 +4158,7 @@ async function handleEvidenceAggregate(url, client, res) {
|
|
|
4049
4158
|
const renderedIds = [];
|
|
4050
4159
|
const skipped = [];
|
|
4051
4160
|
const notFetched = [];
|
|
4161
|
+
let hardFailure = null;
|
|
4052
4162
|
const budget = new FanOutBudget(EVIDENCE_FANOUT_BUDGET_MS);
|
|
4053
4163
|
try {
|
|
4054
4164
|
for (const [index, operationId] of ids.entries()) {
|
|
@@ -4067,19 +4177,42 @@ async function handleEvidenceAggregate(url, client, res) {
|
|
|
4067
4177
|
blocks.push(render(packForReport(pack, measuredSchemaVersion), format));
|
|
4068
4178
|
renderedIds.push(operationId);
|
|
4069
4179
|
} catch (error) {
|
|
4070
|
-
|
|
4071
|
-
|
|
4180
|
+
hardFailure = {
|
|
4181
|
+
operationId,
|
|
4182
|
+
kind: "frame",
|
|
4183
|
+
error,
|
|
4184
|
+
line: `${GP_E_INTERNAL}: the daemon returned a last_evidence frame this gateway cannot render (no parseable evidencePack for ${operationId}: ${boundedCause(describeCause(error))})`
|
|
4185
|
+
};
|
|
4186
|
+
notFetched.push(...ids.slice(index + 1));
|
|
4187
|
+
break;
|
|
4072
4188
|
}
|
|
4073
4189
|
} else if (settled.kind === "missing") {
|
|
4074
4190
|
skipped.push(operationId);
|
|
4075
4191
|
} else {
|
|
4076
|
-
|
|
4077
|
-
|
|
4192
|
+
hardFailure = {
|
|
4193
|
+
operationId,
|
|
4194
|
+
kind: "engine",
|
|
4195
|
+
error: settled.error,
|
|
4196
|
+
line: engineFailureLine(settled.error)
|
|
4197
|
+
};
|
|
4198
|
+
notFetched.push(...ids.slice(index + 1));
|
|
4199
|
+
break;
|
|
4078
4200
|
}
|
|
4079
4201
|
}
|
|
4080
4202
|
} finally {
|
|
4081
4203
|
budget.done();
|
|
4082
4204
|
}
|
|
4205
|
+
if (blocks.length === 0 && hardFailure !== null) {
|
|
4206
|
+
const tail = notFetchedTail(notFetched);
|
|
4207
|
+
if (hardFailure.kind === "frame") {
|
|
4208
|
+
failEvidenceFrameShape(res, hardFailure.error, hardFailure.operationId, tail);
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
await failEngine(res, hardFailure.error, TOOL_BY_NAME.get("gp_last_evidence"), {
|
|
4212
|
+
operationId: hardFailure.operationId
|
|
4213
|
+
}, tail);
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4083
4216
|
if (blocks.length === 0 && notFetched.length > 0) {
|
|
4084
4217
|
fail(
|
|
4085
4218
|
res,
|
|
@@ -4103,15 +4236,23 @@ async function handleEvidenceAggregate(url, client, res) {
|
|
|
4103
4236
|
const header = format === "html" ? `<h1>GlassPane evidence reports (${blocks.length})</h1>` : `# GlassPane evidence reports (${blocks.length})`;
|
|
4104
4237
|
const skipNote = makeSkipNote(skipped, format);
|
|
4105
4238
|
const unfetchedNote = makeNotFetchedNote(notFetched, format);
|
|
4239
|
+
const failureNote = makeHardFailureNote(hardFailure, format);
|
|
4106
4240
|
const separator = format === "html" ? "\n<hr>\n" : "\n\n---\n";
|
|
4107
4241
|
const sections2 = [header];
|
|
4108
|
-
for (const note of [skipNote, unfetchedNote]) {
|
|
4242
|
+
for (const note of [skipNote, unfetchedNote, failureNote]) {
|
|
4109
4243
|
if (note !== "") {
|
|
4110
4244
|
sections2.push(note);
|
|
4111
4245
|
}
|
|
4112
4246
|
}
|
|
4113
4247
|
sections2.push(blocks.join(separator));
|
|
4114
|
-
ok(res, {
|
|
4248
|
+
ok(res, {
|
|
4249
|
+
format,
|
|
4250
|
+
renderedIds,
|
|
4251
|
+
skipped,
|
|
4252
|
+
notFetched,
|
|
4253
|
+
...hardFailure === null ? {} : { failed: [hardFailure.operationId] },
|
|
4254
|
+
report: sections2.join("\n")
|
|
4255
|
+
});
|
|
4115
4256
|
}
|
|
4116
4257
|
async function handleToolForward(name, req, client, res) {
|
|
4117
4258
|
const spec = FORWARDABLE_TOOLS.get(name);
|
|
@@ -4300,6 +4441,33 @@ function makeNotFetchedNote(notFetched, format) {
|
|
|
4300
4441
|
const sentence = `not fetched within this request's ${EVIDENCE_FANOUT_BUDGET_MS}ms wait: ${notFetched.length} ${label} left unanswered: ${notFetched.join(", ")}. Fetch one by id with GET /v1/evidence/<operationId>, or ask again with a shorter ids= list`;
|
|
4301
4442
|
return format === "html" ? `<p class="gp-not-fetched">${escapeHTML(sentence)}</p>` : `> ${sentence}`;
|
|
4302
4443
|
}
|
|
4444
|
+
var CAUSE_MAX_CHARS = 400;
|
|
4445
|
+
function describeCause(error) {
|
|
4446
|
+
if (error instanceof Error) {
|
|
4447
|
+
return error.message.trim() === "" ? error.name : error.message;
|
|
4448
|
+
}
|
|
4449
|
+
return String(error);
|
|
4450
|
+
}
|
|
4451
|
+
function boundedCause(text) {
|
|
4452
|
+
return text.length <= CAUSE_MAX_CHARS ? text : `${text.slice(0, CAUSE_MAX_CHARS)}\u2026 [+${text.length - CAUSE_MAX_CHARS} chars not shown]`;
|
|
4453
|
+
}
|
|
4454
|
+
function engineFailureLine(error) {
|
|
4455
|
+
return error instanceof EngineCallError ? `${error.code}: ${boundedCause(error.message)}` : `${GP_E_INTERNAL}: ${boundedCause(describeCause(error))}`;
|
|
4456
|
+
}
|
|
4457
|
+
function notFetchedTail(notFetched) {
|
|
4458
|
+
if (notFetched.length === 0) {
|
|
4459
|
+
return "";
|
|
4460
|
+
}
|
|
4461
|
+
const label = notFetched.length === 1 ? "id" : "ids";
|
|
4462
|
+
return ` \u2014 and ${notFetched.length} ${label} this request never sent (${notFetched.join(", ")}), which is a stopped read, not an answer about them`;
|
|
4463
|
+
}
|
|
4464
|
+
function makeHardFailureNote(failure, format) {
|
|
4465
|
+
if (failure === null) {
|
|
4466
|
+
return "";
|
|
4467
|
+
}
|
|
4468
|
+
const sentence = `incomplete: ${failure.operationId} failed with ${failure.line} \u2014 the fan-out stopped there, so the packs above are what this request read, not the whole list`;
|
|
4469
|
+
return format === "html" ? `<p class="gp-incomplete">${escapeHTML(sentence)}</p>` : `> ${sentence}`;
|
|
4470
|
+
}
|
|
4303
4471
|
function ok(res, result, attribution) {
|
|
4304
4472
|
writeJson(res, 200, attribution === void 0 ? { ok: true, result } : { ok: true, result, attribution });
|
|
4305
4473
|
}
|
|
@@ -4368,26 +4536,42 @@ function httpStatusForEngineCode(code) {
|
|
|
4368
4536
|
return 422;
|
|
4369
4537
|
}
|
|
4370
4538
|
}
|
|
4371
|
-
async function failEngine(res, error, spec, params) {
|
|
4539
|
+
async function failEngine(res, error, spec, params, tail = "") {
|
|
4372
4540
|
if (error instanceof EngineCallError) {
|
|
4373
|
-
fail(
|
|
4541
|
+
fail(
|
|
4542
|
+
res,
|
|
4543
|
+
httpStatusForEngineCode(error.code),
|
|
4544
|
+
error.code,
|
|
4545
|
+
`${error.message}${tail}`,
|
|
4546
|
+
engineRemedy(error, spec, params)
|
|
4547
|
+
);
|
|
4374
4548
|
return;
|
|
4375
4549
|
}
|
|
4376
4550
|
fail(
|
|
4377
4551
|
res,
|
|
4378
4552
|
500,
|
|
4379
4553
|
GP_E_INTERNAL,
|
|
4380
|
-
`this gateway could not complete the request: ${String(error)}`,
|
|
4554
|
+
`this gateway could not complete the request: ${String(error)}${tail}`,
|
|
4381
4555
|
gatewayFaultAdvice(spec)
|
|
4382
4556
|
);
|
|
4383
4557
|
}
|
|
4384
4558
|
function engineRemedy(error, spec, params) {
|
|
4385
4559
|
if (spec !== void 0 && error.code === GP_E_PAYLOAD_TOO_LARGE) {
|
|
4386
4560
|
const asked = params ?? {};
|
|
4387
|
-
|
|
4561
|
+
switch (payloadOverflowArm(spec.engineMethod)) {
|
|
4562
|
+
case "replay-unsafe":
|
|
4563
|
+
return httpReplayUnsafePayloadAdvice(spec, asked);
|
|
4564
|
+
case "ring-writing":
|
|
4565
|
+
return httpRingWritingPayloadAdvice(spec, asked);
|
|
4566
|
+
case "narrowable":
|
|
4567
|
+
return oversizedReplyAdvice(spec, asked);
|
|
4568
|
+
}
|
|
4388
4569
|
}
|
|
4389
4570
|
return error.remedy;
|
|
4390
4571
|
}
|
|
4572
|
+
function httpRingWritingPayloadAdvice(spec, params) {
|
|
4573
|
+
return `the reply was too large for one frame (${MAX_FRAME_BYTES}-byte limit); '${spec.engineMethod}' reached the daemon and was answered, and only its reply was dropped \u2014 which means the baseline it captured is already stored. Do NOT send this request again to recover that reply: ${RING_WRITE_REASON}. ${narrowingKnobPhrase(spec, params)}, and using it is the second send this answer refuses; the ids the daemon stored are only ever where a caller already has them, in the snapshot replies it has already received, and this gateway lists no ring. Read what is held, and see the attached app as it is now without changing it with POST /v1/tools/observe, POST /v1/tools/assert_element or POST /v1/tools/probe_status; an operation whose id is already held re-reads with GET /v1/evidence/<operationId>. Taking a fresh baseline is a decision, not error recovery, and the connection is intact and the daemon keeps answering other requests`;
|
|
4574
|
+
}
|
|
4391
4575
|
function httpReplayUnsafePayloadAdvice(spec, params) {
|
|
4392
4576
|
return `do not re-send this request on this answer. '${spec.engineMethod}' reached the daemon and was answered, and only its reply was dropped for exceeding one frame, so sending it again performs the change a second time rather than recovering the first answer (an act presses the same control again, a restore replays its steps again, an attach re-points the daemon at another app). Read what happened instead of acting again: GET /v1/evidence/<operationId> and POST /v1/tools/last_evidence {operationId} return the pack of an operation whose id you already hold, and POST /v1/tools/observe, POST /v1/tools/assert_element and POST /v1/tools/probe_status read the attached app as it is now without changing it \u2014 this gateway keeps no operation trail of its own, so it cannot list which operations ran. If that reading shows this request never took effect, send it again narrowed to what its own schema advertises: ${oversizedReplyAdvice(spec, params)}`;
|
|
4393
4577
|
}
|
|
@@ -4398,12 +4582,12 @@ function gatewayFaultAdvice(spec) {
|
|
|
4398
4582
|
}
|
|
4399
4583
|
return `re-send this request only if it is one that changes nothing \u2014 a read; the routes this surface answers are ${TOOL_ROUTES_TEXT}. ${log}.`;
|
|
4400
4584
|
}
|
|
4401
|
-
function failEvidenceFrameShape(res, error, operationId) {
|
|
4585
|
+
function failEvidenceFrameShape(res, error, operationId, tail = "") {
|
|
4402
4586
|
fail(
|
|
4403
4587
|
res,
|
|
4404
4588
|
502,
|
|
4405
4589
|
GP_E_INTERNAL,
|
|
4406
|
-
`the daemon returned a last_evidence frame that does not match the engine contract (no parseable evidencePack${operationId === void 0 ? "" : ` for ${operationId}`}: ${String(error)})`,
|
|
4590
|
+
`the daemon returned a last_evidence frame that does not match the engine contract (no parseable evidencePack${operationId === void 0 ? "" : ` for ${operationId}`}: ${String(error)})${tail}`,
|
|
4407
4591
|
"the daemon answered this request, so the transport is up and restarting it changes nothing here; this gateway validates a pack before rendering or forwarding it, so GET /v1/evidence/<operationId> and POST /v1/tools/last_evidence {operationId} will both refuse this frame for the same reason. Read what the attached app is doing now without performing anything: POST /v1/tools/observe, POST /v1/tools/assert_element or POST /v1/tools/probe_status. Producing a fresh pack for this operation means acting again, which is a decision this read cannot make for you."
|
|
4408
4592
|
);
|
|
4409
4593
|
}
|