jev-agent-tools 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -1
- package/CONTRIBUTING.md +40 -0
- package/README.md +42 -9
- package/SECURITY.md +27 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +189 -0
- package/dist/adapters/ask-proof.js +144 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +181 -0
- package/dist/adapters/docs.js +172 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +293 -0
- package/dist/adapters/find.js +122 -0
- package/dist/adapters/git-base.js +26 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +439 -0
- package/dist/adapters/locate-file.js +159 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +98 -0
- package/dist/adapters/risk-callers.js +426 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +76 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +131 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +257 -0
- package/dist/constants.js +119 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +194 -0
- package/dist/core/asks.js +436 -0
- package/dist/core/batches.js +65 -0
- package/dist/core/command-output.js +224 -0
- package/dist/core/diff.js +178 -0
- package/dist/core/docs.js +302 -0
- package/dist/core/find.js +108 -0
- package/dist/core/git.js +1 -0
- package/dist/core/imports.js +550 -0
- package/dist/core/integrity.js +45 -0
- package/dist/core/lexical.js +132 -0
- package/dist/core/locate.js +169 -0
- package/dist/core/output.js +120 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +44 -0
- package/dist/core/syntax.js +1 -0
- package/dist/core/test-commands.js +334 -0
- package/dist/core/test-coverage.js +74 -0
- package/dist/core/test-discovery.js +1382 -0
- package/dist/core/test-evidence.js +527 -0
- package/dist/core/test-state.js +81 -0
- package/dist/core/truncate.js +12 -0
- package/dist/core/units.js +349 -0
- package/dist/describe.js +23 -0
- package/dist/guide.js +33 -0
- package/dist/host.js +24 -0
- package/dist/jev/client.js +434 -0
- package/dist/jev/pool.js +54 -0
- package/dist/jev/types.js +1 -0
- package/dist/mcp/main.js +124 -0
- package/dist/mcp/protocol.js +187 -0
- package/dist/mcp/tools.js +116 -0
- package/dist/presets/docs.js +62 -0
- package/dist/presets/risk.js +179 -0
- package/dist/presets/spec.js +81 -0
- package/dist/presets/witnesses.js +249 -0
- package/dist/render.js +42 -0
- package/dist/result.js +3 -0
- package/dist/runtime.js +1 -0
- package/dist/session.js +147 -0
- package/dist/texts/ask-files.js +1 -0
- package/dist/texts/ask.js +2 -0
- package/dist/texts/check-diff.js +17 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +14 -0
- package/dist/texts/guide.js +16 -0
- package/dist/texts/locate.js +10 -0
- package/dist/texts/select-tests.js +2 -0
- package/dist/tools/ask-files.js +217 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +686 -0
- package/dist/tools/check-diff.js +402 -0
- package/dist/tools/docs-check.js +299 -0
- package/dist/tools/find.js +389 -0
- package/dist/tools/locate.js +303 -0
- package/dist/tools/select-tests.js +567 -0
- package/dist/tools/spec-check.js +166 -0
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
- package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
- package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
- package/docs/adr/0004-compiled-typed-intents.md +19 -0
- package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
- package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
- package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
- package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
- package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
- package/docs/adr/0010-mcp-server-thin-host.md +23 -0
- package/docs/agent-instructions.md +91 -0
- package/docs/design.md +3 -3
- package/docs/mcp.md +231 -0
- package/package.json +19 -4
- package/server.json +57 -0
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +7 -4
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/private-storage.ts +143 -0
- package/src/adapters/risk-callers.ts +4 -2
- package/src/adapters/shell.ts +97 -0
- package/src/configuration.ts +39 -12
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host.ts +11 -0
- package/src/jev/client.ts +12 -0
- package/src/jev/types.ts +6 -0
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +282 -0
- package/src/mcp/tools.ts +166 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +13 -5
- package/src/tools/ask-files.ts +5 -7
- package/src/tools/ask.ts +26 -22
- package/src/tools/check-diff.ts +8 -5
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +5 -2
- package/src/tools/locate.ts +5 -8
- package/src/tools/select-tests.ts +7 -4
- package/src/tools/spec-check.ts +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { PRIVATE_STORAGE_TIMEOUT_MS } from "../constants.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Owner-only storage, checked with each operating system's own model.
|
|
8
|
+
*
|
|
9
|
+
* POSIX: no group/other mode bits and owned by the current user.
|
|
10
|
+
* Windows: mode bits are synthesized (always 0o666), so the real ACL is read.
|
|
11
|
+
* Storage is private when every allow entry belongs to the current user,
|
|
12
|
+
* SYSTEM or the Administrators group (the Windows equivalent of root), and
|
|
13
|
+
* the owner is the current user or Administrators. Entries are compared as
|
|
14
|
+
* SIDs, so localized account names do not matter.
|
|
15
|
+
*/
|
|
16
|
+
export interface PrivateStorage {
|
|
17
|
+
/** True when every path is private. One check for all paths. */
|
|
18
|
+
isPrivate(
|
|
19
|
+
entries: readonly { path: string; stat: Stats }[],
|
|
20
|
+
): Promise<boolean>;
|
|
21
|
+
/** Restrict a directory the current user owns so new files inherit privacy. */
|
|
22
|
+
restrictDirectory(path: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SYSTEM = "S-1-5-18";
|
|
26
|
+
const ADMINISTRATORS = "S-1-5-32-544";
|
|
27
|
+
const CREATOR_OWNER = "S-1-3-0";
|
|
28
|
+
|
|
29
|
+
export const posixStorage: PrivateStorage = {
|
|
30
|
+
async isPrivate(entries) {
|
|
31
|
+
return entries.every(
|
|
32
|
+
({ stat }) =>
|
|
33
|
+
(stat.mode & 0o077) === 0 &&
|
|
34
|
+
(!process.getuid || stat.uid === process.getuid()),
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
async restrictDirectory() {
|
|
38
|
+
// mkdir(..., { mode: 0o700 }) already creates POSIX directories privately.
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Paths arrive as JSON in an environment variable, never in the command text.
|
|
43
|
+
const CHECK = `$ErrorActionPreference = 'Stop'
|
|
44
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
45
|
+
$trusted = @($me, '${SYSTEM}', '${ADMINISTRATORS}')
|
|
46
|
+
$sid = [System.Security.Principal.SecurityIdentifier]
|
|
47
|
+
$results = foreach ($path in (ConvertFrom-Json $env:JEV_PRIVATE_PATHS)) {
|
|
48
|
+
try {
|
|
49
|
+
$acl = Get-Acl -LiteralPath $path
|
|
50
|
+
$owner = $acl.GetOwner($sid).Value
|
|
51
|
+
$private = ($owner -eq $me) -or ($owner -eq '${ADMINISTRATORS}')
|
|
52
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, $sid)) {
|
|
53
|
+
if ($rule.AccessControlType -ne 'Allow') { continue }
|
|
54
|
+
$id = $rule.IdentityReference.Value
|
|
55
|
+
$inheritOnly = ($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0
|
|
56
|
+
if ($id -eq '${CREATOR_OWNER}' -and $inheritOnly) { continue }
|
|
57
|
+
if ($trusted -notcontains $id) { $private = $false }
|
|
58
|
+
}
|
|
59
|
+
$private
|
|
60
|
+
} catch { $false }
|
|
61
|
+
}
|
|
62
|
+
ConvertTo-Json -Compress @($results)`;
|
|
63
|
+
|
|
64
|
+
const RESTRICT = `$ErrorActionPreference = 'Stop'
|
|
65
|
+
$path = $env:JEV_PRIVATE_PATH
|
|
66
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
67
|
+
$acl = Get-Acl -LiteralPath $path
|
|
68
|
+
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'not owner' }
|
|
69
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
70
|
+
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleAll($rule) }
|
|
71
|
+
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
72
|
+
foreach ($id in @($me.Value, '${SYSTEM}', '${ADMINISTRATORS}')) {
|
|
73
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
74
|
+
(New-Object System.Security.Principal.SecurityIdentifier($id)),
|
|
75
|
+
'FullControl', $inherit, 'None', 'Allow')
|
|
76
|
+
$acl.AddAccessRule($rule)
|
|
77
|
+
}
|
|
78
|
+
Set-Acl -LiteralPath $path -AclObject $acl`;
|
|
79
|
+
|
|
80
|
+
function powershell(
|
|
81
|
+
script: string,
|
|
82
|
+
env: Record<string, string>,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const root = process.env.SystemRoot ?? "C:\\Windows";
|
|
85
|
+
// A fixed system path, so a powershell.exe earlier on PATH is never used.
|
|
86
|
+
const executable = join(
|
|
87
|
+
root,
|
|
88
|
+
"System32",
|
|
89
|
+
"WindowsPowerShell",
|
|
90
|
+
"v1.0",
|
|
91
|
+
"powershell.exe",
|
|
92
|
+
);
|
|
93
|
+
// PowerShell 7 parents export PSModulePath, which stops Windows PowerShell
|
|
94
|
+
// 5.1 from loading its own Get-Acl module; 5.1 rebuilds it when unset.
|
|
95
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env, ...env };
|
|
96
|
+
for (const key of Object.keys(childEnv))
|
|
97
|
+
if (key.toLowerCase() === "psmodulepath") delete childEnv[key];
|
|
98
|
+
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
99
|
+
execFile(
|
|
100
|
+
executable,
|
|
101
|
+
[
|
|
102
|
+
"-NoProfile",
|
|
103
|
+
"-NonInteractive",
|
|
104
|
+
"-ExecutionPolicy",
|
|
105
|
+
"Bypass",
|
|
106
|
+
// UTF-16LE base64 runs the script as one unit; stdin runs line by line.
|
|
107
|
+
"-EncodedCommand",
|
|
108
|
+
Buffer.from(script, "utf16le").toString("base64"),
|
|
109
|
+
],
|
|
110
|
+
{ env: childEnv, windowsHide: true, timeout: PRIVATE_STORAGE_TIMEOUT_MS },
|
|
111
|
+
(error, stdout) => (error ? reject(error) : resolve(stdout)),
|
|
112
|
+
);
|
|
113
|
+
return promise;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const windowsStorage: PrivateStorage = {
|
|
117
|
+
async isPrivate(entries) {
|
|
118
|
+
if (!entries.length) return true;
|
|
119
|
+
try {
|
|
120
|
+
const output = await powershell(CHECK, {
|
|
121
|
+
JEV_PRIVATE_PATHS: JSON.stringify(entries.map(({ path }) => path)),
|
|
122
|
+
});
|
|
123
|
+
const results: unknown = JSON.parse(output.trim());
|
|
124
|
+
return (
|
|
125
|
+
Array.isArray(results) &&
|
|
126
|
+
results.length === entries.length &&
|
|
127
|
+
results.every((result) => result === true)
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
// An ACL that cannot be read is never treated as private.
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async restrictDirectory(path) {
|
|
135
|
+
await powershell(RESTRICT, { JEV_PRIVATE_PATH: path });
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function privateStorage(
|
|
140
|
+
platform: NodeJS.Platform = process.platform,
|
|
141
|
+
): PrivateStorage {
|
|
142
|
+
return platform === "win32" ? windowsStorage : posixStorage;
|
|
143
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FileHandle, lstat } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { posix } from "node:path";
|
|
3
3
|
import type { SgNode } from "@ast-grep/napi";
|
|
4
4
|
import { CONCURRENCY, STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
|
|
5
5
|
import type { GitExec } from "../core/git.ts";
|
|
@@ -460,8 +460,10 @@ export async function collectRiskCallers(
|
|
|
460
460
|
typeof source.beforePath === "string"
|
|
461
461
|
? source.beforePath
|
|
462
462
|
: source.path;
|
|
463
|
+
// Repository paths are always "/"-separated; the platform
|
|
464
|
+
// resolve would yield "C:\\src\\..." on Windows.
|
|
463
465
|
const basePath = specifier.startsWith(".")
|
|
464
|
-
? resolve("/", sourcePath, "..", specifier).slice(1)
|
|
466
|
+
? posix.resolve("/", sourcePath, "..", specifier).slice(1)
|
|
465
467
|
: specifier.replaceAll(".", "/");
|
|
466
468
|
const runtimeSource = basePath.replace(
|
|
467
469
|
/\.(js|jsx|mjs|cjs)$/,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { accessSync, constants } from "node:fs";
|
|
2
|
+
// Candidates are Windows paths whatever the host OS, so the helpers use win32
|
|
3
|
+
// semantics explicitly; the host-native `node:path` splits PATH on ":" on POSIX.
|
|
4
|
+
import { win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface ShellInvocation {
|
|
7
|
+
executable: string;
|
|
8
|
+
/** Arguments placed before the `-c` script. */
|
|
9
|
+
prefix: string[];
|
|
10
|
+
/** Exports prepended to the script when the launcher cannot set them. */
|
|
11
|
+
scriptPrefix: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ShellResolution =
|
|
15
|
+
| ({ ok: true } & ShellInvocation)
|
|
16
|
+
| { ok: false; error: string };
|
|
17
|
+
|
|
18
|
+
function executable(path: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
accessSync(path, constants.X_OK);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Windows ships `bash.exe` launchers for WSL in System32 (and SysWOW64,
|
|
29
|
+
* Sysnative) and in WindowsApps. They run in a Linux VM that cannot see Windows
|
|
30
|
+
* temporary paths, so they are never used for command evidence, including
|
|
31
|
+
* when named explicitly in JEV_TOOLS_BASH.
|
|
32
|
+
*/
|
|
33
|
+
export function isWslLauncher(path: string): boolean {
|
|
34
|
+
const lower = win32.normalize(path).toLowerCase();
|
|
35
|
+
return (
|
|
36
|
+
/\\windows\\(system32|syswow64|sysnative)\\/.test(lower) ||
|
|
37
|
+
lower.includes("\\microsoft\\windowsapps\\")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Permitted Git for Windows bash candidates, most specific first. */
|
|
42
|
+
export function windowsBashCandidates(env: NodeJS.ProcessEnv): string[] {
|
|
43
|
+
const candidates: string[] = [];
|
|
44
|
+
if (env.JEV_TOOLS_BASH?.trim()) candidates.push(env.JEV_TOOLS_BASH.trim());
|
|
45
|
+
const pathEntries = (env.PATH ?? env.Path ?? "")
|
|
46
|
+
.split(win32.delimiter)
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
for (const entry of pathEntries) {
|
|
49
|
+
candidates.push(win32.join(entry, "bash.exe"));
|
|
50
|
+
// Git\cmd\git.exe or Git\bin\git.exe on PATH implies Git\bin\bash.exe.
|
|
51
|
+
if (/[\\/](cmd|bin)$/i.test(entry))
|
|
52
|
+
candidates.push(win32.join(win32.dirname(entry), "bin", "bash.exe"));
|
|
53
|
+
}
|
|
54
|
+
for (const root of [
|
|
55
|
+
env.ProgramFiles,
|
|
56
|
+
env["ProgramFiles(x86)"],
|
|
57
|
+
env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, "Programs"),
|
|
58
|
+
])
|
|
59
|
+
if (root) candidates.push(win32.join(root, "Git", "bin", "bash.exe"));
|
|
60
|
+
// Filter every source, so no candidate can reintroduce a WSL launcher.
|
|
61
|
+
return [...new Set(candidates)].filter(
|
|
62
|
+
(candidate) => win32.isAbsolute(candidate) && !isWslLauncher(candidate),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const UNAVAILABLE =
|
|
67
|
+
"No permitted bash found: install Git for Windows or set JEV_TOOLS_BASH to the full path of a bash that is not the WSL launcher.";
|
|
68
|
+
let cached: ShellResolution | undefined;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* POSIX hosts keep `env CI=1 bash -c`. Windows has no `env` and its default
|
|
72
|
+
* `bash` is the WSL launcher, so resolve Git for Windows bash (or
|
|
73
|
+
* JEV_TOOLS_BASH) to an absolute, permitted path and export CI inside the
|
|
74
|
+
* script. When none is found this fails closed: no bare name is returned,
|
|
75
|
+
* because spawning one would search PATH again without the WSL exclusion.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveShell(
|
|
78
|
+
platform: NodeJS.Platform = process.platform,
|
|
79
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
80
|
+
isExecutable: (path: string) => boolean = executable,
|
|
81
|
+
): ShellResolution {
|
|
82
|
+
if (platform !== "win32")
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
executable: "env",
|
|
86
|
+
prefix: ["CI=1", "bash"],
|
|
87
|
+
scriptPrefix: "",
|
|
88
|
+
};
|
|
89
|
+
const useCache = isExecutable === executable && env === process.env;
|
|
90
|
+
if (useCache && cached) return cached;
|
|
91
|
+
const found = windowsBashCandidates(env).find((path) => isExecutable(path));
|
|
92
|
+
const resolved: ShellResolution = found
|
|
93
|
+
? { ok: true, executable: found, prefix: [], scriptPrefix: "export CI=1; " }
|
|
94
|
+
: { ok: false, error: UNAVAILABLE };
|
|
95
|
+
if (useCache && found) cached = resolved;
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
package/src/configuration.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
type PrivateStorage,
|
|
8
|
+
privateStorage,
|
|
9
|
+
} from "./adapters/private-storage.ts";
|
|
6
10
|
import { createJevClient } from "./jev/client.ts";
|
|
7
11
|
import type { JevClient } from "./jev/types.ts";
|
|
8
12
|
|
|
@@ -56,11 +60,19 @@ export class ConfigController {
|
|
|
56
60
|
private saved: SavedConfiguration = {};
|
|
57
61
|
private session: SavedConfiguration = {};
|
|
58
62
|
private readonly directory: string;
|
|
63
|
+
private readonly storage: PrivateStorage;
|
|
59
64
|
private initialization: Promise<void> | undefined;
|
|
60
65
|
private currentClient: JevClient | undefined;
|
|
61
66
|
|
|
62
|
-
constructor(
|
|
67
|
+
constructor(
|
|
68
|
+
options: {
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
directory?: string;
|
|
71
|
+
storage?: PrivateStorage;
|
|
72
|
+
} = {},
|
|
73
|
+
) {
|
|
63
74
|
const env = options.env ?? process.env;
|
|
75
|
+
this.storage = options.storage ?? privateStorage();
|
|
64
76
|
for (const [field, name] of [
|
|
65
77
|
["url", "JEV_TOOLS_URL"],
|
|
66
78
|
["apiKey", "JEV_TOOLS_API_KEY"],
|
|
@@ -163,19 +175,14 @@ export class ConfigController {
|
|
|
163
175
|
|
|
164
176
|
private async checkDirectory(): Promise<boolean> {
|
|
165
177
|
// Check ancestors too: recursive mkdir and path-based reads must not follow symlinks.
|
|
178
|
+
// Privacy of the directory itself is checked with its file in readSaved,
|
|
179
|
+
// using the operating system's own permission model.
|
|
166
180
|
let path = this.directory;
|
|
167
181
|
let exists = true;
|
|
168
182
|
while (true) {
|
|
169
183
|
try {
|
|
170
184
|
const stat = await lstat(path);
|
|
171
185
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw storageError();
|
|
172
|
-
if (
|
|
173
|
-
path === this.directory &&
|
|
174
|
-
((stat.mode & 0o077) !== 0 ||
|
|
175
|
-
(process.getuid && stat.uid !== process.getuid()))
|
|
176
|
-
) {
|
|
177
|
-
throw storageError();
|
|
178
|
-
}
|
|
179
186
|
} catch (error) {
|
|
180
187
|
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT")
|
|
181
188
|
throw storageError();
|
|
@@ -191,12 +198,20 @@ export class ConfigController {
|
|
|
191
198
|
private async readSaved(): Promise<SavedConfiguration> {
|
|
192
199
|
try {
|
|
193
200
|
if (!(await this.checkDirectory())) return {};
|
|
201
|
+
const directory = {
|
|
202
|
+
path: this.directory,
|
|
203
|
+
stat: await lstat(this.directory),
|
|
204
|
+
};
|
|
194
205
|
const path = join(this.directory, "config.json");
|
|
195
206
|
try {
|
|
196
207
|
const stat = await lstat(path);
|
|
197
208
|
if (!stat.isFile() || stat.isSymbolicLink()) throw storageError();
|
|
198
209
|
} catch (error) {
|
|
199
|
-
if ((error as NodeJS.ErrnoException)?.code === "ENOENT")
|
|
210
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
211
|
+
if (!(await this.storage.isPrivate([directory])))
|
|
212
|
+
throw storageError();
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
200
215
|
throw error;
|
|
201
216
|
}
|
|
202
217
|
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -205,8 +220,7 @@ export class ConfigController {
|
|
|
205
220
|
if (
|
|
206
221
|
!stat.isFile() ||
|
|
207
222
|
stat.nlink !== 1 ||
|
|
208
|
-
(
|
|
209
|
-
(process.getuid && stat.uid !== process.getuid())
|
|
223
|
+
!(await this.storage.isPrivate([directory, { path, stat }]))
|
|
210
224
|
) {
|
|
211
225
|
throw storageError();
|
|
212
226
|
}
|
|
@@ -235,9 +249,22 @@ export class ConfigController {
|
|
|
235
249
|
private async save(values: SavedConfiguration): Promise<void> {
|
|
236
250
|
let temporary: string | undefined;
|
|
237
251
|
try {
|
|
238
|
-
|
|
252
|
+
const existed = await this.checkDirectory();
|
|
253
|
+
if (!existed) {
|
|
239
254
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
240
255
|
}
|
|
256
|
+
// Windows ignores mkdir's mode, so restrict the ACL before any secret is
|
|
257
|
+
// written: always for a new directory, and for an existing one only while
|
|
258
|
+
// it holds no configuration (e.g. left behind by an earlier failed save).
|
|
259
|
+
// Existing configuration is never re-permissioned; readSaved refuses it.
|
|
260
|
+
if (
|
|
261
|
+
!existed ||
|
|
262
|
+
!(await lstat(join(this.directory, "config.json")).then(
|
|
263
|
+
() => true,
|
|
264
|
+
() => false,
|
|
265
|
+
))
|
|
266
|
+
)
|
|
267
|
+
await this.storage.restrictDirectory(this.directory);
|
|
241
268
|
// Refuse to replace malformed, insecure or newly introduced storage.
|
|
242
269
|
await this.readSaved();
|
|
243
270
|
temporary = join(this.directory, `.config-${randomUUID()}.tmp`);
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,17 @@ export const ASK_NOTE_MAX_CHARS = 8_000;
|
|
|
5
5
|
export const ASK_MAX_FILES = 20;
|
|
6
6
|
export const ASK_TIMEOUT_S = 60;
|
|
7
7
|
export const ASK_TIMEOUT_MAX_S = 300;
|
|
8
|
+
export const PROCESS_KILL_GRACE_MS = 2_000;
|
|
9
|
+
export const PROCESS_PIPE_GRACE_MS = 1_000;
|
|
10
|
+
export const PROCESS_TREE_TIMEOUT_MS = 5_000;
|
|
11
|
+
export const MCP_SHUTDOWN_FLUSH_TIMEOUT_MS = 1_000;
|
|
12
|
+
export const PRIVATE_STORAGE_TIMEOUT_MS = 20_000;
|
|
13
|
+
export const MCP_VALIDATION_MAX_ERRORS = 5;
|
|
14
|
+
export const MCP_PACKAGE_REPLY_TIMEOUT_MS = 20_000;
|
|
15
|
+
export const MCP_PACKAGE_EXIT_TIMEOUT_MS = 10_000;
|
|
16
|
+
export const MCP_PACKAGE_KILL_TIMEOUT_MS = 2_000;
|
|
17
|
+
export const MCP_PACKAGE_INSTALL_TIMEOUT_MS = 300_000;
|
|
18
|
+
export const MCP_PACKAGE_STDERR_MAX_CHARS = 2_000;
|
|
8
19
|
export const OUTPUT_REPEAT_MIN = 5;
|
|
9
20
|
export const OUTPUT_CHUNK_CHARS = 2_500;
|
|
10
21
|
export const OUTPUT_FIND_MAX_CALLS = 40;
|
|
@@ -241,9 +241,25 @@ export function failureTargets(text: string): {
|
|
|
241
241
|
// window when no failing-tests block exists (short outputs).
|
|
242
242
|
if (node && (inSecondWindow || (secondAnchor < 0 && inFirstWindow)))
|
|
243
243
|
targets.push({
|
|
244
|
-
path: node[1]
|
|
244
|
+
path: nodeTestPath(node[1] ?? ""),
|
|
245
245
|
assertion: true,
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
248
|
return { assertion: signature === "assertion", signature, targets };
|
|
249
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* node:test reports absolute locations as file URLs: file:///repo/a.mjs on
|
|
253
|
+
* POSIX and file:///C:/repo/a%20b.mjs on Windows. Convert them to plain,
|
|
254
|
+
* decoded paths; other locations are returned unchanged.
|
|
255
|
+
*/
|
|
256
|
+
export function nodeTestPath(location: string): string {
|
|
257
|
+
if (!location.startsWith("file://")) return location;
|
|
258
|
+
let path = location.slice("file://".length);
|
|
259
|
+
if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1);
|
|
260
|
+
try {
|
|
261
|
+
return decodeURIComponent(path);
|
|
262
|
+
} catch {
|
|
263
|
+
return path;
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -20,3 +20,14 @@ export interface Names {
|
|
|
20
20
|
byName: string;
|
|
21
21
|
semantic: string;
|
|
22
22
|
}
|
|
23
|
+
/** MCP clients name their native tools differently; describe them generically. */
|
|
24
|
+
export function mcpHost(): Host {
|
|
25
|
+
return {
|
|
26
|
+
isOmp: false,
|
|
27
|
+
names: {
|
|
28
|
+
grep: "your text search tool",
|
|
29
|
+
byName: "your file-name search tool",
|
|
30
|
+
semantic: "jev_find_files",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/jev/client.ts
CHANGED
|
@@ -259,6 +259,7 @@ export function createJevClient(
|
|
|
259
259
|
});
|
|
260
260
|
for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
|
|
261
261
|
let release: (() => void) | undefined;
|
|
262
|
+
let settle: (() => void) | undefined;
|
|
262
263
|
const timeoutCancellation = new AbortController();
|
|
263
264
|
let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
|
|
264
265
|
try {
|
|
@@ -267,6 +268,13 @@ export function createJevClient(
|
|
|
267
268
|
missing(ids, stopped);
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
271
|
+
// The reservation is held from here on; `finally` releases it on
|
|
272
|
+
// every exit path (stopped, refused, failed, aborted, answered).
|
|
273
|
+
settle = await options.awaitAdmission?.(options.signal);
|
|
274
|
+
if (stopped) {
|
|
275
|
+
missing(ids, stopped);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
270
278
|
const admission = options.beforeRequest?.(ids.length);
|
|
271
279
|
if (admission && !admission.ok) {
|
|
272
280
|
// A diagnostic denied admission supplies no judgment batch and
|
|
@@ -302,6 +310,9 @@ export function createJevClient(
|
|
|
302
310
|
body = undefined;
|
|
303
311
|
}
|
|
304
312
|
addMetadata(body);
|
|
313
|
+
// Release the USD gate before any subdivision re-enters send().
|
|
314
|
+
settle?.();
|
|
315
|
+
settle = undefined;
|
|
305
316
|
release();
|
|
306
317
|
release = undefined;
|
|
307
318
|
if (
|
|
@@ -438,6 +449,7 @@ export function createJevClient(
|
|
|
438
449
|
return;
|
|
439
450
|
}
|
|
440
451
|
} finally {
|
|
452
|
+
settle?.();
|
|
441
453
|
release?.();
|
|
442
454
|
timeoutCancellation.abort();
|
|
443
455
|
}
|
package/src/jev/types.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface JudgmentOptions {
|
|
|
48
48
|
witnesses?: readonly string[];
|
|
49
49
|
cache?: boolean;
|
|
50
50
|
beforeRequest?: (questionCount: number) => Result<object>;
|
|
51
|
+
/**
|
|
52
|
+
* Awaited before each admission check. Resolves to a release function when
|
|
53
|
+
* it reserved a slot (a session under a USD limit), which the client calls
|
|
54
|
+
* exactly once on every exit path; rejects if `signal` aborts while waiting.
|
|
55
|
+
*/
|
|
56
|
+
awaitAdmission?: (signal?: AbortSignal) => Promise<(() => void) | undefined>;
|
|
51
57
|
onUsage?: (usage: { inputTokens: number; costUsd: number }) => void;
|
|
52
58
|
}
|
|
53
59
|
export interface JevClient {
|
package/src/mcp/main.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { canonicalPath } from "../adapters/canonical-path.ts";
|
|
6
|
+
import { MCP_SHUTDOWN_FLUSH_TIMEOUT_MS } from "../constants.ts";
|
|
7
|
+
import { type JsonRpcResponse, McpServer, PARSE_ERROR } from "./protocol.ts";
|
|
8
|
+
import { createMcpTools } from "./tools.ts";
|
|
9
|
+
|
|
10
|
+
const usage = `jev-agent-tools-mcp - MCP stdio server for the six jev_* tools
|
|
11
|
+
|
|
12
|
+
Usage: jev-agent-tools-mcp [--root <repository-directory>]
|
|
13
|
+
|
|
14
|
+
The repository directory is --root, else JEV_TOOLS_ROOT, else the current
|
|
15
|
+
directory. Configuration uses the same JEV_TOOLS_* environment variables as
|
|
16
|
+
the pi and omp extension. Protocol messages use stdout; diagnostics stderr.`;
|
|
17
|
+
|
|
18
|
+
function packageVersion(): string {
|
|
19
|
+
try {
|
|
20
|
+
// src/mcp/main.ts and dist/mcp/main.js both sit two levels below the root.
|
|
21
|
+
const text = readFileSync(
|
|
22
|
+
new URL("../../package.json", import.meta.url),
|
|
23
|
+
"utf8",
|
|
24
|
+
);
|
|
25
|
+
const parsed = JSON.parse(text) as { version?: unknown };
|
|
26
|
+
return typeof parsed.version === "string" ? parsed.version : "0.0.0";
|
|
27
|
+
} catch {
|
|
28
|
+
return "0.0.0";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv: readonly string[]): { root?: string; exit?: string } {
|
|
33
|
+
let root: string | undefined;
|
|
34
|
+
for (let index = 0; index < argv.length; index++) {
|
|
35
|
+
const arg = argv[index];
|
|
36
|
+
if (arg === "--help" || arg === "-h") return { exit: usage };
|
|
37
|
+
if (arg === "--version") return { exit: packageVersion() };
|
|
38
|
+
if (arg === "--root") {
|
|
39
|
+
root = argv[++index];
|
|
40
|
+
if (!root) throw new Error("--root requires a directory");
|
|
41
|
+
} else if (arg?.startsWith("--root=")) root = arg.slice("--root=".length);
|
|
42
|
+
else throw new Error(`Unknown argument: ${arg}\n\n${usage}`);
|
|
43
|
+
}
|
|
44
|
+
return { root };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
49
|
+
if (parsed.exit !== undefined) {
|
|
50
|
+
process.stdout.write(`${parsed.exit}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const root = await canonicalPath(
|
|
54
|
+
resolve(parsed.root ?? process.env.JEV_TOOLS_ROOT ?? process.cwd()),
|
|
55
|
+
);
|
|
56
|
+
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
|
|
57
|
+
throw new Error(`Repository directory not found: ${root}`);
|
|
58
|
+
const { tools, instructions, configured, warning } = await createMcpTools({
|
|
59
|
+
root,
|
|
60
|
+
});
|
|
61
|
+
const server = new McpServer(
|
|
62
|
+
{ name: "jev-agent-tools", version: packageVersion(), instructions },
|
|
63
|
+
tools,
|
|
64
|
+
);
|
|
65
|
+
if (warning) process.stderr.write(`jev-agent-tools MCP: ${warning}\n`);
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
`jev-agent-tools MCP server ready (root ${root}; ${configured ? "endpoint configured" : "no endpoint: set JEV_TOOLS_URL and JEV_TOOLS_API_KEY, or save them with /jev-setup in pi or omp"})\n`,
|
|
68
|
+
);
|
|
69
|
+
let closing = false;
|
|
70
|
+
const send = (response: JsonRpcResponse | JsonRpcResponse[] | undefined) => {
|
|
71
|
+
if (
|
|
72
|
+
closing ||
|
|
73
|
+
response === undefined ||
|
|
74
|
+
(Array.isArray(response) && !response.length)
|
|
75
|
+
)
|
|
76
|
+
return;
|
|
77
|
+
// Newline-delimited JSON; JSON.stringify never emits raw newlines.
|
|
78
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
79
|
+
};
|
|
80
|
+
const pending = new Set<Promise<void>>();
|
|
81
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
82
|
+
const shutdown = async (code: number) => {
|
|
83
|
+
if (closing) return;
|
|
84
|
+
closing = true;
|
|
85
|
+
lines.close();
|
|
86
|
+
process.stdin.destroy();
|
|
87
|
+
server.abortAll();
|
|
88
|
+
// Tool promises include bounded process-tree escalation. Do not exit when
|
|
89
|
+
// only the direct shell has closed: descendants may still need SIGKILL.
|
|
90
|
+
await Promise.allSettled(pending);
|
|
91
|
+
if (process.stdout.destroyed) process.exit(code);
|
|
92
|
+
// A client may leave its stdout pipe open without draining it. Cleanup
|
|
93
|
+
// is already complete; never let that client's backpressure hold us alive.
|
|
94
|
+
setTimeout(() => process.exit(code), MCP_SHUTDOWN_FLUSH_TIMEOUT_MS);
|
|
95
|
+
process.stdout.end(() => process.exit(code));
|
|
96
|
+
};
|
|
97
|
+
process.on("SIGTERM", () => void shutdown(143));
|
|
98
|
+
process.on("SIGINT", () => void shutdown(130));
|
|
99
|
+
process.stdout.on("error", () => void shutdown(1));
|
|
100
|
+
lines.on("line", (line) => {
|
|
101
|
+
if (closing || !line.trim()) return;
|
|
102
|
+
let message: unknown;
|
|
103
|
+
try {
|
|
104
|
+
message = JSON.parse(line);
|
|
105
|
+
} catch {
|
|
106
|
+
send(server.error(null, PARSE_ERROR, "Parse error: invalid JSON."));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const work = (async () => {
|
|
110
|
+
if (Array.isArray(message)) {
|
|
111
|
+
// JSON-RPC batches (protocol 2025-03-26) answer as one array.
|
|
112
|
+
const responses = await Promise.all(
|
|
113
|
+
message.map((item) => server.handle(item)),
|
|
114
|
+
);
|
|
115
|
+
send(
|
|
116
|
+
responses.filter(
|
|
117
|
+
(item): item is JsonRpcResponse => item !== undefined,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
} else send(await server.handle(message));
|
|
121
|
+
})().catch((error: unknown) => {
|
|
122
|
+
process.stderr.write(`jev-agent-tools MCP: ${String(error)}\n`);
|
|
123
|
+
});
|
|
124
|
+
pending.add(work);
|
|
125
|
+
void work.finally(() => pending.delete(work));
|
|
126
|
+
});
|
|
127
|
+
lines.on("close", () => void shutdown(0));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main().catch((error: unknown) => {
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
`jev-agent-tools MCP: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
133
|
+
);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|