@gajae-code/coding-agent 0.18.8 → 0.18.10
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 +100 -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,143 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
const ALLOCATION_ATTEMPTS = 8;
|
|
6
|
+
|
|
7
|
+
type PathApi = Pick<typeof path, "dirname" | "isAbsolute" | "relative" | "resolve" | "sep">;
|
|
8
|
+
|
|
9
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
10
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function defaultToken(): string {
|
|
14
|
+
return randomBytes(9).toString("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parent directory of `mergedDir`, but only when it is a single entry inside
|
|
19
|
+
* `worktreeRoot`. Root, empty, nested, and escaped paths are refused so
|
|
20
|
+
* cleanup cannot delete the worktree root or another session's tree by
|
|
21
|
+
* walking up.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveIsolationRemovalTarget(
|
|
24
|
+
mergedDir: string,
|
|
25
|
+
worktreeRoot: string,
|
|
26
|
+
pathApi: PathApi = path,
|
|
27
|
+
): string {
|
|
28
|
+
if (mergedDir.length === 0) {
|
|
29
|
+
throw new Error("Refusing to remove an isolation directory for an empty merged path.");
|
|
30
|
+
}
|
|
31
|
+
if (worktreeRoot.length === 0) {
|
|
32
|
+
throw new Error("Refusing to remove an isolation directory without a worktree root.");
|
|
33
|
+
}
|
|
34
|
+
const baseDir = pathApi.resolve(pathApi.dirname(mergedDir));
|
|
35
|
+
const root = pathApi.resolve(worktreeRoot);
|
|
36
|
+
const relative = pathApi.relative(root, baseDir);
|
|
37
|
+
if (
|
|
38
|
+
relative.length === 0 ||
|
|
39
|
+
relative === "." ||
|
|
40
|
+
relative.startsWith("..") ||
|
|
41
|
+
pathApi.isAbsolute(relative) ||
|
|
42
|
+
relative.includes(pathApi.sep) ||
|
|
43
|
+
relative.includes("/") ||
|
|
44
|
+
relative.includes("\\")
|
|
45
|
+
) {
|
|
46
|
+
throw new Error("Refusing to remove an isolation directory that is not a single worktree entry.");
|
|
47
|
+
}
|
|
48
|
+
return baseDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create an exclusive directory next to `canonicalBaseDir`. Never removes
|
|
53
|
+
* `canonicalBaseDir` or any path that already exists.
|
|
54
|
+
*/
|
|
55
|
+
export async function allocateDisjointIsolationDir(
|
|
56
|
+
canonicalBaseDir: string,
|
|
57
|
+
tokenFactory: () => string = defaultToken,
|
|
58
|
+
): Promise<string> {
|
|
59
|
+
const parent = path.dirname(canonicalBaseDir);
|
|
60
|
+
const stem = path.basename(canonicalBaseDir);
|
|
61
|
+
if (stem.length === 0 || stem === "." || stem === ".." || stem.includes("/") || stem.includes("\\")) {
|
|
62
|
+
throw new Error("Isolation directory name is not a single path segment.");
|
|
63
|
+
}
|
|
64
|
+
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
|
|
65
|
+
|
|
66
|
+
for (let attempt = 0; attempt < ALLOCATION_ATTEMPTS; attempt++) {
|
|
67
|
+
const token = tokenFactory();
|
|
68
|
+
if (!/^[0-9a-f]+$/i.test(token)) {
|
|
69
|
+
throw new Error("Isolation directory token is not hexadecimal.");
|
|
70
|
+
}
|
|
71
|
+
const name = `${stem}-${token}`;
|
|
72
|
+
const baseDir = path.join(parent, name);
|
|
73
|
+
const relative = path.relative(parent, baseDir);
|
|
74
|
+
if (relative !== name || path.isAbsolute(relative)) {
|
|
75
|
+
throw new Error("Isolation directory escaped its parent.");
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await fs.mkdir(baseDir, { mode: 0o700 });
|
|
79
|
+
return baseDir;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isErrno(error, "EEXIST")) continue;
|
|
82
|
+
if (isErrno(error, "EPERM")) {
|
|
83
|
+
try {
|
|
84
|
+
await fs.lstat(baseDir);
|
|
85
|
+
continue;
|
|
86
|
+
} catch (statError) {
|
|
87
|
+
if (!isErrno(statError, "ENOENT")) throw statError;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error("Failed to allocate a disjoint task isolation directory.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Confirm native teardown may see `mergedDir`.
|
|
99
|
+
*
|
|
100
|
+
* `isoStop` deletes that path. The parent entry and `merged` itself must
|
|
101
|
+
* already be real directories, because native removal follows symlinks.
|
|
102
|
+
* A missing parent means there is nothing to stop. This does not close a
|
|
103
|
+
* replacement that happens after the lstat returns.
|
|
104
|
+
*/
|
|
105
|
+
export async function assertIsolationTeardownTarget(mergedDir: string, worktreeRoot: string): Promise<"stop" | "skip"> {
|
|
106
|
+
const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
|
|
107
|
+
let baseInfo: { isSymbolicLink(): boolean };
|
|
108
|
+
try {
|
|
109
|
+
baseInfo = await fs.lstat(baseDir);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (isErrno(error, "ENOENT")) return "skip";
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (baseInfo.isSymbolicLink()) {
|
|
115
|
+
throw new Error("Refusing to remove a symlinked isolation directory.");
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const mergedInfo = await fs.lstat(mergedDir);
|
|
119
|
+
if (mergedInfo.isSymbolicLink()) {
|
|
120
|
+
throw new Error("Refusing to tear down a symlinked isolation merged directory.");
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (isErrno(error, "ENOENT")) return "stop";
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
return "stop";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove the isolation directory that owns `mergedDir`. Missing directories are ignored. */
|
|
130
|
+
export async function removeIsolationDirectory(mergedDir: string, worktreeRoot: string): Promise<void> {
|
|
131
|
+
const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
|
|
132
|
+
let info: { isSymbolicLink(): boolean };
|
|
133
|
+
try {
|
|
134
|
+
info = await fs.lstat(baseDir);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isErrno(error, "ENOENT")) return;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
if (info.isSymbolicLink()) {
|
|
140
|
+
throw new Error("Refusing to remove a symlinked isolation directory.");
|
|
141
|
+
}
|
|
142
|
+
await fs.rm(baseDir, { recursive: true, force: true });
|
|
143
|
+
}
|
package/src/task/worktree.ts
CHANGED
|
@@ -5,8 +5,9 @@ import * as path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import type * as natives from "@gajae-code/natives";
|
|
7
7
|
|
|
8
|
-
import { getWorktreeDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
|
|
8
|
+
import { getWorktreeDir, getWorktreesDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
|
|
9
9
|
import * as git from "../utils/git";
|
|
10
|
+
import { allocateDisjointIsolationDir, assertIsolationTeardownTarget, removeIsolationDirectory } from "./isolation-dir";
|
|
10
11
|
|
|
11
12
|
let nativeWorktreeBindings: typeof import("@gajae-code/natives") | undefined;
|
|
12
13
|
|
|
@@ -401,8 +402,13 @@ export async function ensureIsolation(
|
|
|
401
402
|
preferred?: IsoBackendKind,
|
|
402
403
|
): Promise<IsolationHandle> {
|
|
403
404
|
const repoRoot = await getRepoRoot(baseCwd);
|
|
404
|
-
|
|
405
|
+
// Task ids are allocated inside one session, so `<id>-<repo hash>` is shared
|
|
406
|
+
// by every fresh session's first task. Never remove that canonical directory.
|
|
407
|
+
// Each attempt gets an exclusive sibling and only that sibling is removed.
|
|
408
|
+
const canonicalBaseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
|
|
409
|
+
const baseDir = await allocateDisjointIsolationDir(canonicalBaseDir);
|
|
405
410
|
const mergedDir = path.join(baseDir, "merged");
|
|
411
|
+
const worktreeRoot = getWorktreesDir();
|
|
406
412
|
const natives = nativeWorktree();
|
|
407
413
|
|
|
408
414
|
const resolution = natives.isoResolve(preferred ?? null);
|
|
@@ -410,7 +416,7 @@ export async function ensureIsolation(
|
|
|
410
416
|
let fallbackReason = resolution.reason ?? null;
|
|
411
417
|
|
|
412
418
|
for (const candidate of candidates) {
|
|
413
|
-
await
|
|
419
|
+
await removeIsolationDirectory(mergedDir, worktreeRoot);
|
|
414
420
|
try {
|
|
415
421
|
await natives.isoStart(candidate, repoRoot, mergedDir);
|
|
416
422
|
return {
|
|
@@ -420,7 +426,7 @@ export async function ensureIsolation(
|
|
|
420
426
|
fallbackReason,
|
|
421
427
|
};
|
|
422
428
|
} catch (err) {
|
|
423
|
-
await
|
|
429
|
+
await removeIsolationDirectory(mergedDir, worktreeRoot);
|
|
424
430
|
const message = errorMessage(err);
|
|
425
431
|
if (!natives.isoIsUnavailableError(message)) {
|
|
426
432
|
throw err;
|
|
@@ -434,6 +440,11 @@ export async function ensureIsolation(
|
|
|
434
440
|
|
|
435
441
|
/** Tear down a handle returned by {@link ensureIsolation}. */
|
|
436
442
|
export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
|
|
443
|
+
const worktreeRoot = getWorktreesDir();
|
|
444
|
+
// Native isoStop removes `mergedDir` and follows symlinks. Refuse a
|
|
445
|
+
// symlink that is already present before that call.
|
|
446
|
+
const teardown = await assertIsolationTeardownTarget(handle.mergedDir, worktreeRoot);
|
|
447
|
+
if (teardown === "skip") return;
|
|
437
448
|
const natives = nativeWorktree();
|
|
438
449
|
try {
|
|
439
450
|
try {
|
|
@@ -446,9 +457,7 @@ export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
|
|
|
446
457
|
});
|
|
447
458
|
}
|
|
448
459
|
} finally {
|
|
449
|
-
|
|
450
|
-
const baseDir = path.dirname(handle.mergedDir);
|
|
451
|
-
await fs.rm(baseDir, { recursive: true, force: true });
|
|
460
|
+
await removeIsolationDirectory(handle.mergedDir, worktreeRoot);
|
|
452
461
|
}
|
|
453
462
|
}
|
|
454
463
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_ANCESTOR_WALK = 64;
|
|
5
|
+
|
|
6
|
+
function isInside(root: string, target: string): boolean {
|
|
7
|
+
const relative = path.relative(root, target);
|
|
8
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Path the native writer will mutate: realpath of an existing file, or realpath
|
|
13
|
+
* of the nearest existing ancestor plus the missing tail.
|
|
14
|
+
*/
|
|
15
|
+
export async function canonicalizeAstEditWritePath(absolutePath: string): Promise<string> {
|
|
16
|
+
const suffix: string[] = [];
|
|
17
|
+
let current = path.resolve(absolutePath);
|
|
18
|
+
for (let depth = 0; depth < MAX_ANCESTOR_WALK; depth++) {
|
|
19
|
+
try {
|
|
20
|
+
const real = await fs.realpath(current);
|
|
21
|
+
return suffix.length === 0 ? real : path.join(real, ...suffix.toReversed());
|
|
22
|
+
} catch {
|
|
23
|
+
const parent = path.dirname(current);
|
|
24
|
+
if (parent === current) break;
|
|
25
|
+
suffix.push(path.basename(current));
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return path.resolve(absolutePath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Preview paths as the workflow guard must see them.
|
|
34
|
+
*
|
|
35
|
+
* The guard compares lexically against the session cwd. A raw realpath misses
|
|
36
|
+
* `.gjc/**` when that cwd is not already canonical (`/var` vs `/private/var`,
|
|
37
|
+
* or a symlinked workspace), so an in-workspace real path is reattached to `cwd`.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveAstEditPreviewWritePaths(
|
|
40
|
+
cwd: string,
|
|
41
|
+
previewedPaths: readonly string[],
|
|
42
|
+
): Promise<string[]> {
|
|
43
|
+
const resolvedCwd = path.resolve(cwd);
|
|
44
|
+
let realCwd = resolvedCwd;
|
|
45
|
+
try {
|
|
46
|
+
realCwd = await fs.realpath(resolvedCwd);
|
|
47
|
+
} catch {
|
|
48
|
+
// Missing cwd has no canonical spelling; keep the lexical root.
|
|
49
|
+
}
|
|
50
|
+
const resolved: string[] = [];
|
|
51
|
+
for (const previewed of previewedPaths) {
|
|
52
|
+
const lexical = path.resolve(resolvedCwd, previewed);
|
|
53
|
+
const real = await canonicalizeAstEditWritePath(lexical);
|
|
54
|
+
const relative = path.relative(realCwd, real);
|
|
55
|
+
const canonical =
|
|
56
|
+
isInside(realCwd, real) && relative !== ""
|
|
57
|
+
? path.resolve(resolvedCwd, relative)
|
|
58
|
+
: isInside(realCwd, real)
|
|
59
|
+
? resolvedCwd
|
|
60
|
+
: real;
|
|
61
|
+
// The guard matches `.gjc` lexically. Keep the preview spelling so a
|
|
62
|
+
// symlinked `.gjc` directory is still recognized, and add the canonical
|
|
63
|
+
// spelling so `src` → `.gjc` is recognized too.
|
|
64
|
+
resolved.push(previewed);
|
|
65
|
+
if (path.resolve(canonical) !== lexical) resolved.push(canonical);
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -13,11 +13,13 @@ import { assertWorkflowMutationRawPathsAllowed } from "../skill-state/workflow-m
|
|
|
13
13
|
import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
|
|
14
14
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
15
15
|
import type { ToolSession } from ".";
|
|
16
|
+
import { resolveAstEditPreviewWritePaths } from "./ast-edit-write-path";
|
|
17
|
+
import { assertEditableFile } from "./auto-generated-guard";
|
|
16
18
|
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
|
17
19
|
import { formatGroupedFiles } from "./grouped-file-output";
|
|
18
20
|
import type { OutputMeta } from "./output-meta";
|
|
19
21
|
import { resolveToolSearchScope } from "./path-utils";
|
|
20
|
-
import { enforcePlanModeWrite } from "./plan-mode-guard";
|
|
22
|
+
import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
|
|
21
23
|
import {
|
|
22
24
|
appendParseErrorsBulletList,
|
|
23
25
|
capParseErrors,
|
|
@@ -344,8 +346,11 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
|
|
344
346
|
await assertWorkflowMutationRawPathsAllowed({
|
|
345
347
|
cwd: this.session.cwd,
|
|
346
348
|
sessionId: this.session.getSessionId?.() ?? undefined,
|
|
347
|
-
rawPaths: previewedFiles,
|
|
349
|
+
rawPaths: await resolveAstEditPreviewWritePaths(this.session.cwd, previewedFiles),
|
|
348
350
|
});
|
|
351
|
+
for (const filePath of previewedFiles) {
|
|
352
|
+
await assertEditableFile(resolvePlanPath(this.session, filePath), filePath, this.session.settings);
|
|
353
|
+
}
|
|
349
354
|
const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
|
|
350
355
|
rewrites: normalizedRewrites,
|
|
351
356
|
dryRun: false,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
2
|
+
|
|
3
|
+
/** Require an http(s) CDP URL whose host is loopback. */
|
|
4
|
+
export function assertLoopbackCdpUrl(cdpUrl: string): string {
|
|
5
|
+
let parsed: URL;
|
|
6
|
+
try {
|
|
7
|
+
parsed = new URL(cdpUrl);
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
|
|
10
|
+
}
|
|
11
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
12
|
+
throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
|
|
13
|
+
}
|
|
14
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) {
|
|
15
|
+
throw new Error(`Refusing non-loopback CDP endpoint: ${cdpUrl}`);
|
|
16
|
+
}
|
|
17
|
+
return cdpUrl;
|
|
18
|
+
}
|
package/src/tools/browser.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as z from "zod/v4";
|
|
|
6
6
|
import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
|
|
7
7
|
import type { ToolSession } from "../sdk";
|
|
8
8
|
import { type BrowserActionStep, compileActionSteps } from "./browser/actions";
|
|
9
|
+
import { assertLoopbackCdpUrl } from "./browser/cdp-url";
|
|
9
10
|
import { isChromeProfileExecutableForLaunch, isEdgeExecutable, resolveSystemChromeForProfile } from "./browser/launch";
|
|
10
11
|
import { chromeUserDataRoots, type DiscoveryEnv, defaultDiscoveryEnv } from "./browser/profile-discovery";
|
|
11
12
|
import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
|
|
@@ -245,7 +246,13 @@ async function resolveBrowserKind(
|
|
|
245
246
|
): Promise<BrowserKind> {
|
|
246
247
|
const app = params.app;
|
|
247
248
|
if (app?.cdp_url) {
|
|
248
|
-
|
|
249
|
+
const cdpUrl = app.cdp_url.replace(/\/+$/, "");
|
|
250
|
+
try {
|
|
251
|
+
assertLoopbackCdpUrl(cdpUrl);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new ToolError(error instanceof Error ? error.message : String(error));
|
|
254
|
+
}
|
|
255
|
+
return { kind: "connected", cdpUrl };
|
|
249
256
|
}
|
|
250
257
|
if (app?.browser === "chrome") {
|
|
251
258
|
return resolveChromeProfileKind(app, session, signal, discoveryEnv);
|
package/src/tools/gh.ts
CHANGED
|
@@ -8,10 +8,12 @@ import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@gajae
|
|
|
8
8
|
import * as z from "zod/v4";
|
|
9
9
|
import type { Settings } from "../config/settings";
|
|
10
10
|
import githubDescription from "../prompts/tools/github.md" with { type: "text" };
|
|
11
|
+
import { assertWorkflowMutationAllowed } from "../skill-state/workflow-mutation-guard";
|
|
11
12
|
import * as git from "../utils/git";
|
|
12
13
|
import type { ToolSession } from ".";
|
|
13
14
|
import { formatShortSha } from "./gh-format";
|
|
14
15
|
import { type CacheStatus, getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
|
|
16
|
+
import { githubMutationTool, isGithubReadOnlyOp } from "./github-side-effect";
|
|
15
17
|
import type { OutputMeta } from "./output-meta";
|
|
16
18
|
import { ToolError, throwIfAborted } from "./tool-errors";
|
|
17
19
|
import { toolResult } from "./tool-result";
|
|
@@ -2591,6 +2593,14 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
|
|
|
2591
2593
|
_context?: AgentToolContext,
|
|
2592
2594
|
): Promise<AgentToolResult<GhToolDetails>> {
|
|
2593
2595
|
return untilAborted(signal, async () => {
|
|
2596
|
+
if (!isGithubReadOnlyOp(params.op)) {
|
|
2597
|
+
await assertWorkflowMutationAllowed({
|
|
2598
|
+
cwd: this.session.cwd,
|
|
2599
|
+
sessionId: this.session.getSessionId?.() ?? undefined,
|
|
2600
|
+
tool: githubMutationTool,
|
|
2601
|
+
args: params,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2594
2604
|
switch (params.op) {
|
|
2595
2605
|
case "repo_view":
|
|
2596
2606
|
return executeRepoView(this.session, params, signal);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify `github` tool operations for the approval gate and the planning
|
|
3
|
+
* mutation guard. Read-only ops stay available. Every other op, including a
|
|
4
|
+
* missing or unrecognized one, is a side effect and fails closed.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentTool } from "@gajae-code/agent-core";
|
|
7
|
+
|
|
8
|
+
/** Name-only tool the planning guard uses when `GithubTool.execute` checks itself. */
|
|
9
|
+
export const githubMutationTool = {
|
|
10
|
+
name: "github",
|
|
11
|
+
label: "GitHub",
|
|
12
|
+
description: "github",
|
|
13
|
+
parameters: {},
|
|
14
|
+
execute: async () => ({ content: [] }),
|
|
15
|
+
} as AgentTool;
|
|
16
|
+
|
|
17
|
+
const GITHUB_READ_ONLY_OPS = new Set([
|
|
18
|
+
"repo_view",
|
|
19
|
+
"search_issues",
|
|
20
|
+
"search_prs",
|
|
21
|
+
"search_code",
|
|
22
|
+
"search_commits",
|
|
23
|
+
"search_repos",
|
|
24
|
+
"run_watch",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const GITHUB_SIDE_EFFECT_OPS = new Set(["pr_create", "pr_checkout", "pr_push"]);
|
|
28
|
+
|
|
29
|
+
const TITLE_MAX = 160;
|
|
30
|
+
|
|
31
|
+
function recordOf(args: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
|
33
|
+
return args as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
37
|
+
const value = record?.[key];
|
|
38
|
+
if (typeof value !== "string") return undefined;
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clip(value: string): string {
|
|
44
|
+
const cleaned = value
|
|
45
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
if (cleaned.length <= TITLE_MAX) return cleaned;
|
|
49
|
+
return `${cleaned.slice(0, TITLE_MAX - 1)}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatPr(value: unknown): string {
|
|
53
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
54
|
+
if (!Array.isArray(value)) return "the current pull request";
|
|
55
|
+
const parts = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
56
|
+
return parts.length > 0 ? parts.join(", ") : "the current pull request";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function githubOp(args: unknown): string | undefined {
|
|
60
|
+
const op = recordOf(args)?.op;
|
|
61
|
+
return typeof op === "string" ? op : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True only for a known read-only github op. Missing and unknown ops are not read-only. */
|
|
65
|
+
export function isGithubReadOnlyArgs(args: unknown): boolean {
|
|
66
|
+
const op = githubOp(args);
|
|
67
|
+
return op !== undefined && GITHUB_READ_ONLY_OPS.has(op);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isGithubReadOnlyOp(op: string): boolean {
|
|
71
|
+
return GITHUB_READ_ONLY_OPS.has(op);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Approval summary for a side-effect github call. The PR body is omitted so a
|
|
76
|
+
* token pasted into `body` is not copied into the permission title.
|
|
77
|
+
*/
|
|
78
|
+
export function describeGithubSideEffect(args: unknown): string {
|
|
79
|
+
const record = recordOf(args);
|
|
80
|
+
const op = githubOp(args) ?? "";
|
|
81
|
+
if (op === "pr_push") {
|
|
82
|
+
const branch = stringField(record, "branch") ?? "the current branch";
|
|
83
|
+
const lease = record?.forceWithLease === true ? " with --force-with-lease" : "";
|
|
84
|
+
return clip(`Push ${branch} to its pull request branch${lease} using stored git credentials`);
|
|
85
|
+
}
|
|
86
|
+
if (op === "pr_checkout") {
|
|
87
|
+
const force = record?.force === true ? " and reset the local branch" : "";
|
|
88
|
+
return clip(
|
|
89
|
+
`Check out ${formatPr(record?.pr)} into ~/.gjc/wt, write branch git config, and may add a remote${force}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (op === "pr_create") {
|
|
93
|
+
const repo = stringField(record, "repo") ?? "the current repository";
|
|
94
|
+
const head = stringField(record, "head") ?? "the current branch";
|
|
95
|
+
const base = stringField(record, "base") ?? "the default base";
|
|
96
|
+
return clip(`Create a pull request on ${repo} from ${head} into ${base} with gh`);
|
|
97
|
+
}
|
|
98
|
+
return "Run a github operation that is not read-only";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function githubPermissionIntent(
|
|
102
|
+
args: unknown,
|
|
103
|
+
): { toolName: string; title: string; cacheKey: string } | undefined {
|
|
104
|
+
if (isGithubReadOnlyArgs(args)) return undefined;
|
|
105
|
+
const op = githubOp(args);
|
|
106
|
+
const cacheOp = op && GITHUB_SIDE_EFFECT_OPS.has(op) ? op : "unspecified";
|
|
107
|
+
return {
|
|
108
|
+
toolName: "github",
|
|
109
|
+
title: describeGithubSideEffect(args),
|
|
110
|
+
cacheKey: `github:${cacheOp}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AgentTool } from "@gajae-code/agent-core";
|
|
2
|
+
import { assertWorkflowMutationAllowed } from "../skill-state/workflow-mutation-guard";
|
|
3
|
+
|
|
4
|
+
const bashShapedTool = {
|
|
5
|
+
name: "bash",
|
|
6
|
+
label: "bash",
|
|
7
|
+
description: "bash",
|
|
8
|
+
parameters: {},
|
|
9
|
+
execute: async () => ({ content: [] }),
|
|
10
|
+
} as AgentTool;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Judge a monitor command with the same mutation rules as bash. Monitor is a
|
|
14
|
+
* background shell, so a planning-phase mutation must fail before the job starts.
|
|
15
|
+
*/
|
|
16
|
+
export async function assertMonitorMutationAllowed(input: {
|
|
17
|
+
cwd: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
command: string;
|
|
20
|
+
}): Promise<void> {
|
|
21
|
+
await assertWorkflowMutationAllowed({
|
|
22
|
+
cwd: input.cwd,
|
|
23
|
+
sessionId: input.sessionId,
|
|
24
|
+
tool: bashShapedTool,
|
|
25
|
+
args: { command: input.command },
|
|
26
|
+
});
|
|
27
|
+
}
|
package/src/tools/monitor.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { truncateTail } from "../session/streaming-output";
|
|
|
7
7
|
import { lookupOwnedRegistration } from "../session/terminal-abort";
|
|
8
8
|
import { BashTool } from "./bash";
|
|
9
9
|
import type { ToolSession } from "./index";
|
|
10
|
+
import { assertMonitorMutationAllowed } from "./monitor-mutation";
|
|
10
11
|
import { ToolError } from "./tool-errors";
|
|
11
12
|
import { TOOL_TIMEOUTS } from "./tool-timeouts";
|
|
12
13
|
|
|
@@ -137,6 +138,12 @@ export class MonitorTool implements AgentTool<typeof monitorSchema, MonitorToolD
|
|
|
137
138
|
throw new ToolError("Async execution is disabled; the monitor tool is unavailable in this session.");
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
await assertMonitorMutationAllowed({
|
|
142
|
+
cwd: this.session.cwd,
|
|
143
|
+
sessionId: this.session.getSessionId?.() ?? undefined,
|
|
144
|
+
command: params.command,
|
|
145
|
+
});
|
|
146
|
+
|
|
140
147
|
const persistent = params.persistent ?? false;
|
|
141
148
|
const label = buildMonitorLabel(params);
|
|
142
149
|
const ownerId = this.session.getAgentId?.() ?? undefined;
|
package/src/tools/python.ts
CHANGED
|
@@ -297,6 +297,13 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
|
|
|
297
297
|
signal?.removeEventListener("abort", abortFromCaller);
|
|
298
298
|
contextGeneration.abortControllers.delete(abortController);
|
|
299
299
|
});
|
|
300
|
+
const settled = execution.then(
|
|
301
|
+
() => {},
|
|
302
|
+
() => {},
|
|
303
|
+
);
|
|
304
|
+
contextGeneration.completions.add(settled);
|
|
305
|
+
void settled.then(() => contextGeneration.completions.delete(settled));
|
|
306
|
+
|
|
300
307
|
let completion: Promise<AgentToolResult>;
|
|
301
308
|
try {
|
|
302
309
|
completion = input.trackEvalExecution ? input.trackEvalExecution(execution, abortController) : execution;
|
|
@@ -308,12 +315,6 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
|
|
|
308
315
|
void execution.catch(() => {});
|
|
309
316
|
throw error;
|
|
310
317
|
}
|
|
311
|
-
const settled = completion.then(
|
|
312
|
-
() => {},
|
|
313
|
-
() => {},
|
|
314
|
-
);
|
|
315
|
-
contextGeneration.completions.add(settled);
|
|
316
|
-
void settled.then(() => contextGeneration.completions.delete(settled));
|
|
317
318
|
return await completion;
|
|
318
319
|
},
|
|
319
320
|
};
|
|
@@ -6,11 +6,12 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import * as fs from "node:fs/promises";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { $
|
|
9
|
+
import { $pickCredentialEnv, Snowflake } from "@gajae-code/utils";
|
|
10
10
|
|
|
11
11
|
/** Returns the user's preferred editor command, or undefined if not configured. */
|
|
12
12
|
export function getEditorCommand(): string | undefined {
|
|
13
|
-
|
|
13
|
+
// The editor is executed, so a repository `.env` must not be able to choose it.
|
|
14
|
+
return $pickCredentialEnv("VISUAL", "EDITOR");
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface OpenInEditorOptions {
|
|
@@ -74,7 +74,7 @@ function isBlockedHostname(hostname: string): boolean {
|
|
|
74
74
|
function isPrivateIPv4(address: string): boolean {
|
|
75
75
|
const parts = address.split(".").map(part => Number.parseInt(part, 10));
|
|
76
76
|
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
77
|
-
const [a, b] = parts;
|
|
77
|
+
const [a, b, c] = parts;
|
|
78
78
|
return (
|
|
79
79
|
a === 0 || // unspecified / "this network"
|
|
80
80
|
a === 10 || // RFC1918
|
|
@@ -83,6 +83,7 @@ function isPrivateIPv4(address: string): boolean {
|
|
|
83
83
|
(a === 169 && b === 254) || // link-local
|
|
84
84
|
(a === 172 && b >= 16 && b <= 31) || // RFC1918
|
|
85
85
|
(a === 192 && b === 0) || // 192.0.0/24 & 192.0.2/24 (documentation/reserved)
|
|
86
|
+
(a === 192 && b === 88 && c === 99) || // 192.88.99.0/24 deprecated 6to4 anycast; 192.88.99.2/32 is not globally reachable
|
|
86
87
|
(a === 192 && b === 168) || // RFC1918
|
|
87
88
|
(a === 198 && (b === 18 || b === 19)) || // benchmarking 198.18/15
|
|
88
89
|
(a === 198 && b === 51) || // 198.51.100/24 documentation
|