@gajae-code/coding-agent 0.18.8 → 0.18.9
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/CHANGELOG.md +98 -0
- package/dist/types/async/endpoint-id.d.ts +16 -0
- package/dist/types/async/support.d.ts +0 -14
- package/dist/types/commands/mcp-serve.d.ts +4 -0
- package/dist/types/discovery/mcp-json.d.ts +1 -0
- package/dist/types/eval/py/kernel.d.ts +18 -0
- package/dist/types/export/html/html-export-destination.d.ts +2 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
- package/dist/types/extensibility/skill-management.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
- package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
- package/dist/types/lsp/config.d.ts +8 -5
- package/dist/types/runtime-mcp/manager.d.ts +2 -1
- package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
- package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -1
- package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
- package/dist/types/sdk/broker/broker.d.ts +6 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -1
- package/dist/types/session/session-storage.d.ts +7 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
- package/dist/types/task/isolation-dir.d.ts +26 -0
- package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
- package/dist/types/tools/browser/cdp-url.d.ts +2 -0
- package/dist/types/tools/github-side-effect.d.ts +22 -0
- package/dist/types/tools/monitor-mutation.d.ts +9 -0
- package/package.json +7 -7
- package/src/async/endpoint-id.ts +26 -0
- package/src/async/job-manager.ts +19 -10
- package/src/async/support.ts +0 -25
- package/src/capability/index.ts +14 -16
- package/src/cli/doctor/repairs.ts +4 -1
- package/src/cli/doctor-supervisor.ts +3 -0
- package/src/cli/public-command-registry.ts +28 -6
- package/src/commands/mcp-serve.ts +7 -0
- package/src/commands/sdk.ts +453 -415
- package/src/config/file-lock.ts +9 -11
- package/src/config/model-profile-activation.ts +5 -5
- package/src/coordinator-mcp/server.ts +2 -0
- package/src/customization/inventory.ts +1 -13
- package/src/dap/config.ts +2 -2
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
- package/src/discovery/builtin.ts +3 -1
- package/src/discovery/helpers.ts +12 -9
- package/src/discovery/mcp-json.ts +20 -12
- package/src/eval/py/executor.ts +459 -77
- package/src/eval/py/kernel.ts +79 -13
- package/src/eval/py/tool-bridge.ts +4 -0
- package/src/export/html/html-export-destination.ts +16 -0
- package/src/export/html/index.ts +2 -6
- package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
- package/src/extensibility/extensions/runner.ts +11 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +27 -15
- package/src/extensibility/skill-management.ts +3 -0
- package/src/gjc-runtime/launch-worktree.ts +56 -5
- package/src/gjc-runtime/psmux-detect.ts +28 -4
- package/src/gjc-runtime/session-state-sidecar.ts +3 -2
- package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
- package/src/gjc-runtime/state-writer.ts +22 -8
- package/src/gjc-runtime/tmux-common.ts +8 -5
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/config.ts +12 -63
- package/src/memories/index.ts +88 -7
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +1 -1
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +21 -13
- package/src/modes/controllers/selector-controller.ts +3 -7
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -1
- package/src/runtime-mcp/client.ts +4 -2
- package/src/runtime-mcp/json-rpc.ts +2 -1
- package/src/runtime-mcp/manager.ts +29 -11
- package/src/runtime-mcp/mcp-redirect.ts +73 -0
- package/src/runtime-mcp/oauth-flow.ts +11 -4
- package/src/runtime-mcp/oauth-public-url.ts +8 -2
- package/src/runtime-mcp/pool.ts +6 -0
- package/src/runtime-mcp/project-host-values.ts +46 -0
- package/src/runtime-mcp/transports/http.ts +2 -1
- package/src/sdk/acp/adapter.ts +2 -0
- package/src/sdk/broker/broker.ts +6 -0
- package/src/sdk/broker/doctor-restart.ts +4 -0
- package/src/sdk/broker/ensure.ts +29 -2
- package/src/sdk/host/session-runtime.test.ts +58 -9
- package/src/sdk/host/session-runtime.ts +65 -35
- package/src/sdk/router/session-router.ts +7 -6
- package/src/sdk/session.ts +2 -7
- package/src/session/agent-session.ts +59 -17
- package/src/session/artifacts.ts +3 -2
- package/src/session/internal/managed-session-scope.ts +403 -81
- package/src/session/internal/managed-session-storage.ts +125 -17
- package/src/session/session-manager.ts +12 -0
- package/src/session/session-storage.ts +30 -1
- package/src/skill-state/active-state.ts +13 -0
- package/src/skill-state/workflow-mutation-guard.ts +102 -10
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/slash-commands/helpers/mcp.ts +8 -4
- package/src/task/isolation-dir.ts +143 -0
- package/src/task/worktree.ts +16 -7
- package/src/tools/ast-edit-write-path.ts +68 -0
- package/src/tools/ast-edit.ts +7 -2
- package/src/tools/browser/cdp-url.ts +18 -0
- package/src/tools/browser.ts +8 -1
- package/src/tools/gh.ts +10 -0
- package/src/tools/github-side-effect.ts +112 -0
- package/src/tools/monitor-mutation.ts +27 -0
- package/src/tools/monitor.ts +7 -0
- package/src/tools/python.ts +7 -6
- package/src/utils/external-editor.ts +3 -2
- package/src/web/insane/url-guard.ts +2 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cancelMCPStream } from "./content-limits";
|
|
2
|
+
|
|
3
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
4
|
+
const MAX_REDIRECTS = 20;
|
|
5
|
+
const REQUEST_BODY_HEADERS = [
|
|
6
|
+
"content-encoding",
|
|
7
|
+
"content-language",
|
|
8
|
+
"content-length",
|
|
9
|
+
"content-location",
|
|
10
|
+
"content-type",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
function rewritesToGet(status: number, method: string): boolean {
|
|
14
|
+
return (
|
|
15
|
+
((status === 301 || status === 302) && method === "POST") ||
|
|
16
|
+
(status === 303 && method !== "GET" && method !== "HEAD")
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Follow same-origin MCP redirects only.
|
|
22
|
+
*
|
|
23
|
+
* A cross-origin 307/308 would carry custom credential headers, `Mcp-Session-Id`,
|
|
24
|
+
* and the JSON-RPC body. The first request uses `rawUrl` unchanged so a caller
|
|
25
|
+
* that cannot be parsed (diagnostic redaction) still reaches fetch, but with
|
|
26
|
+
* `redirect: "manual"` so that hop is not followed either.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchMcpRespectingOrigin(rawUrl: string, init: BunFetchRequestInit): Promise<Response> {
|
|
29
|
+
let currentUrl: URL;
|
|
30
|
+
try {
|
|
31
|
+
currentUrl = new URL(rawUrl);
|
|
32
|
+
} catch {
|
|
33
|
+
return fetch(rawUrl, { ...init, redirect: "manual" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let requestTarget = rawUrl;
|
|
37
|
+
let currentInit: BunFetchRequestInit = { ...init, redirect: "manual" };
|
|
38
|
+
for (let redirectCount = 0; ; redirectCount++) {
|
|
39
|
+
const response = await fetch(requestTarget, currentInit);
|
|
40
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
41
|
+
|
|
42
|
+
const location = response.headers.get("location");
|
|
43
|
+
if (!location) return response;
|
|
44
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
45
|
+
cancelMCPStream(response.body);
|
|
46
|
+
throw new Error("MCP redirect limit exceeded");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let nextUrl: URL;
|
|
50
|
+
try {
|
|
51
|
+
nextUrl = new URL(location, currentUrl);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
cancelMCPStream(response.body);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (nextUrl.origin !== currentUrl.origin) {
|
|
57
|
+
cancelMCPStream(response.body);
|
|
58
|
+
throw new Error("cross-origin redirects are not allowed");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headers = new Headers(currentInit.headers);
|
|
62
|
+
const method = (currentInit.method ?? "GET").toUpperCase();
|
|
63
|
+
if (rewritesToGet(response.status, method)) {
|
|
64
|
+
for (const name of REQUEST_BODY_HEADERS) headers.delete(name);
|
|
65
|
+
currentInit = { ...currentInit, method: "GET", headers, body: undefined, redirect: "manual" };
|
|
66
|
+
} else {
|
|
67
|
+
currentInit = { ...currentInit, headers, redirect: "manual" };
|
|
68
|
+
}
|
|
69
|
+
currentUrl = nextUrl;
|
|
70
|
+
requestTarget = nextUrl.toString();
|
|
71
|
+
cancelMCPStream(response.body);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { OAuthCallbackFlowOptions } from "@gajae-code/ai/utils/oauth/callback-server";
|
|
9
9
|
import { OAuthCallbackFlow } from "@gajae-code/ai/utils/oauth/callback-server";
|
|
10
10
|
import type { OAuthController, OAuthCredentials } from "@gajae-code/ai/utils/oauth/types";
|
|
11
|
+
import { guardedPublicFetch } from "../web/insane/url-guard";
|
|
11
12
|
import { assertPublicOAuthUrl } from "./oauth-public-url";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_PORT = 3000;
|
|
@@ -262,7 +263,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
262
263
|
params.set("client_secret", clientSecret);
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
await assertPublicOAuthUrl(this.config.tokenUrl);
|
|
266
|
+
await assertPublicOAuthUrl(this.config.tokenUrl, { signal: this.ctrl.signal });
|
|
266
267
|
const response = await fetch(this.config.tokenUrl, {
|
|
267
268
|
method: "POST",
|
|
268
269
|
redirect: "error",
|
|
@@ -343,7 +344,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
343
344
|
if (!registrationEndpoint) return;
|
|
344
345
|
|
|
345
346
|
try {
|
|
346
|
-
await assertPublicOAuthUrl(registrationEndpoint);
|
|
347
|
+
await assertPublicOAuthUrl(registrationEndpoint, { signal });
|
|
347
348
|
const response = await fetch(registrationEndpoint, {
|
|
348
349
|
method: "POST",
|
|
349
350
|
redirect: "error",
|
|
@@ -385,8 +386,10 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
385
386
|
try {
|
|
386
387
|
const authorizationEndpoint = new URL(this.config.authorizationUrl);
|
|
387
388
|
const metadataUrl = new URL("/.well-known/oauth-authorization-server", authorizationEndpoint.origin);
|
|
389
|
+
await assertPublicOAuthUrl(metadataUrl.toString(), { signal });
|
|
388
390
|
const response = await fetch(metadataUrl.toString(), {
|
|
389
391
|
method: "GET",
|
|
392
|
+
redirect: "error",
|
|
390
393
|
headers: { Accept: "application/json" },
|
|
391
394
|
signal,
|
|
392
395
|
});
|
|
@@ -406,12 +409,16 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
|
|
|
406
409
|
|
|
407
410
|
async #assertClientIdNotRequired(authorizationUrl: string, signal?: AbortSignal): Promise<void> {
|
|
408
411
|
try {
|
|
409
|
-
|
|
412
|
+
// Pin the GET to the addresses that passed validation. A plain fetch would
|
|
413
|
+
// resolve the hostname again. Skip the probe when that route cannot be
|
|
414
|
+
// established; the flow signal cancels the DNS lookup.
|
|
415
|
+
const probed = await guardedPublicFetch(authorizationUrl, {
|
|
410
416
|
method: "GET",
|
|
411
|
-
redirect: "manual",
|
|
412
417
|
headers: { Accept: "text/plain,text/html,application/json" },
|
|
413
418
|
signal,
|
|
414
419
|
});
|
|
420
|
+
if (!probed.ok) return;
|
|
421
|
+
const response = probed.response;
|
|
415
422
|
if (response.status < 400) return;
|
|
416
423
|
const body = await response.text();
|
|
417
424
|
if (/client[_-]?id/i.test(body) && /(required|missing|invalid)/i.test(body)) {
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { validatePublicHttpUrl } from "../web/insane/url-guard";
|
|
2
2
|
|
|
3
|
-
export async function assertPublicOAuthUrl(url: string): Promise<void> {
|
|
4
|
-
|
|
3
|
+
export async function assertPublicOAuthUrl(url: string, options: { signal?: AbortSignal } = {}): Promise<void> {
|
|
4
|
+
if (options.signal?.aborted) throw abortReason(options.signal);
|
|
5
|
+
const checked = await validatePublicHttpUrl(url, { signal: options.signal });
|
|
6
|
+
if (options.signal?.aborted) throw abortReason(options.signal);
|
|
5
7
|
if (!checked.ok) {
|
|
6
8
|
throw new Error(`Refusing non-public OAuth endpoint: ${checked.reason ?? url}`);
|
|
7
9
|
}
|
|
8
10
|
}
|
|
11
|
+
|
|
12
|
+
function abortReason(signal: AbortSignal): Error {
|
|
13
|
+
return signal.reason instanceof Error ? signal.reason : new Error("OAuth URL validation aborted");
|
|
14
|
+
}
|
package/src/runtime-mcp/pool.ts
CHANGED
|
@@ -307,6 +307,12 @@ class MCPPoolLeaseImpl implements MCPPoolLease {
|
|
|
307
307
|
notify: (method, params) => lease.notify(method, params),
|
|
308
308
|
close: () => lease.release(),
|
|
309
309
|
closeBeforeReconnect: physical.transport.closeBeforeReconnect,
|
|
310
|
+
get onRequest() {
|
|
311
|
+
return physical.transport.onRequest;
|
|
312
|
+
},
|
|
313
|
+
set onRequest(handler) {
|
|
314
|
+
physical.transport.onRequest = handler;
|
|
315
|
+
},
|
|
310
316
|
};
|
|
311
317
|
const facade = { ...physical, transport };
|
|
312
318
|
for (const property of ["tools", "resources", "resourceTemplates", "prompts"] as const) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-value policy for MCP config that comes from an untrusted project file.
|
|
3
|
+
*
|
|
4
|
+
* User mcp.json and an explicit `--mcp-config` file may still substitute
|
|
5
|
+
* secret-named environment variables and run `!` shell commands. A project
|
|
6
|
+
* file (native `.gjc/mcp.json` or a repo-root `mcp.json`) uses the same
|
|
7
|
+
* sensitive-name rule as project `ssh.json`: do not copy those host values
|
|
8
|
+
* into server env or headers, and do not execute `!`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SourceMeta } from "../capability/types";
|
|
12
|
+
import { isSensitiveEnvName } from "../discovery/helpers";
|
|
13
|
+
|
|
14
|
+
const UNTRUSTED_PROJECT_MCP_PROVIDERS = new Set(["native", "mcp-json"]);
|
|
15
|
+
|
|
16
|
+
export function nativeMcpSource(level: "user" | "project", filePath: string): SourceMeta {
|
|
17
|
+
return {
|
|
18
|
+
provider: "native",
|
|
19
|
+
providerName: "GJC",
|
|
20
|
+
path: filePath,
|
|
21
|
+
level,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when this server was loaded from untrusted project MCP config. */
|
|
26
|
+
export function isUntrustedProjectMcpSource(source: SourceMeta | undefined, toolsOnly: boolean): boolean {
|
|
27
|
+
if (toolsOnly || !source || source.level !== "project") return false;
|
|
28
|
+
return UNTRUSTED_PROJECT_MCP_PROVIDERS.has(source.provider);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ProjectHostValueDecision =
|
|
32
|
+
| { action: "resolve" }
|
|
33
|
+
| { action: "drop" }
|
|
34
|
+
| { action: "literal"; value: string };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide how to interpret one env or header value.
|
|
38
|
+
* `drop` means "do not run the shell, and omit the value".
|
|
39
|
+
* `literal` means "do not read process.env".
|
|
40
|
+
*/
|
|
41
|
+
export function decideUntrustedProjectConfigValue(value: string, untrusted: boolean): ProjectHostValueDecision {
|
|
42
|
+
if (!untrusted) return { action: "resolve" };
|
|
43
|
+
if (value.startsWith("!")) return { action: "drop" };
|
|
44
|
+
if (isSensitiveEnvName(value)) return { action: "literal", value };
|
|
45
|
+
return { action: "resolve" };
|
|
46
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
MCP_MAX_SSE_REQUEST_MESSAGES,
|
|
29
29
|
readMCPResponseText,
|
|
30
30
|
} from "../content-limits";
|
|
31
|
+
import { fetchMcpRespectingOrigin } from "../mcp-redirect";
|
|
31
32
|
import { fetchPluginMcpRequest, usesPublicNetworkMcpFetch } from "../plugin-network-boundary";
|
|
32
33
|
import { buildModernMcpHeaders, type MCPModernClientContext, type MCPProtocolEra, withModernMeta } from "../protocol";
|
|
33
34
|
|
|
@@ -157,7 +158,7 @@ export class HttpTransport implements MCPTransport {
|
|
|
157
158
|
#fetch(init: BunFetchRequestInit): Promise<Response> {
|
|
158
159
|
return usesPublicNetworkMcpFetch(this.config)
|
|
159
160
|
? fetchPluginMcpRequest(this.config.url, init)
|
|
160
|
-
:
|
|
161
|
+
: fetchMcpRespectingOrigin(this.config.url, init);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
#trackReader(promise: Promise<void>, controller?: AbortController): void {
|
package/src/sdk/acp/adapter.ts
CHANGED
|
@@ -150,6 +150,8 @@ const ACP_MCP_PRESERVED_LAUNCH_CODES = new Set([
|
|
|
150
150
|
"spawn_failed",
|
|
151
151
|
"worktree_in_use",
|
|
152
152
|
"uncertain_after_send",
|
|
153
|
+
// Attach-only refusal: raised by ensureBroker before any lifecycle request or MCP server.
|
|
154
|
+
"broker_unavailable",
|
|
153
155
|
]);
|
|
154
156
|
|
|
155
157
|
/**
|
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -1581,6 +1581,7 @@ export class Broker {
|
|
|
1581
1581
|
#startupAfterDiscoveryWriteTestHook: (() => Promise<void>) | undefined;
|
|
1582
1582
|
#startupPostPublicationDelayMs: number;
|
|
1583
1583
|
#completionTask: Promise<void> | null = null;
|
|
1584
|
+
#terminalExit: { mode: BrokerExitMode; reason: BrokerExitReason } | null = null;
|
|
1584
1585
|
#completion!: Promise<void>;
|
|
1585
1586
|
#resolveCompletion!: () => void;
|
|
1586
1587
|
#rejectCompletion!: (error: unknown) => void;
|
|
@@ -4019,6 +4020,10 @@ export class Broker {
|
|
|
4019
4020
|
get completion(): Promise<void> {
|
|
4020
4021
|
return this.#completion;
|
|
4021
4022
|
}
|
|
4023
|
+
/** Why this broker completed, or null while it is still running. */
|
|
4024
|
+
get terminalExit(): { readonly mode: BrokerExitMode; readonly reason: BrokerExitReason } | null {
|
|
4025
|
+
return this.#terminalExit;
|
|
4026
|
+
}
|
|
4022
4027
|
#restartIdentity(): BrokerRestartOwnerIdentity | undefined {
|
|
4023
4028
|
const d = this.discovery;
|
|
4024
4029
|
if (!d?.incarnation) return undefined;
|
|
@@ -4312,6 +4317,7 @@ export class Broker {
|
|
|
4312
4317
|
blockingLockPath?: string,
|
|
4313
4318
|
): Promise<void> {
|
|
4314
4319
|
if (this.#completionTask) return this.#completionTask;
|
|
4320
|
+
this.#terminalExit = { mode, reason };
|
|
4315
4321
|
const now = process.hrtime.bigint();
|
|
4316
4322
|
const fenceStartedAt = this.#lossAt ?? this.#ambiguousAt;
|
|
4317
4323
|
const exitRecord: BrokerExitRecord & Record<string, unknown> = {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from "./broker";
|
|
10
10
|
import { launchAuthorizedBrokerSuccessor, oldOwnerConfirmedExited } from "./daemon-entry";
|
|
11
11
|
import { type BrokerDiscovery, readBrokerDiscovery } from "./discovery";
|
|
12
|
+
import { isBrokerAttachOnly } from "./ensure";
|
|
12
13
|
|
|
13
14
|
export interface DoctorBrokerRestartOptions {
|
|
14
15
|
agentDir: string;
|
|
@@ -33,6 +34,8 @@ export type DoctorBrokerRestartOutcome =
|
|
|
33
34
|
adopted: boolean;
|
|
34
35
|
}
|
|
35
36
|
| { kind: "owner_unavailable"; reason: "no_discovery" | "owner_not_confirmed_live" }
|
|
37
|
+
/** Attach-only mode: the broker belongs to an external supervisor, so restart through it. */
|
|
38
|
+
| { kind: "attach_only_refused"; reason: "supervisor_owned" }
|
|
36
39
|
| { kind: "prepare_refused"; code: string; message: string }
|
|
37
40
|
| { kind: "commit_refused"; code: string; message: string }
|
|
38
41
|
| { kind: "old_owner_exit_timeout"; requestId: string; oldOwner: BrokerRestartOwnerIdentity }
|
|
@@ -163,6 +166,7 @@ const OLD_OWNER_EXIT_POLL_MS = 50;
|
|
|
163
166
|
* proven it owns the root -- never here, and never before that proof exists.
|
|
164
167
|
*/
|
|
165
168
|
export async function restartBrokerForDoctor(options: DoctorBrokerRestartOptions): Promise<DoctorBrokerRestartOutcome> {
|
|
169
|
+
if (isBrokerAttachOnly()) return { kind: "attach_only_refused", reason: "supervisor_owned" };
|
|
166
170
|
const old = await readBrokerDiscovery(options.agentDir);
|
|
167
171
|
if (!old) return { kind: "owner_unavailable", reason: "no_discovery" };
|
|
168
172
|
const oldOwner = identity(old);
|
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
7
7
|
import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
|
|
8
8
|
import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
|
|
9
|
-
import { SdkClient } from "../client/client";
|
|
9
|
+
import { SdkClient, SdkClientError } from "../client/client";
|
|
10
10
|
import { type BrokerStartupExitRecord, clearBrokerStartupExitRecord, readBrokerStartupExitRecord } from "./broker-exit";
|
|
11
11
|
import {
|
|
12
12
|
type BrokerDiscovery,
|
|
@@ -1003,9 +1003,36 @@ function startEnsure(settings: EnsureBrokerSettings, initiator: EnsureInitiator)
|
|
|
1003
1003
|
return entry;
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
|
-
/**
|
|
1006
|
+
/**
|
|
1007
|
+
* Attach-only client mode. When `GJC_SDK_BROKER_AUTOSTART=0`, `ensureBroker`
|
|
1008
|
+
* never spawns, retires or restarts a broker: it returns a live, reusable
|
|
1009
|
+
* discovery or fails with `broker_unavailable`. Hosts that supervise their own
|
|
1010
|
+
* broker (for example a system service) set it for every client they run. The
|
|
1011
|
+
* broker entrypoints never call `ensureBroker`, so they ignore the variable and
|
|
1012
|
+
* their descendants may inherit it.
|
|
1013
|
+
*/
|
|
1014
|
+
export const SDK_BROKER_AUTOSTART_ENV = "GJC_SDK_BROKER_AUTOSTART";
|
|
1015
|
+
|
|
1016
|
+
export function isBrokerAttachOnly(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
1017
|
+
return env[SDK_BROKER_AUTOSTART_ENV] === "0";
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function attachExistingBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
|
|
1021
|
+
const discovery = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs);
|
|
1022
|
+
if (discovery && (await isBrokerReusable(discovery))) return discovery;
|
|
1023
|
+
throw new SdkClientError(
|
|
1024
|
+
"broker_unavailable",
|
|
1025
|
+
`SDK broker is not running and ${SDK_BROKER_AUTOSTART_ENV}=0 forbids starting one.`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Starts the detached broker entrypoint when discovery has no live owner. In
|
|
1031
|
+
* attach-only mode (`SDK_BROKER_AUTOSTART_ENV`) it only attaches.
|
|
1032
|
+
*/
|
|
1007
1033
|
export function ensureBroker(settings: EnsureBrokerSettings): Promise<BrokerDiscovery> {
|
|
1008
1034
|
const resolvedSettings = { ...settings, agentDir: path.resolve(settings.agentDir) };
|
|
1035
|
+
if (isBrokerAttachOnly()) return attachExistingBroker(resolvedSettings);
|
|
1009
1036
|
const inFlight = ensureInFlight.get(resolvedSettings.agentDir) ?? startEnsure(resolvedSettings, "discovery");
|
|
1010
1037
|
return inFlight.discovery;
|
|
1011
1038
|
}
|
|
@@ -5378,19 +5378,18 @@ async function settledStatus(
|
|
|
5378
5378
|
harness: InvocationHarness,
|
|
5379
5379
|
name: string,
|
|
5380
5380
|
input: Record<string, unknown>,
|
|
5381
|
+
timeoutMs = 10_000,
|
|
5381
5382
|
): Promise<NonNullable<ResponseFrame["result"]>> {
|
|
5382
5383
|
// Wall-clock budget instead of a fixed poll count: CI runners can starve the
|
|
5383
|
-
// event loop
|
|
5384
|
-
//
|
|
5385
|
-
|
|
5386
|
-
// reconciliation state with the asserted shape within a bounded horizon.
|
|
5387
|
-
const budgetEndsAt = Date.now() + 10_000;
|
|
5384
|
+
// event loop and delay deadline timers. Polling every 10ms avoids flooding
|
|
5385
|
+
// reconciliation queries while preserving the asserted terminal contract.
|
|
5386
|
+
const budgetEndsAt = Date.now() + timeoutMs;
|
|
5388
5387
|
for (;;) {
|
|
5389
5388
|
const frame = await harness.query(name, input);
|
|
5390
5389
|
const result = frame.result;
|
|
5391
5390
|
if (result && (result.status === "failed" || result.status === "terminal_ok")) return result;
|
|
5392
5391
|
if (Date.now() > budgetEndsAt) throw new Error(`${name} never reported a terminal reconciliation status`);
|
|
5393
|
-
await Bun.sleep(
|
|
5392
|
+
await Bun.sleep(10);
|
|
5394
5393
|
}
|
|
5395
5394
|
}
|
|
5396
5395
|
|
|
@@ -5842,6 +5841,48 @@ test("SDK turn.steer preserves its expected run token and propagates a stale-run
|
|
|
5842
5841
|
});
|
|
5843
5842
|
|
|
5844
5843
|
describe("post-acceptance invocation terminalization", () => {
|
|
5844
|
+
test("completion fallback and a held lifecycle end publish one terminal boundary", async () => {
|
|
5845
|
+
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-completion-lifecycle-race-"));
|
|
5846
|
+
const completion = Promise.withResolvers<string>();
|
|
5847
|
+
const terminalEntered = Promise.withResolvers<void>();
|
|
5848
|
+
const releaseTerminal = Promise.withResolvers<void>();
|
|
5849
|
+
const completionReconciled = Promise.withResolvers<void>();
|
|
5850
|
+
let harness: InvocationHarness | undefined;
|
|
5851
|
+
try {
|
|
5852
|
+
harness = await invocationHarness("completion-lifecycle-race", cwd, {
|
|
5853
|
+
sendUserMessage: async (_content, options) => {
|
|
5854
|
+
await options?.onPreflightAcceptCommit?.();
|
|
5855
|
+
return completion.promise;
|
|
5856
|
+
},
|
|
5857
|
+
persistInterceptor: () => {},
|
|
5858
|
+
agentFailedWriteFailures: 0,
|
|
5859
|
+
persistHold: { type: "agent_end", onEntered: terminalEntered.resolve, release: releaseTerminal.promise },
|
|
5860
|
+
onInvocationCompletionReconciled: () => completionReconciled.resolve(),
|
|
5861
|
+
});
|
|
5862
|
+
const accepted = await harness.control("turn.prompt", { text: "complete once" });
|
|
5863
|
+
expect(accepted.ok).toBe(true);
|
|
5864
|
+
const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
|
|
5865
|
+
await harness.emit("agent_start");
|
|
5866
|
+
completion.resolve("completed");
|
|
5867
|
+
await terminalEntered.promise;
|
|
5868
|
+
const end = harness.emit("agent_end", {
|
|
5869
|
+
messages: [{ role: "assistant", stopReason: "stop", content: "completed" }],
|
|
5870
|
+
});
|
|
5871
|
+
await Bun.sleep(0);
|
|
5872
|
+
releaseTerminal.resolve();
|
|
5873
|
+
await Promise.all([end, completionReconciled.promise]);
|
|
5874
|
+
expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject({
|
|
5875
|
+
status: "terminal_ok",
|
|
5876
|
+
content: { text: "completed" },
|
|
5877
|
+
});
|
|
5878
|
+
expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(1);
|
|
5879
|
+
} finally {
|
|
5880
|
+
completion.resolve("completed");
|
|
5881
|
+
releaseTerminal.resolve();
|
|
5882
|
+
await harness?.stop();
|
|
5883
|
+
await rm(cwd, { recursive: true, force: true });
|
|
5884
|
+
}
|
|
5885
|
+
});
|
|
5845
5886
|
test.each([
|
|
5846
5887
|
false,
|
|
5847
5888
|
true,
|
|
@@ -8488,8 +8529,15 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8488
8529
|
try {
|
|
8489
8530
|
harness = await invocationHarness(sessionId, cwd, {
|
|
8490
8531
|
settings: {
|
|
8532
|
+
// This test covers deferred terminal recovery, not deadline-triggered worktree persistence.
|
|
8491
8533
|
get: (key: string) =>
|
|
8492
|
-
key === "sdk.promptDeadlineMs"
|
|
8534
|
+
key === "sdk.promptDeadlineMs"
|
|
8535
|
+
? 150
|
|
8536
|
+
: key === "sdk.promptMaxRuntimeMs"
|
|
8537
|
+
? 60_000
|
|
8538
|
+
: key === "sdk.flushWorktreeOnDeadline"
|
|
8539
|
+
? false
|
|
8540
|
+
: undefined,
|
|
8493
8541
|
} as unknown as Settings,
|
|
8494
8542
|
sendUserMessage: async (_content, options) => {
|
|
8495
8543
|
await options?.onPreflightAcceptCommit?.();
|
|
@@ -8549,7 +8597,8 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8549
8597
|
});
|
|
8550
8598
|
|
|
8551
8599
|
activeTools.clear();
|
|
8552
|
-
|
|
8600
|
+
// Keep this inside the 60s hard-runtime budget while tolerating a slow CI retry tick.
|
|
8601
|
+
expect(await settledStatus(harness, "turn.prompt_status", correlation, 30_000)).toMatchObject({
|
|
8553
8602
|
status: "terminal_ok",
|
|
8554
8603
|
outcome: { kind: "stopped", reason: "cancelled" },
|
|
8555
8604
|
});
|
|
@@ -8559,7 +8608,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
|
|
|
8559
8608
|
await Bun.sleep(10);
|
|
8560
8609
|
await rm(cwd, { recursive: true, force: true });
|
|
8561
8610
|
}
|
|
8562
|
-
});
|
|
8611
|
+
}, 60_000);
|
|
8563
8612
|
|
|
8564
8613
|
test("an unproven real end stays private until exact settlement evidence exists", async () => {
|
|
8565
8614
|
// A matching lifecycle end still cannot settle a recovered deadline record
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
validateSettingPatch,
|
|
36
36
|
} from "../../config/settings";
|
|
37
37
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
|
|
38
|
+
import { tagSdkLifecycleObserver } from "../../extensibility/extensions/function-hooks-internal";
|
|
38
39
|
import type { AgentEndEvent } from "../../extensibility/shared-events";
|
|
39
40
|
import { normalizeGoal } from "../../goals/state";
|
|
40
41
|
import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
|
|
@@ -6046,40 +6047,43 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
6046
6047
|
resolveTerminalPublicationWaiters(observed, terminalPublicationByCorrelation);
|
|
6047
6048
|
}
|
|
6048
6049
|
};
|
|
6049
|
-
api.on(
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6050
|
+
api.on(
|
|
6051
|
+
"agent_start",
|
|
6052
|
+
tagSdkLifecycleObserver((event, ctx) => {
|
|
6053
|
+
const owner = lifecycleStateForEvent(ctx, "agent_start", event.sdkRunToken);
|
|
6054
|
+
// The activity checkpoint is already fire-and-forget and does not depend on
|
|
6055
|
+
// the handler awaiting trackLifecycle, so it composes with #5683 unchanged.
|
|
6056
|
+
void (owner?.runtime ?? lifecycleStateForContext(ctx, "agent_start")?.runtime)
|
|
6057
|
+
?.reportActivity("active")
|
|
6058
|
+
.catch(error => logger.warn(`sdk: active activity checkpoint failed: ${String(error)}`));
|
|
6059
|
+
// Interactive/skill turns must not wait on durable start persist. Keep
|
|
6060
|
+
// trackLifecycle so drain, persist-then-publish, content-hold release, and
|
|
6061
|
+
// shutdown still run; do not return that promise to the extension runner
|
|
6062
|
+
// (EXTENSION_HANDLER_TIMEOUT_MS would otherwise stall the prompt).
|
|
6063
|
+
void trackLifecycle(
|
|
6064
|
+
async () =>
|
|
6065
|
+
emitLifecycle(
|
|
6066
|
+
"agent_start",
|
|
6067
|
+
ctx,
|
|
6068
|
+
undefined,
|
|
6069
|
+
undefined,
|
|
6070
|
+
owner ? { state: owner, sessionId: owner.sessionId } : undefined,
|
|
6071
|
+
undefined,
|
|
6072
|
+
false,
|
|
6073
|
+
undefined,
|
|
6074
|
+
undefined,
|
|
6075
|
+
typeof event.sdkRunToken === "string" ? event.sdkRunToken : undefined,
|
|
6076
|
+
event.sdkRunTokens,
|
|
6077
|
+
event.lifecycleScope,
|
|
6078
|
+
),
|
|
6079
|
+
owner,
|
|
6080
|
+
).catch(error => {
|
|
6081
|
+
logger.error("SDK agent_start lifecycle task failed", {
|
|
6082
|
+
error: sanitizePromptFailure(error),
|
|
6083
|
+
});
|
|
6080
6084
|
});
|
|
6081
|
-
})
|
|
6082
|
-
|
|
6085
|
+
}),
|
|
6086
|
+
);
|
|
6083
6087
|
api.on("agent_end", (event, ctx) => {
|
|
6084
6088
|
const tokenBinding =
|
|
6085
6089
|
typeof event.sdkRunToken === "string" ? lifecycleRunOwners.get(event.sdkRunToken) : undefined;
|
|
@@ -6444,7 +6448,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
6444
6448
|
const seams = options.terminalAbortSeams;
|
|
6445
6449
|
const handle = seams?.getActivePromptHandle();
|
|
6446
6450
|
const epoch = seams?.getTerminalTurnEpoch();
|
|
6447
|
-
if (!seams || !handle || epoch === undefined
|
|
6451
|
+
if (!seams || !handle || epoch === undefined) return;
|
|
6448
6452
|
const existing = deadlineTerminalizationObservations.get(key);
|
|
6449
6453
|
if (existing) return () => cleanupDeadlineTerminalizationObservation(key, existing);
|
|
6450
6454
|
|
|
@@ -7075,7 +7079,33 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
|
|
|
7075
7079
|
() => acceptingGateResolutions,
|
|
7076
7080
|
trackGateResolution,
|
|
7077
7081
|
options.onInvocationCompletionReconciledForTests,
|
|
7078
|
-
frame =>
|
|
7082
|
+
frame => {
|
|
7083
|
+
// Completion can win the durable transaction while a lifecycle end is
|
|
7084
|
+
// still waiting on it. Both publishers must share the same claim.
|
|
7085
|
+
if (typeof frame.commandId !== "string" || typeof frame.turnId !== "string") return;
|
|
7086
|
+
const correlation = { commandId: frame.commandId, turnId: frame.turnId };
|
|
7087
|
+
if (frame.type !== "agent_end") {
|
|
7088
|
+
if (!hasClaimedTerminalBoundary(correlation)) runtime.emitEvent(frame);
|
|
7089
|
+
return;
|
|
7090
|
+
}
|
|
7091
|
+
const key = lifecycleCorrelationKey(correlation);
|
|
7092
|
+
if (!claimTerminalBoundary(correlation)) {
|
|
7093
|
+
const previouslyPublished = terminalBoundaryClaims.publicationResult(key) === true;
|
|
7094
|
+
if (previouslyPublished) return;
|
|
7095
|
+
terminalBoundaryClaims.releaseClaim(key);
|
|
7096
|
+
if (!claimTerminalBoundary(correlation)) return;
|
|
7097
|
+
}
|
|
7098
|
+
const sequenceBefore = runtime.host.events.sequence;
|
|
7099
|
+
try {
|
|
7100
|
+
runtime.emitEvent(frame);
|
|
7101
|
+
terminalBoundaryClaims.setPublicationResult(key, true);
|
|
7102
|
+
} catch (error) {
|
|
7103
|
+
if (runtime.host.events.sequence > sequenceBefore)
|
|
7104
|
+
terminalBoundaryClaims.setPublicationResult(key, true);
|
|
7105
|
+
else terminalBoundaryClaims.releaseClaim(key);
|
|
7106
|
+
throw error;
|
|
7107
|
+
}
|
|
7108
|
+
},
|
|
7079
7109
|
acceptedQueueCancellations,
|
|
7080
7110
|
);
|
|
7081
7111
|
const installProviderDefinitions = (capability: string, definitions: unknown): void => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { logger,
|
|
4
|
+
import { logger, pathIdentityKey } from "@gajae-code/utils";
|
|
5
5
|
import { endpointIncarnation, matchesIndexedEndpointFile } from "../broker/endpoint-authority";
|
|
6
6
|
import {
|
|
7
7
|
canonicalSessionCwd,
|
|
@@ -1532,13 +1532,14 @@ export class SessionRouter {
|
|
|
1532
1532
|
const cwd = indexed.locator.cwd;
|
|
1533
1533
|
const defaultStateRoot = path.join(cwd, ".gjc", "state");
|
|
1534
1534
|
// Locator cwd is already canonical. `stateRoot` remains the host-provided
|
|
1535
|
-
// authority path, so
|
|
1536
|
-
//
|
|
1537
|
-
|
|
1535
|
+
// authority path, so compare filesystem identity here: case-insensitive
|
|
1536
|
+
// Windows aliases match without rewriting I/O paths, while case-sensitive
|
|
1537
|
+
// directories remain distinct.
|
|
1538
|
+
const indexedStateRoot = pathIdentityKey(indexed.locator.stateRoot);
|
|
1538
1539
|
const scope =
|
|
1539
|
-
indexedStateRoot ===
|
|
1540
|
+
indexedStateRoot === pathIdentityKey(defaultStateRoot)
|
|
1540
1541
|
? "default"
|
|
1541
|
-
: indexedStateRoot ===
|
|
1542
|
+
: indexedStateRoot === pathIdentityKey(path.join(defaultStateRoot, "chat"))
|
|
1542
1543
|
? "chat"
|
|
1543
1544
|
: undefined;
|
|
1544
1545
|
if (!scope || indexed.endpointMtimeMs === undefined || !Number.isFinite(indexed.endpointMtimeMs)) return null;
|
package/src/sdk/session.ts
CHANGED
|
@@ -47,13 +47,8 @@ import {
|
|
|
47
47
|
providerSupportsAppendOnlyAuto,
|
|
48
48
|
resolveAppendOnlyMode,
|
|
49
49
|
} from "../append-only-mode";
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
AsyncJobManager,
|
|
53
|
-
asyncJobEndpointId as deriveAsyncJobEndpointId,
|
|
54
|
-
isBackgroundJobSupportEnabled,
|
|
55
|
-
jobElapsedMs,
|
|
56
|
-
} from "../async";
|
|
50
|
+
import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "../async";
|
|
51
|
+
import { asyncJobEndpointId as deriveAsyncJobEndpointId } from "../async/endpoint-id";
|
|
57
52
|
import { resolveBrowserBackend } from "../browser-backend";
|
|
58
53
|
import { loadCapability, reset as resetCapabilities } from "../capability";
|
|
59
54
|
import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";
|