@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
package/src/eval/py/kernel.ts
CHANGED
|
@@ -77,6 +77,26 @@ interface KernelStartOptions extends KernelLifecycleOptions {
|
|
|
77
77
|
env?: Record<string, string | undefined>;
|
|
78
78
|
runtimeOptions?: PythonRuntimeOptions;
|
|
79
79
|
settings?: Settings;
|
|
80
|
+
/** @internal Synchronously registers the actual spawned kernel before startup callbacks run. */
|
|
81
|
+
onKernelCreated?: (kernel: PythonKernel) => void;
|
|
82
|
+
/** @internal Routes partial-start cleanup through the executor's retained ownership. */
|
|
83
|
+
onStartupFailure?: (kernel: PythonKernel) => Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type PythonKernelStartupCleanupFailure =
|
|
87
|
+
| { kind: "unconfirmed"; result: KernelShutdownResult }
|
|
88
|
+
| { kind: "rejected"; error: unknown };
|
|
89
|
+
|
|
90
|
+
/** Startup failed after a real runner was created and physical cleanup failed. */
|
|
91
|
+
export class PythonKernelStartError extends Error {
|
|
92
|
+
constructor(
|
|
93
|
+
readonly kernel: PythonKernel,
|
|
94
|
+
readonly startupError: unknown,
|
|
95
|
+
readonly cleanupFailure: PythonKernelStartupCleanupFailure,
|
|
96
|
+
) {
|
|
97
|
+
super("Python kernel startup failed and runner cleanup was not confirmed", { cause: startupError });
|
|
98
|
+
this.name = "PythonKernelStartError";
|
|
99
|
+
}
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
interface KernelShutdownOptions {
|
|
@@ -108,6 +128,13 @@ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string)
|
|
|
108
128
|
throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
|
|
109
129
|
}
|
|
110
130
|
|
|
131
|
+
function throwIfLifecycleEnded(lifecycle: KernelLifecycleOptions, message: string): void {
|
|
132
|
+
throwIfAborted(lifecycle.signal, message);
|
|
133
|
+
if (lifecycle.deadlineMs !== undefined && lifecycle.deadlineMs <= Date.now()) {
|
|
134
|
+
throw createAbortError("TimeoutError", `${message} timed out`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
async function waitForLifecycle<T>(
|
|
112
139
|
value: Promise<T>,
|
|
113
140
|
lifecycle: PythonRuntimeLifecycleOptions | undefined,
|
|
@@ -244,6 +271,7 @@ export class PythonKernel {
|
|
|
244
271
|
#alive = true;
|
|
245
272
|
#disposed = false;
|
|
246
273
|
#shutdownConfirmed = false;
|
|
274
|
+
#shutdownPromise: Promise<KernelShutdownResult> | null = null;
|
|
247
275
|
#exitedPromise: Promise<number> | null = null;
|
|
248
276
|
#exitCode: number | null = null;
|
|
249
277
|
#stderrTail = "";
|
|
@@ -255,6 +283,7 @@ export class PythonKernel {
|
|
|
255
283
|
}
|
|
256
284
|
|
|
257
285
|
static async start(options: KernelStartOptions): Promise<PythonKernel> {
|
|
286
|
+
throwIfLifecycleEnded(options, "Python kernel startup");
|
|
258
287
|
const availability = await logger.time(
|
|
259
288
|
"PythonKernel.start:availabilityCheck",
|
|
260
289
|
checkPythonKernelAvailability,
|
|
@@ -263,17 +292,20 @@ export class PythonKernel {
|
|
|
263
292
|
{ signal: options.signal, deadlineMs: options.deadlineMs },
|
|
264
293
|
options.settings,
|
|
265
294
|
);
|
|
295
|
+
throwIfLifecycleEnded(options, "Python kernel startup");
|
|
266
296
|
if (!availability.ok) {
|
|
267
297
|
throw new Error(availability.reason ?? "Python kernel unavailable");
|
|
268
298
|
}
|
|
269
299
|
|
|
270
300
|
const settings = options.settings ?? (await Settings.init());
|
|
301
|
+
throwIfLifecycleEnded(options, "Python kernel startup");
|
|
271
302
|
const { env: shellEnv } = settings.getShellConfig();
|
|
272
303
|
const baseEnv = filterEnv(shellEnv);
|
|
273
304
|
const runtime = await ensurePythonRuntime(options.cwd, baseEnv, options.runtimeOptions, {
|
|
274
305
|
signal: options.signal,
|
|
275
306
|
deadlineMs: options.deadlineMs,
|
|
276
307
|
});
|
|
308
|
+
throwIfLifecycleEnded(options, "Python kernel startup");
|
|
277
309
|
const spawnEnv: Record<string, string> = {};
|
|
278
310
|
for (const [key, value] of Object.entries(runtime.env)) {
|
|
279
311
|
if (typeof value === "string") spawnEnv[key] = value;
|
|
@@ -286,6 +318,7 @@ export class PythonKernel {
|
|
|
286
318
|
spawnEnv.PYTHONIOENCODING = "utf-8";
|
|
287
319
|
|
|
288
320
|
const scriptPath = await ensureRunnerScript();
|
|
321
|
+
throwIfLifecycleEnded(options, "Python kernel startup");
|
|
289
322
|
const kernel = new PythonKernel(Snowflake.next());
|
|
290
323
|
|
|
291
324
|
const proc = Bun.spawn([runtime.pythonPath, "-u", scriptPath], {
|
|
@@ -302,25 +335,40 @@ export class PythonKernel {
|
|
|
302
335
|
kernel.#proc = proc;
|
|
303
336
|
kernel.#stdin = proc.stdin;
|
|
304
337
|
kernel.#exitedPromise = proc.exited;
|
|
305
|
-
void kernel.#exitedPromise.then(code => {
|
|
306
|
-
kernel.#exitCode = code;
|
|
307
|
-
kernel.#alive = false;
|
|
308
|
-
kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
|
|
309
|
-
});
|
|
310
338
|
|
|
311
|
-
|
|
312
|
-
|
|
339
|
+
try {
|
|
340
|
+
options.onKernelCreated?.(kernel);
|
|
341
|
+
void kernel.#exitedPromise.then(code => {
|
|
342
|
+
kernel.#exitCode = code;
|
|
343
|
+
kernel.#alive = false;
|
|
344
|
+
kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
|
|
345
|
+
});
|
|
313
346
|
|
|
314
|
-
|
|
315
|
-
|
|
347
|
+
kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
|
|
348
|
+
kernel.#startStderrDrain(proc.stderr as ReadableStream<Uint8Array>);
|
|
316
349
|
|
|
317
|
-
|
|
350
|
+
const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
|
|
351
|
+
const startupBudget = Math.min(
|
|
352
|
+
getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS,
|
|
353
|
+
STARTUP_TIMEOUT_MS,
|
|
354
|
+
);
|
|
318
355
|
const initScript = buildInitScript(options.cwd, options.env);
|
|
319
356
|
await kernel.#executeWithBudget(initScript, startup.signal, startupBudget, "Python kernel init");
|
|
320
357
|
await kernel.#executeWithBudget(PYTHON_PRELUDE, startup.signal, startupBudget, "Python kernel prelude");
|
|
321
358
|
return kernel;
|
|
322
359
|
} catch (err) {
|
|
323
|
-
|
|
360
|
+
let cleanupFailure: PythonKernelStartupCleanupFailure | undefined;
|
|
361
|
+
try {
|
|
362
|
+
if (options.onStartupFailure) {
|
|
363
|
+
await options.onStartupFailure(kernel);
|
|
364
|
+
} else {
|
|
365
|
+
const result = await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS });
|
|
366
|
+
if (!result.confirmed) cleanupFailure = { kind: "unconfirmed", result };
|
|
367
|
+
}
|
|
368
|
+
} catch (cleanupError) {
|
|
369
|
+
cleanupFailure = { kind: "rejected", error: cleanupError };
|
|
370
|
+
}
|
|
371
|
+
if (cleanupFailure) throw new PythonKernelStartError(kernel, err, cleanupFailure);
|
|
324
372
|
throw err;
|
|
325
373
|
}
|
|
326
374
|
}
|
|
@@ -475,9 +523,27 @@ export class PythonKernel {
|
|
|
475
523
|
}
|
|
476
524
|
}
|
|
477
525
|
|
|
478
|
-
|
|
479
|
-
if (this.#shutdownConfirmed) return { confirmed: true };
|
|
526
|
+
shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
|
|
527
|
+
if (this.#shutdownConfirmed) return Promise.resolve({ confirmed: true });
|
|
528
|
+
if (this.#shutdownPromise) return this.#shutdownPromise;
|
|
529
|
+
|
|
530
|
+
const { promise, resolve, reject } = Promise.withResolvers<KernelShutdownResult>();
|
|
531
|
+
this.#shutdownPromise = promise;
|
|
532
|
+
void promise.then(
|
|
533
|
+
() => {
|
|
534
|
+
if (this.#shutdownPromise === promise) this.#shutdownPromise = null;
|
|
535
|
+
},
|
|
536
|
+
() => {
|
|
537
|
+
if (this.#shutdownPromise === promise) this.#shutdownPromise = null;
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
void Promise.resolve()
|
|
541
|
+
.then(() => this.#shutdown(options))
|
|
542
|
+
.then(resolve, reject);
|
|
543
|
+
return promise;
|
|
544
|
+
}
|
|
480
545
|
|
|
546
|
+
async #shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
|
|
481
547
|
this.#alive = false;
|
|
482
548
|
this.#abortPendingExecutions("Python kernel shutdown", { kernelKilled: true });
|
|
483
549
|
|
|
@@ -72,6 +72,10 @@ async function startServer(): Promise<BridgeServer> {
|
|
|
72
72
|
if (!sessionId || !name) {
|
|
73
73
|
return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
|
|
74
74
|
}
|
|
75
|
+
const live = registrations.get(capability);
|
|
76
|
+
if (live !== registration || registration.signal?.aborted) {
|
|
77
|
+
return new Response("Forbidden", { status: 403 });
|
|
78
|
+
}
|
|
75
79
|
if (sessionId !== registration.sessionId) {
|
|
76
80
|
return new Response("Forbidden", { status: 403 });
|
|
77
81
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
const HTML_EXPORT_DESTINATION_OPEN_FLAGS =
|
|
4
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0));
|
|
5
|
+
|
|
6
|
+
/** Owner-only HTML export. A replaced file is locked down on the opened descriptor. */
|
|
7
|
+
export function openHtmlExportDestination(outputPath: string): number {
|
|
8
|
+
const fd = fs.openSync(outputPath, HTML_EXPORT_DESTINATION_OPEN_FLAGS, 0o600);
|
|
9
|
+
try {
|
|
10
|
+
fs.fchmodSync(fd, 0o600);
|
|
11
|
+
return fd;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
fs.closeSync(fd);
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/export/html/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/
|
|
|
7
7
|
import { SessionManager } from "../../session/session-manager";
|
|
8
8
|
// Pre-generated template (created by scripts/generate-template.ts at publish time)
|
|
9
9
|
import { cssColorOrFallback } from "./css-color";
|
|
10
|
+
import { openHtmlExportDestination } from "./html-export-destination";
|
|
10
11
|
import { TEMPLATE } from "./template.generated";
|
|
11
12
|
|
|
12
13
|
export interface ExportOptions {
|
|
@@ -169,11 +170,6 @@ const HTML_EXPORT_SOURCE_OPEN_FLAGS =
|
|
|
169
170
|
syncFs.constants.O_NONBLOCK |
|
|
170
171
|
(process.platform === "win32" ? 0 : (syncFs.constants.O_NOFOLLOW ?? 0));
|
|
171
172
|
|
|
172
|
-
const HTML_EXPORT_DESTINATION_OPEN_FLAGS =
|
|
173
|
-
syncFs.constants.O_WRONLY |
|
|
174
|
-
syncFs.constants.O_CREAT |
|
|
175
|
-
(process.platform === "win32" ? 0 : (syncFs.constants.O_NOFOLLOW ?? 0));
|
|
176
|
-
|
|
177
173
|
function sameFileIdentity(left: syncFs.BigIntStats, right: syncFs.BigIntStats): boolean {
|
|
178
174
|
return left.dev === right.dev && left.ino === right.ino;
|
|
179
175
|
}
|
|
@@ -193,7 +189,7 @@ async function openHtmlExportDescriptors(
|
|
|
193
189
|
}
|
|
194
190
|
await assertExportDoesNotAliasSource(sourcePath, outputPath);
|
|
195
191
|
}
|
|
196
|
-
destination =
|
|
192
|
+
destination = openHtmlExportDestination(outputPath);
|
|
197
193
|
if (source !== undefined) {
|
|
198
194
|
const sourceIdentity = syncFs.fstatSync(source, { bigint: true });
|
|
199
195
|
const destinationIdentity = syncFs.fstatSync(destination, { bigint: true });
|
|
@@ -4,6 +4,30 @@ import { type FunctionHookRegistration, validateFunctionHookTarget } from "./fun
|
|
|
4
4
|
|
|
5
5
|
export type TaggedFunctionHookHandler = (...args: unknown[]) => Promise<unknown>;
|
|
6
6
|
|
|
7
|
+
// Host SDK lifecycle observers publish the accepted run's bookkeeping. They do
|
|
8
|
+
// not execute user work and must not consume provider-attempt replay authority.
|
|
9
|
+
const sdkLifecycleObservers = new WeakSet<(...args: never[]) => unknown>();
|
|
10
|
+
const providerRequestHandlers = new WeakMap<(...args: never[]) => unknown, string>();
|
|
11
|
+
|
|
12
|
+
/** Host-owned provider adapters must not consume replay authority for other providers. */
|
|
13
|
+
export function tagProviderRequestHandler<T extends (...args: never[]) => unknown>(provider: string, handler: T): T {
|
|
14
|
+
providerRequestHandlers.set(handler, provider);
|
|
15
|
+
return handler;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getProviderRequestHandlerProvider(handler: (...args: never[]) => unknown): string | undefined {
|
|
19
|
+
return providerRequestHandlers.get(handler);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function tagSdkLifecycleObserver<T extends (...args: never[]) => unknown>(handler: T): T {
|
|
23
|
+
sdkLifecycleObservers.add(handler);
|
|
24
|
+
return handler;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isSdkLifecycleObserver(handler: (...args: never[]) => unknown): boolean {
|
|
28
|
+
return sdkLifecycleObservers.has(handler);
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
const registrations = new WeakMap<TaggedFunctionHookHandler, FunctionHookRegistration>();
|
|
8
32
|
const registrationOrders = new WeakMap<(...args: never[]) => unknown, number>();
|
|
9
33
|
|
|
@@ -21,6 +45,9 @@ export function wrapExtensionHandlerRegistration<T extends (...args: never[]) =>
|
|
|
21
45
|
): T {
|
|
22
46
|
const registered = ((...args: never[]) => handler(...args)) as T;
|
|
23
47
|
registrationOrders.set(registered, registrationOrder);
|
|
48
|
+
if (sdkLifecycleObservers.has(handler)) sdkLifecycleObservers.add(registered);
|
|
49
|
+
const provider = providerRequestHandlers.get(handler);
|
|
50
|
+
if (provider !== undefined) providerRequestHandlers.set(registered, provider);
|
|
24
51
|
return registered;
|
|
25
52
|
}
|
|
26
53
|
|
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
getExtensionHandlerRegistrationOrder,
|
|
53
53
|
getFunctionHookRegistration,
|
|
54
|
+
getProviderRequestHandlerProvider,
|
|
55
|
+
isSdkLifecycleObserver,
|
|
54
56
|
readConstrainedFunctionHookFile,
|
|
55
57
|
} from "./function-hooks-internal";
|
|
56
58
|
import type {
|
|
@@ -918,6 +920,13 @@ export class ExtensionRunner {
|
|
|
918
920
|
const wildcard = this.#handlersByEvent.get("*") ?? [];
|
|
919
921
|
const matches: IndexedHandler[] = [];
|
|
920
922
|
for (const indexed of [...exact, ...wildcard]) {
|
|
923
|
+
const provider = getProviderRequestHandlerProvider(indexed.handler);
|
|
924
|
+
if (
|
|
925
|
+
event.type === "before_provider_request" &&
|
|
926
|
+
provider !== undefined &&
|
|
927
|
+
this.createContext().model?.provider !== provider
|
|
928
|
+
)
|
|
929
|
+
continue;
|
|
921
930
|
const registration = getFunctionHookRegistration(indexed.handler);
|
|
922
931
|
if (!registration) {
|
|
923
932
|
if (includeLegacyToolHandlers && indexed.registrationOrder >= 0 && exact.includes(indexed))
|
|
@@ -1076,7 +1085,8 @@ export class ExtensionRunner {
|
|
|
1076
1085
|
if (handlers.length === 0) return { action: "continue", event };
|
|
1077
1086
|
const includesLegacy = handlers.some(indexed => getFunctionHookRegistration(indexed.handler) === undefined);
|
|
1078
1087
|
if (includesLegacy) this.#requireScopeOrFailClosed(options.scope, event.type);
|
|
1079
|
-
|
|
1088
|
+
if (handlers.some(indexed => event.type !== "agent_start" || !isSdkLifecycleObserver(indexed.handler)))
|
|
1089
|
+
this.#markAttemptExecuted(options.scope);
|
|
1080
1090
|
const functionHookDepth = this.#functionHookDepth.getStore() ?? 0;
|
|
1081
1091
|
if (functionHookDepth >= 16) {
|
|
1082
1092
|
const reason = "Function hook re-entry depth exceeded";
|
|
@@ -6,7 +6,7 @@ import { GjcPluginLoadError, type GjcPluginMcpManifestEntry } from "./types";
|
|
|
6
6
|
/**
|
|
7
7
|
* Shared MCP security policy applied at BOTH install validation and runtime
|
|
8
8
|
* connect for third-party plugin-bundle MCP servers. Defaults are deny-first:
|
|
9
|
-
* HTTPS only, no private/loopback/link-local/metadata endpoints, stdio confined
|
|
9
|
+
* HTTPS only, no private/loopback/link-local/CGNAT/metadata endpoints, stdio confined
|
|
10
10
|
* to the plugin root.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -34,6 +34,7 @@ export function isDeniedIpv4(host: string): boolean {
|
|
|
34
34
|
if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
|
|
35
35
|
if (a === 192 && b === 168) return true; // private 192.168.0.0/16
|
|
36
36
|
if (a === 169 && b === 254) return true; // link-local 169.254.0.0/16 (incl 169.254.169.254 metadata)
|
|
37
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
|
|
37
38
|
if (a === 0) return true; // 0.0.0.0/8 unspecified/this-network
|
|
38
39
|
if (a >= 224) return true; // multicast/reserved 224.0.0.0/4 and 240.0.0.0/4
|
|
39
40
|
return false;
|
|
@@ -172,6 +172,8 @@ const initialNodeIdentities = initialProcessEnvironment.then(async environment =
|
|
|
172
172
|
const lexical = path.join(pathEntry, process.platform === "win32" ? "node.exe" : "node");
|
|
173
173
|
try {
|
|
174
174
|
const real = await fs.realpath(lexical);
|
|
175
|
+
// A temporary root is writable by other local users and by checkouts the user
|
|
176
|
+
// never vetted, so a `node` resolved there never becomes launch authority.
|
|
175
177
|
if (temporaryRoots.some(root => isWithin(root, real))) continue;
|
|
176
178
|
if (identities.has(real)) continue;
|
|
177
179
|
const stat = await fs.stat(real);
|
|
@@ -189,7 +191,11 @@ const initialNodeIdentities = initialProcessEnvironment.then(async environment =
|
|
|
189
191
|
return identities;
|
|
190
192
|
});
|
|
191
193
|
|
|
192
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* First-use digests of startup Node candidates, keyed by realpath. Successful and
|
|
196
|
+
* in-flight digests stay memoized; a failed attempt is evicted so a transient error
|
|
197
|
+
* (e.g. EMFILE) can be retried against the same startup identity.
|
|
198
|
+
*/
|
|
193
199
|
const initialNodeHashes = new Map<string, Promise<string | undefined>>();
|
|
194
200
|
|
|
195
201
|
/**
|
|
@@ -197,20 +203,26 @@ const initialNodeHashes = new Map<string, Promise<string | undefined>>();
|
|
|
197
203
|
* was not a startup candidate or no longer is the startup file object.
|
|
198
204
|
*/
|
|
199
205
|
export function getInitialNodeHash(realPath: string): Promise<string | undefined> {
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
const cached = initialNodeHashes.get(realPath);
|
|
207
|
+
if (cached) return cached;
|
|
208
|
+
|
|
209
|
+
const attempt = initialNodeIdentities.then(async identities => {
|
|
210
|
+
const identity = identities.get(realPath);
|
|
211
|
+
if (!identity) return undefined;
|
|
212
|
+
try {
|
|
213
|
+
return await hashStableFile(realPath, "Initial Node executable", MCP_LAUNCHER_MAX_BYTES, identity);
|
|
214
|
+
} catch {
|
|
215
|
+
// Do not memoize failed attempts to allow retries on transient errors
|
|
216
|
+
// (e.g., EMFILE on file open/read). Remove the pending entry so the next
|
|
217
|
+
// call can retry the operation with the same startup identity.
|
|
218
|
+
initialNodeHashes.delete(realPath);
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// Memoize the in-flight attempt so only one concurrent operation runs, but
|
|
223
|
+
// the entry may be deleted if the operation fails (to allow retry).
|
|
224
|
+
initialNodeHashes.set(realPath, attempt);
|
|
225
|
+
return attempt;
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
async function snapshotExistingFile(filePath: string): Promise<FileSnapshot | null> {
|
|
@@ -56,6 +56,7 @@ export type SkillDisabledReason = "protected" | "scope-trust" | "ignored" | "inc
|
|
|
56
56
|
export interface ManagedSkillRecord {
|
|
57
57
|
name: string;
|
|
58
58
|
description: string;
|
|
59
|
+
importedFrom?: string;
|
|
59
60
|
path: string;
|
|
60
61
|
scope: SkillScope;
|
|
61
62
|
/** Canonical source label, e.g. "project .gjc/skills" or "user ~/.gjc/agent/skills". */
|
|
@@ -392,9 +393,11 @@ export async function listNativeSkillsForManagement(options: {
|
|
|
392
393
|
|
|
393
394
|
if (!seenNames.has(skill.name)) {
|
|
394
395
|
seenNames.add(skill.name);
|
|
396
|
+
const importedFrom = skill.frontmatter?.xGjcImportedFrom ?? skill.frontmatter?.["x-gjc-imported-from"];
|
|
395
397
|
records.push({
|
|
396
398
|
name: skill.name,
|
|
397
399
|
description: typeof skill.frontmatter?.description === "string" ? skill.frontmatter.description : "",
|
|
400
|
+
...(typeof importedFrom === "string" ? { importedFrom } : {}),
|
|
398
401
|
path: skill.discoveryPath ?? skill.path,
|
|
399
402
|
scope,
|
|
400
403
|
source,
|
|
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
5
6
|
import { shortenPath } from "../tools/render-utils";
|
|
6
7
|
|
|
7
8
|
export type GjcLaunchWorktreeMode =
|
|
@@ -175,6 +176,8 @@ function sanitizePathToken(value: string): string {
|
|
|
175
176
|
* beside the first.
|
|
176
177
|
*/
|
|
177
178
|
const WORKTREE_BUCKET_ENV = "GJC_WORKTREE_DIR";
|
|
179
|
+
/** Captured when this module loads, before a later `chdir` or dotenv delete. */
|
|
180
|
+
const startupWorktreeBucketSnapshot = projectEnvSnapshot();
|
|
178
181
|
/**
|
|
179
182
|
* Expands to the repository directory name.
|
|
180
183
|
*
|
|
@@ -210,15 +213,35 @@ export function resolveWorktreeBucketForPath(
|
|
|
210
213
|
);
|
|
211
214
|
}
|
|
212
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Operator override only. Bun has already copied the project dotenv into the
|
|
218
|
+
* environment, so a value that matches `projectEnvSnapshot` — a static hit or
|
|
219
|
+
* a dynamic declaration — must not select the bucket. An operator value the
|
|
220
|
+
* project does not declare is kept.
|
|
221
|
+
*/
|
|
222
|
+
function trustedWorktreeBucketEnv(env: NodeJS.ProcessEnv): string | undefined {
|
|
223
|
+
const raw = env[WORKTREE_BUCKET_ENV];
|
|
224
|
+
if (!raw) return undefined;
|
|
225
|
+
const snapshot = startupWorktreeBucketSnapshot;
|
|
226
|
+
const key = canonicalEnvKey(WORKTREE_BUCKET_ENV);
|
|
227
|
+
const declared = snapshot.values[key];
|
|
228
|
+
const trimmed = raw.trim();
|
|
229
|
+
if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
213
235
|
/**
|
|
214
236
|
* Directory that holds this repository's launch worktrees.
|
|
215
237
|
*
|
|
216
238
|
* A relative override resolves against the repository's parent directory. The default
|
|
217
239
|
* `{repo}/.worktrees` template places managed worktrees inside the repository, while
|
|
218
240
|
* `{repo}.worktrees` adopts an existing sibling bucket. An absolute override is used verbatim.
|
|
241
|
+
* A project dotenv value is ignored; an operator value the project does not declare is kept.
|
|
219
242
|
*/
|
|
220
243
|
function resolveWorktreeBucket(repoRoot: string): string {
|
|
221
|
-
return resolveWorktreeBucketForPath(repoRoot, process.env
|
|
244
|
+
return resolveWorktreeBucketForPath(repoRoot, trustedWorktreeBucketEnv(process.env), os.homedir(), path);
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
|
|
@@ -454,6 +477,36 @@ function isWorktreeDirty(worktreePath: string): boolean {
|
|
|
454
477
|
return runGit(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
455
478
|
}
|
|
456
479
|
|
|
480
|
+
/** Porcelain status is clean when the only unique state is commits on this detached HEAD. */
|
|
481
|
+
function isCommitAncestor(cwd: string, ancestor: string, descendant: string): boolean {
|
|
482
|
+
const result = Bun.spawnSync(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
483
|
+
cwd,
|
|
484
|
+
stdout: "ignore",
|
|
485
|
+
stderr: "pipe",
|
|
486
|
+
});
|
|
487
|
+
if (result.exitCode === 0) return true;
|
|
488
|
+
if (result.exitCode === 1) return false;
|
|
489
|
+
const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
|
|
490
|
+
throw new Error(stderr || "git merge-base --is-ancestor failed");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function formatWorktreeDiverged(worktreePath: string, existingHead: string, baseRef: string): string {
|
|
494
|
+
return [
|
|
495
|
+
`worktree_diverged:${worktreePath}`,
|
|
496
|
+
`GJC refused to move detached launch worktree ${existingHead} onto ${baseRef} because that commit is not an ancestor of the source HEAD.`,
|
|
497
|
+
`Path: ${worktreePath}`,
|
|
498
|
+
"Checking out the source HEAD would drop that commit from this worktree. Safe remediation: create a branch at HEAD so the commit stays reachable (git branch <name>), then retarget or remove the worktree yourself if a later launch should reuse this path. GJC did not change HEAD.",
|
|
499
|
+
].join("\n");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function reuseDetachedLaunchHead(plan: GjcLaunchWorktreePlan, existingHead: string, dirty: boolean): void {
|
|
503
|
+
if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
|
|
504
|
+
if (!isCommitAncestor(plan.worktreePath, existingHead, plan.baseRef)) {
|
|
505
|
+
throw new Error(formatWorktreeDiverged(plan.worktreePath, existingHead, plan.baseRef));
|
|
506
|
+
}
|
|
507
|
+
runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
|
|
508
|
+
}
|
|
509
|
+
|
|
457
510
|
function resolveOptionalWorktreeName(args: string[], index: number): { name: string | null; nextIndex: number } {
|
|
458
511
|
const next = args[index + 1];
|
|
459
512
|
if (!next) return { name: null, nextIndex: index };
|
|
@@ -781,8 +834,7 @@ function ensureLaunchWorktreeSync(
|
|
|
781
834
|
throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
|
|
782
835
|
}
|
|
783
836
|
if (existingAtPath.head !== plan.baseRef) {
|
|
784
|
-
|
|
785
|
-
runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
|
|
837
|
+
reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
|
|
786
838
|
dirty = false;
|
|
787
839
|
}
|
|
788
840
|
} else if (existingAtPath.branchRef !== expectedBranchRef) {
|
|
@@ -877,8 +929,7 @@ export async function ensureLaunchWorktreeCancellable(
|
|
|
877
929
|
throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
|
|
878
930
|
}
|
|
879
931
|
if (existingAtPath.head !== plan.baseRef) {
|
|
880
|
-
|
|
881
|
-
runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
|
|
932
|
+
reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
|
|
882
933
|
dirty = false;
|
|
883
934
|
}
|
|
884
935
|
} else if (existingAtPath.branchRef !== expectedBranchRef) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Windows psmux detection and tmux-binary resolution.
|
|
@@ -305,17 +306,40 @@ export interface ResolvedTmuxBinary {
|
|
|
305
306
|
viaExplicitOverride: boolean;
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
|
|
310
|
+
/** Captured when this module loads, before a later `chdir` or dotenv delete. */
|
|
311
|
+
const startupTmuxCommandSnapshot = projectEnvSnapshot();
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Operator override only. Bun has already copied the project dotenv into the
|
|
315
|
+
* environment, so a value that matches `projectEnvSnapshot` — a static hit or
|
|
316
|
+
* a dynamic declaration — must not select the binary. An operator value the
|
|
317
|
+
* project does not declare is kept.
|
|
318
|
+
*/
|
|
319
|
+
function trustedTmuxCommand(env: NodeJS.ProcessEnv): string | undefined {
|
|
320
|
+
const raw = env[GJC_TMUX_COMMAND_ENV];
|
|
321
|
+
if (!raw) return undefined;
|
|
322
|
+
const snapshot = startupTmuxCommandSnapshot;
|
|
323
|
+
const key = canonicalEnvKey(GJC_TMUX_COMMAND_ENV);
|
|
324
|
+
const declared = snapshot.values[key];
|
|
325
|
+
const trimmed = raw.trim();
|
|
326
|
+
if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
308
332
|
/**
|
|
309
|
-
* Resolve the tmux command GJC should invoke. Honors
|
|
310
|
-
* GJC_TMUX_COMMAND override;
|
|
311
|
-
* override is set, psmux (installed as psmux, pmux, or tmux) is picked
|
|
333
|
+
* Resolve the tmux command GJC should invoke. Honors an operator
|
|
334
|
+
* GJC_TMUX_COMMAND override; a project dotenv value is ignored. On Windows
|
|
335
|
+
* when no override is set, psmux (installed as psmux, pmux, or tmux) is picked
|
|
312
336
|
* automatically so the default gjc --tmux flow lands on a real multiplexer.
|
|
313
337
|
*/
|
|
314
338
|
export function resolveGjcTmuxBinary(options: ResolveGjcTmuxBinaryOptions = {}): ResolvedTmuxBinary {
|
|
315
339
|
const env = options.env ?? process.env;
|
|
316
340
|
const platform = options.platform ?? process.platform;
|
|
317
341
|
const runner = options.runner ?? readSpawnRunner();
|
|
318
|
-
const explicit = env
|
|
342
|
+
const explicit = trustedTmuxCommand(env);
|
|
319
343
|
if (explicit) {
|
|
320
344
|
const isPsmux =
|
|
321
345
|
platform === "win32"
|
|
@@ -22,6 +22,7 @@ import { reduceTerminalReceiptState } from "../sdk/receipt-state";
|
|
|
22
22
|
import { TOOL_CATALOG } from "../tools/tool-catalog.generated";
|
|
23
23
|
import { sessionRoot, sessionRuntimeDir, sessionRuntimeStatePath } from "./session-layout";
|
|
24
24
|
import { SessionStateLockUnavailableError, withSessionStateFileLock } from "./session-state-lock";
|
|
25
|
+
import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
|
|
25
26
|
import {
|
|
26
27
|
isValidOwnerIntent,
|
|
27
28
|
lifecyclePaths,
|
|
@@ -52,9 +53,9 @@ export const GJC_COORDINATOR_SIDECAR_SIGNATURE_REQUIRED_ENV = "GJC_COORDINATOR_S
|
|
|
52
53
|
* children. The process environment is deliberately scrubbed synchronously even when the
|
|
53
54
|
* key is malformed, so a rejected launch cannot leak it to descendants.
|
|
54
55
|
*/
|
|
55
|
-
const coordinatorSidecarSigningBootstrapUrl =
|
|
56
|
+
const coordinatorSidecarSigningBootstrapUrl = trustedCoordinatorEnv(GJC_COORDINATOR_SIDECAR_BOOTSTRAP_URL_ENV);
|
|
56
57
|
const coordinatorSidecarSigningKeyBootstrap = await (async (): Promise<string | undefined> => {
|
|
57
|
-
if (!coordinatorSidecarSigningBootstrapUrl) return
|
|
58
|
+
if (!coordinatorSidecarSigningBootstrapUrl) return trustedCoordinatorEnv(GJC_COORDINATOR_SIDECAR_SIGNING_KEY_ENV);
|
|
58
59
|
try {
|
|
59
60
|
const response = await fetch(coordinatorSidecarSigningBootstrapUrl);
|
|
60
61
|
if (!response.ok) return undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operator environment only. A value that matches the project dotenv snapshot
|
|
5
|
+
* was loaded by Bun from the repository and must not become signing material.
|
|
6
|
+
*/
|
|
7
|
+
export function trustedCoordinatorEnv(
|
|
8
|
+
name: string,
|
|
9
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
10
|
+
snapshot: ProjectEnvSnapshot = projectEnvSnapshot(),
|
|
11
|
+
): string | undefined {
|
|
12
|
+
const raw = env[name];
|
|
13
|
+
if (!raw) return undefined;
|
|
14
|
+
const key = canonicalEnvKey(name);
|
|
15
|
+
const declared = snapshot.values[key];
|
|
16
|
+
if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return undefined;
|
|
17
|
+
const trimmed = raw.trim();
|
|
18
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
19
|
+
}
|
|
@@ -310,19 +310,33 @@ export function stampWorkflowEnvelopeChecksum<T>(value: T, filePath: string, com
|
|
|
310
310
|
return envelope as T;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
export
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
313
|
+
export type WorkflowEnvelopeChecksumStatus = "absent" | "match" | "mismatch";
|
|
314
|
+
|
|
315
|
+
/** Compare a parsed envelope to its writer-stamped `content_sha256`, when one is present. */
|
|
316
|
+
export function workflowEnvelopeChecksumStatus(value: unknown): WorkflowEnvelopeChecksumStatus {
|
|
317
|
+
const checksum = workflowEnvelopeChecksumPair(value);
|
|
318
|
+
if (!checksum) return "absent";
|
|
319
|
+
return checksum.actual === checksum.expected ? "match" : "mismatch";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function workflowEnvelopeChecksumPair(value: unknown): { expected: string; actual: string } | undefined {
|
|
323
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
324
|
+
const receipt = (value as Record<string, unknown>).receipt;
|
|
319
325
|
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return undefined;
|
|
320
326
|
const checksum = (receipt as Record<string, unknown>).content_sha256;
|
|
321
327
|
if (!checksum || typeof checksum !== "object" || Array.isArray(checksum)) return undefined;
|
|
322
328
|
const expected = (checksum as Record<string, unknown>).value;
|
|
323
329
|
if (typeof expected !== "string" || !expected) return undefined;
|
|
324
|
-
|
|
325
|
-
|
|
330
|
+
return { expected, actual: workflowEnvelopeContentSha256(value) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function detectWorkflowEnvelopeIntegrityMismatch(
|
|
334
|
+
filePath: string,
|
|
335
|
+
): Promise<WorkflowEnvelopeIntegrityMismatch | undefined> {
|
|
336
|
+
const current = await readJsonIfPresent(filePath);
|
|
337
|
+
const checksum = workflowEnvelopeChecksumPair(current);
|
|
338
|
+
if (!checksum || checksum.actual === checksum.expected) return undefined;
|
|
339
|
+
return { path: filePath, expected: checksum.expected, actual: checksum.actual };
|
|
326
340
|
}
|
|
327
341
|
|
|
328
342
|
function safeString(value: unknown): string {
|