@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
|
@@ -1280,8 +1280,8 @@ export function retainManagedDirectoryAuthority(
|
|
|
1280
1280
|
expected?: { dev: bigint; ino: bigint },
|
|
1281
1281
|
): RecoveryFsRoot | undefined {
|
|
1282
1282
|
assertManagedDirectoryRoot(root);
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1283
|
+
const resolved = canonicalizeManagedPathWithinRoot(root.canonicalPath, directory);
|
|
1284
|
+
const relative = managedRelativePath(root, resolved).join("/");
|
|
1285
1285
|
if (process.platform !== "linux") return undefined;
|
|
1286
1286
|
const named = fs.lstatSync(resolved, { bigint: true });
|
|
1287
1287
|
if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("Managed directory authority is unavailable");
|
|
@@ -1297,7 +1297,6 @@ export function retainManagedDirectoryAuthority(
|
|
|
1297
1297
|
retainedRoot.identity.ino !== root.ino.toString()
|
|
1298
1298
|
)
|
|
1299
1299
|
throw new Error("Managed root authority changed");
|
|
1300
|
-
const relative = path.relative(root.canonicalPath, resolved).split(path.sep).join("/");
|
|
1301
1300
|
const rootRecovery = rootAuthority.recoveryReaperMetrics();
|
|
1302
1301
|
const rootRecoveryUnhealthy = !rootRecovery.ok || Boolean(rootRecovery.code);
|
|
1303
1302
|
if (rootRecoveryUnhealthy)
|
|
@@ -1366,14 +1365,79 @@ function ensureManagedRoot(root: ManagedDirectoryRoot): void {
|
|
|
1366
1365
|
assertManagedDirectoryRoot(root);
|
|
1367
1366
|
}
|
|
1368
1367
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1368
|
+
/** Resolve aliases above a managed root without following descendant symlinks. */
|
|
1369
|
+
function canonicalizeManagedPathWithinRoot(rootPath: string, pathname: string): string {
|
|
1370
|
+
const canonicalRootPath = path.resolve(rootPath);
|
|
1371
|
+
const namedRoot = fs.lstatSync(canonicalRootPath);
|
|
1372
|
+
if (!namedRoot.isDirectory() || namedRoot.isSymbolicLink())
|
|
1373
|
+
throw new Error(`Managed root authority changed: ${canonicalRootPath}`);
|
|
1374
|
+
const resolvedPath = path.resolve(pathname);
|
|
1375
|
+
const filesystemRoot = path.parse(resolvedPath).root;
|
|
1376
|
+
const components = path.relative(filesystemRoot, resolvedPath).split(path.sep).filter(Boolean);
|
|
1377
|
+
let currentPath = filesystemRoot;
|
|
1378
|
+
let reachedManagedRoot = currentPath === canonicalRootPath;
|
|
1379
|
+
if (reachedManagedRoot) currentPath = canonicalRootPath;
|
|
1380
|
+
|
|
1381
|
+
for (let index = 0; index < components.length; index++) {
|
|
1382
|
+
const component = components[index]!;
|
|
1383
|
+
const nextPath = path.join(currentPath, component);
|
|
1384
|
+
if (!reachedManagedRoot) {
|
|
1385
|
+
let canonicalPrefix: string;
|
|
1386
|
+
try {
|
|
1387
|
+
canonicalPrefix = fs.realpathSync.native(nextPath);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1390
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
1391
|
+
throw new Error(`Managed path escapes configured root: ${pathname}`, { cause: error });
|
|
1392
|
+
throw error;
|
|
1393
|
+
}
|
|
1394
|
+
if (canonicalPrefix === canonicalRootPath) {
|
|
1395
|
+
reachedManagedRoot = true;
|
|
1396
|
+
currentPath = canonicalRootPath;
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
currentPath = nextPath;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
let named: fs.Stats;
|
|
1404
|
+
try {
|
|
1405
|
+
named = fs.lstatSync(nextPath);
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1408
|
+
if (code === "ENOENT") return path.join(currentPath, ...components.slice(index));
|
|
1409
|
+
throw error;
|
|
1410
|
+
}
|
|
1411
|
+
if (named.isSymbolicLink()) throw new Error(`Managed path contains symlink: ${nextPath}`);
|
|
1412
|
+
if (index < components.length - 1 && !named.isDirectory())
|
|
1413
|
+
throw new Error(`Managed path component is not a directory: ${nextPath}`);
|
|
1414
|
+
currentPath = nextPath;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (!reachedManagedRoot) throw new Error(`Managed path escapes configured root: ${pathname}`);
|
|
1418
|
+
return currentPath;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function relativeWithinManagedRoot(rootPath: string, pathname: string): readonly string[] {
|
|
1422
|
+
const canonicalPath = canonicalizeManagedPathWithinRoot(rootPath, pathname);
|
|
1423
|
+
const relative = path.relative(rootPath, canonicalPath);
|
|
1424
|
+
if (process.platform === "win32") {
|
|
1425
|
+
const canonicalRoot = path.win32.normalize(rootPath);
|
|
1426
|
+
const canonicalCandidate = path.win32.normalize(canonicalPath);
|
|
1427
|
+
const rootPrefix = canonicalRoot.endsWith(path.win32.sep) ? canonicalRoot : `${canonicalRoot}${path.win32.sep}`;
|
|
1428
|
+
if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(rootPrefix))
|
|
1429
|
+
throw new Error(`Managed path escapes configured root: ${pathname}`);
|
|
1430
|
+
}
|
|
1371
1431
|
if (relative === "") return [];
|
|
1372
1432
|
if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
|
|
1373
1433
|
throw new Error(`Managed path escapes configured root: ${pathname}`);
|
|
1374
1434
|
return relative.split(path.sep);
|
|
1375
1435
|
}
|
|
1376
1436
|
|
|
1437
|
+
function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
|
|
1438
|
+
return relativeWithinManagedRoot(root.canonicalPath, pathname);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1377
1441
|
const PROCESS_START_ID = randomUUID();
|
|
1378
1442
|
|
|
1379
1443
|
class ManagedSecurityError extends Error {
|
|
@@ -1538,16 +1602,46 @@ export class ManagedSessionDescendantStore {
|
|
|
1538
1602
|
if (access === "read-only" && retained) throw new Error("managed_read_store_cannot_borrow_authority");
|
|
1539
1603
|
if (access === "read-only" && !expectedSubtreeRoot)
|
|
1540
1604
|
throw new Error("managed_read_store_requires_existing_identity");
|
|
1541
|
-
|
|
1605
|
+
assertManagedDirectoryRoot(root);
|
|
1606
|
+
let canonicalBaseDir: string;
|
|
1607
|
+
try {
|
|
1608
|
+
canonicalBaseDir = canonicalizeManagedPathWithinRoot(root.canonicalPath, baseDir);
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
// If canonicalization fails with a symlink error on a retained path,
|
|
1611
|
+
// the binding has changed: the path no longer resolves to the original inode.
|
|
1612
|
+
if (retained && error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
|
|
1613
|
+
throw new Error("Managed descendant root binding changed");
|
|
1614
|
+
}
|
|
1615
|
+
throw error;
|
|
1616
|
+
}
|
|
1617
|
+
let canonicalAuthorityBaseDir: string;
|
|
1618
|
+
if (retained) {
|
|
1619
|
+
try {
|
|
1620
|
+
canonicalAuthorityBaseDir = canonicalizeManagedPathWithinRoot(
|
|
1621
|
+
root.canonicalPath,
|
|
1622
|
+
retained.authorityBaseDir,
|
|
1623
|
+
);
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
// If canonicalization fails with a symlink error on a retained path,
|
|
1626
|
+
// the binding has changed: the path no longer resolves to the original inode.
|
|
1627
|
+
if (error instanceof Error && error.message.startsWith("Managed path contains symlink:")) {
|
|
1628
|
+
throw new Error("Managed descendant root binding changed");
|
|
1629
|
+
}
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
} else {
|
|
1633
|
+
canonicalAuthorityBaseDir = canonicalBaseDir;
|
|
1634
|
+
}
|
|
1635
|
+
managedRelativePath(root, canonicalBaseDir);
|
|
1542
1636
|
|
|
1543
1637
|
this.#root = root;
|
|
1544
|
-
this.#baseDir =
|
|
1638
|
+
this.#baseDir = canonicalBaseDir;
|
|
1545
1639
|
this.#policy = policy ?? "default";
|
|
1546
1640
|
this.#access = access;
|
|
1547
1641
|
this.#profileAgentDir = profileAgentDir ?? root.canonicalPath;
|
|
1548
|
-
this.#authorityBaseDir =
|
|
1642
|
+
this.#authorityBaseDir = canonicalAuthorityBaseDir;
|
|
1549
1643
|
if (retained) {
|
|
1550
|
-
const relative =
|
|
1644
|
+
const relative = relativeWithinManagedRoot(canonicalAuthorityBaseDir, this.#baseDir).join("/");
|
|
1551
1645
|
// For the root case (authorityBaseDir === baseDir, relative === ""), the
|
|
1552
1646
|
// stable identity() result carries the exact root dev/inode without
|
|
1553
1647
|
// snapshotting the entire live session tree. snapshotManagedTree("")
|
|
@@ -2668,12 +2762,18 @@ export class ManagedSessionDescendantStore {
|
|
|
2668
2762
|
canonicalFileId(rootBefore.ino) !== this.#subtreeRoot.ino
|
|
2669
2763
|
)
|
|
2670
2764
|
throw new Error("Managed descendant root binding changed");
|
|
2671
|
-
let fd: number
|
|
2765
|
+
let fd: number;
|
|
2672
2766
|
try {
|
|
2673
2767
|
fd = fs.openSync(
|
|
2674
2768
|
resolved,
|
|
2675
2769
|
fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
|
|
2676
2770
|
);
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
2773
|
+
throw error;
|
|
2774
|
+
}
|
|
2775
|
+
let descriptor: SessionStorageStat;
|
|
2776
|
+
try {
|
|
2677
2777
|
const opened = fs.fstatSync(fd, { bigint: true });
|
|
2678
2778
|
const named = fs.lstatSync(resolved, { bigint: true });
|
|
2679
2779
|
if (
|
|
@@ -2695,7 +2795,7 @@ export class ManagedSessionDescendantStore {
|
|
|
2695
2795
|
)
|
|
2696
2796
|
throw new Error("Managed descendant root binding changed");
|
|
2697
2797
|
this.#assertBound();
|
|
2698
|
-
|
|
2798
|
+
descriptor = managedAppendReceiptFromIdentity({
|
|
2699
2799
|
dev: canonicalFileId(opened.dev),
|
|
2700
2800
|
ino: canonicalFileId(opened.ino),
|
|
2701
2801
|
nlink: opened.nlink,
|
|
@@ -2704,11 +2804,13 @@ export class ManagedSessionDescendantStore {
|
|
|
2704
2804
|
ctimeNs: opened.ctimeNs,
|
|
2705
2805
|
}).descriptor;
|
|
2706
2806
|
} catch (error) {
|
|
2707
|
-
|
|
2807
|
+
try {
|
|
2808
|
+
fs.closeSync(fd);
|
|
2809
|
+
} catch {}
|
|
2708
2810
|
throw error;
|
|
2709
|
-
} finally {
|
|
2710
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
2711
2811
|
}
|
|
2812
|
+
fs.closeSync(fd);
|
|
2813
|
+
return descriptor;
|
|
2712
2814
|
}
|
|
2713
2815
|
const stat = this.#authority.stat(this.#relative(resolved));
|
|
2714
2816
|
if (!stat.ok) {
|
|
@@ -2759,6 +2861,7 @@ export class ManagedSessionDescendantStore {
|
|
|
2759
2861
|
resolved,
|
|
2760
2862
|
fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0),
|
|
2761
2863
|
);
|
|
2864
|
+
let snapshot: SessionStorageRangeSnapshot;
|
|
2762
2865
|
try {
|
|
2763
2866
|
const before = fs.fstatSync(fd, { bigint: true });
|
|
2764
2867
|
if (!before.isFile() || before.nlink > 1n) throw new Error("source_changed");
|
|
@@ -2820,7 +2923,7 @@ export class ManagedSessionDescendantStore {
|
|
|
2820
2923
|
)
|
|
2821
2924
|
throw new Error("Managed descendant root binding changed");
|
|
2822
2925
|
this.#assertBound();
|
|
2823
|
-
|
|
2926
|
+
snapshot = {
|
|
2824
2927
|
bytes,
|
|
2825
2928
|
stat: {
|
|
2826
2929
|
dev: canonicalFileId(after.dev),
|
|
@@ -2834,9 +2937,14 @@ export class ManagedSessionDescendantStore {
|
|
|
2834
2937
|
isFile: true,
|
|
2835
2938
|
},
|
|
2836
2939
|
};
|
|
2837
|
-
}
|
|
2838
|
-
|
|
2940
|
+
} catch (error) {
|
|
2941
|
+
try {
|
|
2942
|
+
fs.closeSync(fd);
|
|
2943
|
+
} catch {}
|
|
2944
|
+
throw error;
|
|
2839
2945
|
}
|
|
2946
|
+
fs.closeSync(fd);
|
|
2947
|
+
return snapshot;
|
|
2840
2948
|
}
|
|
2841
2949
|
|
|
2842
2950
|
/** Open an identity-bound stream lease; Darwin uses exact bounded range reads per chunk. */
|
|
@@ -17274,6 +17274,18 @@ export class SessionManager {
|
|
|
17274
17274
|
if (observedPersistError && outcome.kind === "closed") {
|
|
17275
17275
|
return { kind: "close_unknown", error: observedPersistError };
|
|
17276
17276
|
}
|
|
17277
|
+
// Always release managed sidecar cache to prevent file handle leaks on Windows (#6566).
|
|
17278
|
+
// This must happen even if the close is retryable, because the sidecar cache holds
|
|
17279
|
+
// directory handles that prevent temp directory deletion during test cleanup.
|
|
17280
|
+
if (!observedPersistError && !this.#persistWriter && outcome.kind === "closed") {
|
|
17281
|
+
try {
|
|
17282
|
+
this.#releaseManagedSidecarCache();
|
|
17283
|
+
} catch (error) {
|
|
17284
|
+
logger.warn("Failed to release managed sidecar cache during closeStrict", {
|
|
17285
|
+
error: String(error),
|
|
17286
|
+
});
|
|
17287
|
+
}
|
|
17288
|
+
}
|
|
17277
17289
|
return outcome;
|
|
17278
17290
|
}
|
|
17279
17291
|
|
|
@@ -822,6 +822,34 @@ function secureOwnerOnlyFileDescriptor(
|
|
|
822
822
|
if (!result.ok) throw new Error(`Owner-only security rejected ${pathname}: ${result.code}`);
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Replace an explicit session file. Creation mode `0600` does not install a
|
|
827
|
+
* Windows owner-only DACL, so this applies and verifies the same native path a
|
|
828
|
+
* primary transcript uses when it has no managed security context. POSIX mode
|
|
829
|
+
* stays `0600`.
|
|
830
|
+
*/
|
|
831
|
+
export function writeExplicitOwnerOnlyTextSync(pathname: string, content: string): void {
|
|
832
|
+
const fd = fs.openSync(
|
|
833
|
+
pathname,
|
|
834
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW ?? 0),
|
|
835
|
+
0o600,
|
|
836
|
+
);
|
|
837
|
+
try {
|
|
838
|
+
secureOwnerOnlyFileDescriptor(pathname, fd, "apply", undefined);
|
|
839
|
+
fs.ftruncateSync(fd, 0);
|
|
840
|
+
const bytes = Buffer.from(content, "utf8");
|
|
841
|
+
let offset = 0;
|
|
842
|
+
while (offset < bytes.byteLength) {
|
|
843
|
+
const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
844
|
+
if (written === 0) throw new Error("Short write");
|
|
845
|
+
offset += written;
|
|
846
|
+
}
|
|
847
|
+
secureOwnerOnlyFileDescriptor(pathname, fd, "verify", undefined);
|
|
848
|
+
} finally {
|
|
849
|
+
fs.closeSync(fd);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
825
853
|
/** Reject a symlink/junction/reparse component before a storage path is created or opened. */
|
|
826
854
|
function assertNoReparsePath(pathname: string): void {
|
|
827
855
|
const resolved = path.resolve(pathname);
|
|
@@ -2073,7 +2101,8 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
2073
2101
|
|
|
2074
2102
|
writeTextSync(fpath: string, content: string): void {
|
|
2075
2103
|
this.ensureDirSync(path.dirname(fpath));
|
|
2076
|
-
|
|
2104
|
+
// New fork and branch transcripts are created here.
|
|
2105
|
+
writeExplicitOwnerOnlyTextSync(fpath, content);
|
|
2077
2106
|
}
|
|
2078
2107
|
|
|
2079
2108
|
readTextSync(fpath: string): string {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
rebuildActiveSnapshot,
|
|
18
18
|
removeActiveEntry,
|
|
19
19
|
setActiveStateCacheInvalidator,
|
|
20
|
+
workflowEnvelopeChecksumStatus,
|
|
20
21
|
writeActiveEntry,
|
|
21
22
|
} from "../gjc-runtime/state-writer";
|
|
22
23
|
import { getSkillManifest } from "../gjc-runtime/workflow-manifest";
|
|
@@ -519,6 +520,18 @@ async function readModeStatePhase(
|
|
|
519
520
|
const phase = safeString(record.current_phase).trim();
|
|
520
521
|
if (!phase) return undefined;
|
|
521
522
|
if (record.active === false && !getSkillManifest("ralplan").canonicalOverrides.includes(phase)) return undefined;
|
|
523
|
+
const claimedSession = safeString(record.session_id).trim();
|
|
524
|
+
if (claimedSession && claimedSession !== sessionId) return undefined;
|
|
525
|
+
// A mode-state phase may replace the active entry only when the writer
|
|
526
|
+
// stamp still matches. A checksum mismatch is an out-of-band edit. An
|
|
527
|
+
// unsigned releasing phase is not a seal and must not paint "complete"
|
|
528
|
+
// onto a planning entry. Non-releasing canonical phases (final, handoff)
|
|
529
|
+
// still overlay, and a matching stamp still overlays a releasing phase.
|
|
530
|
+
const checksum = workflowEnvelopeChecksumStatus(record);
|
|
531
|
+
if (checksum === "mismatch") return undefined;
|
|
532
|
+
if (checksum === "absent" && getSkillManifest(skill).stopReleasingPhases.includes(phase.trim().toLowerCase())) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
522
535
|
return phase;
|
|
523
536
|
} catch {
|
|
524
537
|
return undefined;
|
|
@@ -8,8 +8,10 @@ import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
|
|
|
8
8
|
import { GJC_SESSION_PREFIX, modeStatePath as sessionModeStatePath } from "../gjc-runtime/session-layout";
|
|
9
9
|
import { resolveGjcSessionForRead } from "../gjc-runtime/session-resolution";
|
|
10
10
|
import { ModeStateSchema } from "../gjc-runtime/state-schema";
|
|
11
|
+
import { workflowEnvelopeChecksumStatus } from "../gjc-runtime/state-writer";
|
|
11
12
|
import { getSkillManifest } from "../gjc-runtime/workflow-manifest";
|
|
12
13
|
import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
|
|
14
|
+
import { isGithubReadOnlyArgs } from "../tools/github-side-effect";
|
|
13
15
|
import { resolveToCwd } from "../tools/path-utils";
|
|
14
16
|
import { ToolError } from "../tools/tool-errors";
|
|
15
17
|
import { listActiveSkills, readVisibleSkillActiveState, type SkillActiveEntry } from "./active-state";
|
|
@@ -38,7 +40,14 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
|
|
|
38
40
|
return DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
/** Tools whose execute path can mutate product state. `github` is included so side-effect ops share this guard. */
|
|
44
|
+
export const WORKFLOW_MUTATION_TOOL_NAMES = ["edit", "write", "ast_edit", "bash", "github"] as const;
|
|
45
|
+
|
|
46
|
+
const BLOCKED_TOOL_NAMES = new Set<string>(WORKFLOW_MUTATION_TOOL_NAMES);
|
|
47
|
+
|
|
48
|
+
export function isWorkflowMutationTool(name: string): boolean {
|
|
49
|
+
return BLOCKED_TOOL_NAMES.has(name);
|
|
50
|
+
}
|
|
42
51
|
/**
|
|
43
52
|
* Only `/dev/null` is exempt. `/dev/stdout`, `/dev/stderr`, and `/dev/fd/<n>` are descriptor
|
|
44
53
|
* aliases: `exec 1<>src/product.ts; printf x >/dev/stdout` reaches a real repository file
|
|
@@ -317,10 +326,20 @@ function resolveCurrentWorkflowEntry(entries: SkillActiveEntry[], topLevelSkill:
|
|
|
317
326
|
* governs, so a stale planning entry can never block while an executor runs and
|
|
318
327
|
* a resumed planning phase reliably re-blocks.
|
|
319
328
|
*
|
|
320
|
-
* Fail-open contract: a missing or invalid durable mode-state releases
|
|
321
|
-
* (a corrupt state file must not lock all mutation), matching the
|
|
322
|
-
* historical behavior — this is intentionally looser than the Stop
|
|
323
|
-
* fails closed for handoff-required skills.
|
|
329
|
+
* Fail-open contract: a missing or schema-invalid durable mode-state releases
|
|
330
|
+
* the block (a corrupt state file must not lock all mutation), matching the
|
|
331
|
+
* guard's historical behavior — this is intentionally looser than the Stop
|
|
332
|
+
* hook, which fails closed for handoff-required skills. A checksum mismatch is
|
|
333
|
+
* not that case: the file is still schema-valid, and the CLI already refuses
|
|
334
|
+
* it as an out-of-band edit. The guard keeps the active entry's phase instead
|
|
335
|
+
* of letting the forged `current_phase` or `active: false` release the block.
|
|
336
|
+
* An unsigned envelope may restate that entry, but it may not release a block
|
|
337
|
+
* the entry still holds. It may still name a stricter blocking phase: the
|
|
338
|
+
* pre-fix guard honored that phase, and dropping it would allow an edit the
|
|
339
|
+
* base rejected. A `session_id` or `thread_id` that does not match this
|
|
340
|
+
* session is the same kind of claim: it does not make the file someone else's,
|
|
341
|
+
* and it does not open the block. A matching `content_sha256` whose context
|
|
342
|
+
* also matches is the existing writer stamp and is trusted.
|
|
324
343
|
*/
|
|
325
344
|
async function getActivePlanningSkill(
|
|
326
345
|
cwd: string,
|
|
@@ -343,11 +362,73 @@ async function getActivePlanningSkill(
|
|
|
343
362
|
const modeState = guardContext.modeStates.get(current.skill) ?? null;
|
|
344
363
|
|
|
345
364
|
if (!modeState) return null;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
365
|
+
const activePhase = String(current.phase ?? "").trim();
|
|
366
|
+
const posture = planningPostureFromModeState(current.skill, activePhase, modeState, resolvedSessionId, threadId);
|
|
367
|
+
if (posture.tampered) {
|
|
368
|
+
warnInvalidModeState(
|
|
369
|
+
modeStatePath(cwd, current.skill, resolvedSessionId),
|
|
370
|
+
"out-of-band edit detected (content_sha256 mismatch)",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (!posture.blocking) return null;
|
|
374
|
+
return { skill: current.skill, phase: posture.phase };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Decide whether `modeState` may release the planning block.
|
|
379
|
+
*
|
|
380
|
+
* The active entry is the seal. A mode-state file releases that seal only when
|
|
381
|
+
* its writer-stamped `content_sha256` matches and its session context matches,
|
|
382
|
+
* or when an unsigned file in the same context does not claim a looser posture
|
|
383
|
+
* than the entry. An unsigned file may still impose a stricter blocking phase.
|
|
384
|
+
* Checksum mismatch ignores the file. A context mismatch does not release the
|
|
385
|
+
* entry's block: the file was read from this session's path.
|
|
386
|
+
*/
|
|
387
|
+
function planningPostureFromModeState(
|
|
388
|
+
skill: MutationGatedSkill,
|
|
389
|
+
activePhase: string,
|
|
390
|
+
modeState: ModeState,
|
|
391
|
+
sessionId?: string,
|
|
392
|
+
threadId?: string,
|
|
393
|
+
): { blocking: boolean; phase: string; tampered: boolean } {
|
|
394
|
+
const status = workflowEnvelopeChecksumStatus(modeState);
|
|
395
|
+
if (status === "mismatch") {
|
|
396
|
+
return {
|
|
397
|
+
blocking: isBlockingPlanningPhase(skill, activePhase),
|
|
398
|
+
phase: activePhase,
|
|
399
|
+
tampered: true,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (!modeStateMatchesContext(modeState, sessionId, threadId)) {
|
|
403
|
+
return {
|
|
404
|
+
blocking: isBlockingPlanningPhase(skill, activePhase),
|
|
405
|
+
phase: activePhase,
|
|
406
|
+
tampered: false,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (status === "match") {
|
|
410
|
+
if (modeState.active !== true) return { blocking: false, phase: activePhase, tampered: false };
|
|
411
|
+
const phase = String(modeState.current_phase ?? activePhase).trim();
|
|
412
|
+
return { blocking: isBlockingPlanningPhase(skill, phase), phase, tampered: false };
|
|
413
|
+
}
|
|
414
|
+
if (!isBlockingPlanningPhase(skill, activePhase)) {
|
|
415
|
+
const stricterPhase = String(modeState.current_phase ?? "").trim();
|
|
416
|
+
if (modeState.active === true && stricterPhase && isBlockingPlanningPhase(skill, stricterPhase)) {
|
|
417
|
+
return { blocking: true, phase: stricterPhase, tampered: false };
|
|
418
|
+
}
|
|
419
|
+
return { blocking: false, phase: activePhase, tampered: false };
|
|
420
|
+
}
|
|
421
|
+
if (modeState.active !== true) return { blocking: true, phase: activePhase, tampered: false };
|
|
422
|
+
const modePhase = String(modeState.current_phase ?? "").trim();
|
|
423
|
+
if (
|
|
424
|
+
modePhase &&
|
|
425
|
+
!isBlockingPlanningPhase(skill, modePhase) &&
|
|
426
|
+
modePhase.trim().toLowerCase() !== activePhase.trim().toLowerCase()
|
|
427
|
+
) {
|
|
428
|
+
return { blocking: true, phase: activePhase, tampered: false };
|
|
429
|
+
}
|
|
430
|
+
const phase = modePhase || activePhase;
|
|
431
|
+
return { blocking: isBlockingPlanningPhase(skill, phase), phase, tampered: false };
|
|
351
432
|
}
|
|
352
433
|
|
|
353
434
|
function normalizePosix(value: string): string {
|
|
@@ -1607,11 +1688,22 @@ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
|
|
|
1607
1688
|
}
|
|
1608
1689
|
return targets;
|
|
1609
1690
|
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Read-only github ops extract no mutation. Every other op fails closed during
|
|
1693
|
+
* planning: remote push, PR creation, and worktree/config writes are not a
|
|
1694
|
+
* filesystem path the planning allowlist can clear.
|
|
1695
|
+
*/
|
|
1696
|
+
function extractGithubTargets(args: unknown): ExtractedTargets {
|
|
1697
|
+
if (isGithubReadOnlyArgs(args)) return { paths: [], unknown: false };
|
|
1698
|
+
return { paths: [], unknown: true, explicitMutation: true };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1610
1701
|
function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
|
|
1611
1702
|
if (tool.name === "write") return extractWriteTargets(args);
|
|
1612
1703
|
if (tool.name === "ast_edit") return extractAstEditTargets(args);
|
|
1613
1704
|
if (tool.name === "edit") return extractEditTargets(args, tool);
|
|
1614
1705
|
if (tool.name === "bash") return extractBashTargets(args);
|
|
1706
|
+
if (tool.name === "github") return extractGithubTargets(args);
|
|
1615
1707
|
return { paths: [], unknown: true };
|
|
1616
1708
|
}
|
|
1617
1709
|
|
|
@@ -1050,7 +1050,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1050
1050
|
runtime,
|
|
1051
1051
|
);
|
|
1052
1052
|
}
|
|
1053
|
-
if (targetIds.some(targetId => targetId !== "default")) runtime.session.markUserModelSelection();
|
|
1054
1053
|
// Preset shortcut: when the selector names a known model profile
|
|
1055
1054
|
// (optionally `gajae-code/`-prefixed) and the target is implicit,
|
|
1056
1055
|
// activate the profile immediately instead of treating the preset name
|
|
@@ -1113,6 +1112,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1113
1112
|
throw new Error(`No API key for ${selection.model.provider}/${selection.model.id}`);
|
|
1114
1113
|
}
|
|
1115
1114
|
}
|
|
1115
|
+
if (includesRoleAgent && !includesDefault) runtime.session.markUserModelSelection();
|
|
1116
1116
|
|
|
1117
1117
|
const overrides = runtime.settings.get("task.agentModelOverrides");
|
|
1118
1118
|
const assignments = new Map<GjcModelAssignmentTargetId, string>();
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
updateMCPServer,
|
|
10
10
|
} from "../../runtime-mcp/config-writer";
|
|
11
11
|
import { MCPManager } from "../../runtime-mcp/manager";
|
|
12
|
+
import { nativeMcpSource } from "../../runtime-mcp/project-host-values";
|
|
12
13
|
import { getSmitheryApiKey } from "../../runtime-mcp/smithery-auth";
|
|
13
14
|
import { searchSmitheryRegistry } from "../../runtime-mcp/smithery-registry";
|
|
14
15
|
import type { MCPServerConfig, MCPServerConnection } from "../../runtime-mcp/types";
|
|
@@ -199,6 +200,7 @@ async function withPreparedMcpConnection<T>(
|
|
|
199
200
|
runtime: SlashCommandRuntime,
|
|
200
201
|
name: string,
|
|
201
202
|
config: MCPServerConfig,
|
|
203
|
+
scope: AcpMcpScope,
|
|
202
204
|
fn: (connection: MCPServerConnection) => Promise<T>,
|
|
203
205
|
): Promise<T> {
|
|
204
206
|
const manager = new MCPManager(runtime.cwd, null, {
|
|
@@ -207,7 +209,9 @@ async function withPreparedMcpConnection<T>(
|
|
|
207
209
|
// Auth storage must be wired in before the prepared lease so OAuth-backed
|
|
208
210
|
// servers can refresh credentials and inject Authorization headers.
|
|
209
211
|
manager.setAuthStorage(runtime.session.modelRegistry.authStorage);
|
|
210
|
-
return manager.withPreparedLease(name, config, lease => fn(lease.connectionForLease())
|
|
212
|
+
return manager.withPreparedLease(name, config, lease => fn(lease.connectionForLease()), {
|
|
213
|
+
source: nativeMcpSource(scope, getMCPConfigPath(scope, runtime.cwd)),
|
|
214
|
+
});
|
|
211
215
|
}
|
|
212
216
|
|
|
213
217
|
async function collectConnectedMcpLines(
|
|
@@ -218,9 +222,9 @@ async function collectConnectedMcpLines(
|
|
|
218
222
|
if (servers.length === 0) return undefined;
|
|
219
223
|
|
|
220
224
|
const lines: string[] = [];
|
|
221
|
-
for (const { name, config } of servers) {
|
|
225
|
+
for (const { name, config, scope } of servers) {
|
|
222
226
|
try {
|
|
223
|
-
const collected = await withPreparedMcpConnection(runtime, name, config, connection =>
|
|
227
|
+
const collected = await withPreparedMcpConnection(runtime, name, config, scope, connection =>
|
|
224
228
|
collect(name, connection),
|
|
225
229
|
);
|
|
226
230
|
lines.push(...collected);
|
|
@@ -265,7 +269,7 @@ async function handleTestCommand(rest: string, runtime: SlashCommandRuntime): Pr
|
|
|
265
269
|
if (!server) return usage(`Server "${name}" not found. Run /mcp list to see configured servers.`, runtime);
|
|
266
270
|
|
|
267
271
|
try {
|
|
268
|
-
return await withPreparedMcpConnection(runtime, name, server.config, async connection => {
|
|
272
|
+
return await withPreparedMcpConnection(runtime, name, server.config, server.scope, async connection => {
|
|
269
273
|
const tools = await listTools(connection);
|
|
270
274
|
const lines = [`Server "${name}" connected (${tools.length} tools).`];
|
|
271
275
|
for (const tool of tools) lines.push(` - ${tool.name}`);
|