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.
Files changed (131) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +42 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +39 -12
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host.ts +11 -0
  117. package/src/jev/client.ts +12 -0
  118. package/src/jev/types.ts +6 -0
  119. package/src/mcp/main.ts +135 -0
  120. package/src/mcp/protocol.ts +282 -0
  121. package/src/mcp/tools.ts +166 -0
  122. package/src/session.ts +59 -0
  123. package/src/setup.ts +13 -5
  124. package/src/tools/ask-files.ts +5 -7
  125. package/src/tools/ask.ts +26 -22
  126. package/src/tools/check-diff.ts +8 -5
  127. package/src/tools/docs-check.ts +1 -0
  128. package/src/tools/find.ts +5 -2
  129. package/src/tools/locate.ts +5 -8
  130. package/src/tools/select-tests.ts +7 -4
  131. package/src/tools/spec-check.ts +1 -0
@@ -0,0 +1,187 @@
1
+ let parserPromise;
2
+ /** Optional grammars resolve from this extension, not the repository under review. */
3
+ export function loadSyntaxParser() {
4
+ parserPromise ??= createParser();
5
+ return parserPromise;
6
+ }
7
+ /** Raw AST access shares grammar availability and registration with declaration extraction. */
8
+ export function loadSyntaxRootParser() {
9
+ parserPromise ??= createParser();
10
+ return parserPromise;
11
+ }
12
+ async function createParser() {
13
+ try {
14
+ const napi = await import("@ast-grep/napi");
15
+ let python = false;
16
+ try {
17
+ const language = await import("@ast-grep/lang-python");
18
+ napi.registerDynamicLanguage({ python: language.default });
19
+ python = true;
20
+ }
21
+ catch {
22
+ /* buildUnits reports the missing grammar for each affected file. */
23
+ }
24
+ const parser = {
25
+ supports: (path) => /\.[cm]?[jt]sx?$/.test(path) || (python && path.endsWith(".py")),
26
+ parseRaw(path, text) {
27
+ try {
28
+ const language = path.endsWith(".py")
29
+ ? "python"
30
+ : /\.[cm]?tsx?$/.test(path)
31
+ ? path.endsWith("tsx")
32
+ ? napi.Lang.Tsx
33
+ : napi.Lang.TypeScript
34
+ : path.endsWith(".jsx")
35
+ ? "jsx"
36
+ : napi.Lang.JavaScript;
37
+ const root = napi.parse(language, text).root();
38
+ return { ok: true, root };
39
+ }
40
+ catch {
41
+ return { ok: false, error: "Source parser unavailable" };
42
+ }
43
+ },
44
+ parse(path, text) {
45
+ const parsed = this.parseRaw(path, text);
46
+ if (!parsed.ok)
47
+ return parsed;
48
+ return parsed.root.find({ rule: { kind: "ERROR" } })
49
+ ? { ok: false, error: "Source syntax incomplete" }
50
+ : parsed;
51
+ },
52
+ declarations(path, text, options) {
53
+ const isPython = path.endsWith(".py");
54
+ const parsed = this.parseRaw(path, text);
55
+ if (!parsed.ok)
56
+ return { declarations: [], limits: [{ kind: "parse_failed" }] };
57
+ const root = parsed.root;
58
+ const result = [];
59
+ const limits = [];
60
+ const errors = root.findAll({ rule: { kind: "ERROR" } });
61
+ const declarationKind = /^(?:function_declaration|generator_function_declaration|method_definition|function_definition|lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration|class_declaration|class_definition)$/;
62
+ if (errors.some((error) => !error
63
+ .ancestors()
64
+ .some((node) => declarationKind.test(String(node.kind()))) &&
65
+ (error
66
+ .findAll({ rule: { regex: ".*" } })
67
+ .some((node) => declarationKind.test(String(node.kind()))) ||
68
+ /^(?:export\s+)?(?:function|const|let|var|class)\b/.test(error.text().trim()))))
69
+ return { declarations: [], limits: [{ kind: "parse_failed" }] };
70
+ const attributedErrors = new Set();
71
+ const source = text.split("\n");
72
+ const visit = (node) => {
73
+ const kind = String(node.kind());
74
+ // Most AST nodes cannot yield a declaration; avoid constructing their ancestor chains.
75
+ if (!/^(?:function_declaration|generator_function_declaration|method_definition|function_definition|lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration|expression_statement|class_declaration|class_definition)$/.test(kind)) {
76
+ for (const child of node.children())
77
+ visit(child);
78
+ return;
79
+ }
80
+ const ancestors = node.ancestors();
81
+ const functionNode = /^(?:function_declaration|generator_function_declaration|method_definition|function_definition)$/.test(kind);
82
+ const variable = /^(?:lexical_declaration|variable_declaration|type_alias_declaration|interface_declaration|enum_declaration)$/.test(kind) &&
83
+ ancestors.every((a) => !/function|method|statement_block/.test(String(a.kind())));
84
+ const callableVariable = variable &&
85
+ node.find({
86
+ rule: {
87
+ any: [
88
+ { kind: "arrow_function" },
89
+ { kind: "function_expression" },
90
+ ],
91
+ },
92
+ }) !== null;
93
+ const assignment = isPython &&
94
+ kind === "expression_statement" &&
95
+ node.parent()?.kind() === "module" &&
96
+ node.children().some((child) => child.kind() === "assignment");
97
+ const classNode = options?.descendAbove !== undefined &&
98
+ /^(?:class_declaration|class_definition)$/.test(kind);
99
+ if (functionNode || variable || assignment || classNode) {
100
+ let envelope = node;
101
+ if (node.parent()?.kind() === "decorated_definition" ||
102
+ node.parent()?.kind() === "export_statement")
103
+ envelope = node.parent() ?? node;
104
+ const named = node.field("name") ?? node.find({ rule: { kind: "identifier" } });
105
+ const owner = ancestors
106
+ .filter((a) => /^(?:class_definition|class_declaration|internal_module|module)$/.test(String(a.kind())))
107
+ .reverse()
108
+ .map((a) => a.field("name")?.text())
109
+ .filter(Boolean);
110
+ let name = [...owner, named?.text() ?? "declaration"].join(".");
111
+ if (assignment)
112
+ name = node.text().split(/\s*[:=]/, 1)[0] ?? "declaration";
113
+ let start = envelope.range().start.line;
114
+ const body = node.field("body") ??
115
+ (callableVariable
116
+ ? node
117
+ .find({
118
+ rule: {
119
+ any: [
120
+ { kind: "arrow_function" },
121
+ { kind: "function_expression" },
122
+ ],
123
+ },
124
+ })
125
+ ?.field("body")
126
+ : null);
127
+ const tokens = (part) => {
128
+ const children = part.children();
129
+ if (!children.length ||
130
+ /string|comment/.test(String(part.kind())))
131
+ return part.kind() === "comment" ? [] : [part.text()];
132
+ return children.flatMap(tokens);
133
+ };
134
+ const accessor = kind === "method_definition"
135
+ ? node
136
+ .children()
137
+ .find((child) => /^(get|set)$/.test(String(child.kind())))
138
+ ?.kind()
139
+ : undefined;
140
+ const end = envelope.range().end;
141
+ // Include the immediately preceding comment block without swallowing another declaration.
142
+ while (start > 0 &&
143
+ /^\s*(?:\/\/|\/\*|\*|\*\/|#)/.test(source[start - 1] ?? ""))
144
+ start--;
145
+ result.push({
146
+ kind: functionNode || callableVariable ? "function" : "declaration",
147
+ name,
148
+ type: accessor ? `${kind}:${accessor}` : kind,
149
+ signatureEnd: body
150
+ ? body.range().start.line + (isPython ? 0 : 1)
151
+ : undefined,
152
+ body: body ? JSON.stringify(tokens(body)) : undefined,
153
+ exported: isPython
154
+ ? !name.split(".").some((part) => part.startsWith("_"))
155
+ : ancestors.some((a) => a.kind() === "export_statement") ||
156
+ envelope.kind() === "export_statement",
157
+ start: start + 1,
158
+ end: end.line + (end.column === 0 ? 0 : 1),
159
+ });
160
+ const declarationErrors = envelope.findAll({
161
+ rule: { kind: "ERROR" },
162
+ });
163
+ if (declarationErrors.length) {
164
+ limits.push({ kind: "parse_partial", declaration: name });
165
+ for (const error of declarationErrors)
166
+ attributedErrors.add(error.id());
167
+ }
168
+ if (options?.descendAbove === undefined ||
169
+ end.line + (end.column === 0 ? 0 : 1) - start <=
170
+ options.descendAbove)
171
+ return;
172
+ }
173
+ for (const child of node.children())
174
+ visit(child);
175
+ };
176
+ visit(root);
177
+ if (errors.some((error) => !attributedErrors.has(error.id())))
178
+ limits.push({ kind: "parse_partial" });
179
+ return { declarations: result, limits };
180
+ },
181
+ };
182
+ return parser;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
@@ -0,0 +1,131 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { TEST_SOURCE_MAX_BYTES, TIMEOUT_MS } from "../constants.js";
3
+ import { checkFileAdmission, openRepoFile, resolveInsideRepo, } from "./files.js";
4
+ import { decodeUtf8 } from "./utf8.js";
5
+ export async function collectTestInventory(exec, cwd, signal) {
6
+ const root = await exec("git", ["rev-parse", "--show-toplevel"], {
7
+ cwd,
8
+ timeout: TIMEOUT_MS,
9
+ signal,
10
+ });
11
+ if (root.code !== 0 || root.killed)
12
+ return {
13
+ ok: false,
14
+ error: root.stderr || "Cannot identify repository root.",
15
+ };
16
+ cwd = root.stdout.replace(/\n$/, "");
17
+ const listed = await exec("git", [
18
+ "ls-files",
19
+ "--cached",
20
+ "--others",
21
+ "--exclude-standard",
22
+ "--full-name",
23
+ "-z",
24
+ ], { cwd, timeout: TIMEOUT_MS, signal });
25
+ if (listed.code !== 0 || listed.killed)
26
+ return {
27
+ ok: false,
28
+ error: listed.stderr || "Cannot inventory repository files.",
29
+ };
30
+ const ignored = await exec("git", ["ls-files", "--cached", "--ignored", "--exclude-standard", "-z"], { cwd, timeout: TIMEOUT_MS, signal });
31
+ if (ignored.code !== 0 || ignored.killed)
32
+ return {
33
+ ok: false,
34
+ error: ignored.stderr || "Cannot identify ignored tracked files.",
35
+ };
36
+ const excluded = new Set(ignored.stdout.split("\0").filter(Boolean));
37
+ const paths = listed.stdout
38
+ .split("\0")
39
+ .filter((path) => path &&
40
+ (!excluded.has(path) ||
41
+ /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path)));
42
+ const admitted = new Set(paths);
43
+ const limits = [];
44
+ const cache = new Map();
45
+ for (let offset = 0; offset < paths.length; offset += 16) {
46
+ const inspected = await Promise.all(paths.slice(offset, offset + 16).map(async (path) => {
47
+ try {
48
+ signal?.throwIfAborted();
49
+ const admission = await checkFileAdmission(cwd, path, exec, signal, admitted);
50
+ if (!admission.ok)
51
+ return "unreadable";
52
+ const resolved = await resolveInsideRepo(cwd, path);
53
+ if (!resolved.ok)
54
+ return "unreadable";
55
+ const stat = await lstat(resolved.abs);
56
+ if (!stat.isFile())
57
+ return "unreadable";
58
+ return stat.size > TEST_SOURCE_MAX_BYTES
59
+ ? "too_large"
60
+ : undefined;
61
+ }
62
+ catch {
63
+ return "unreadable";
64
+ }
65
+ }));
66
+ for (const [index, kind] of inspected.entries()) {
67
+ if (!kind)
68
+ continue;
69
+ const path = paths[offset + index];
70
+ if (path === undefined || cache.has(path))
71
+ continue;
72
+ limits.push({ path, kind });
73
+ cache.set(path, Promise.resolve(undefined));
74
+ }
75
+ }
76
+ const read = (path) => {
77
+ const cached = cache.get(path);
78
+ if (cached)
79
+ return cached;
80
+ const pending = (async () => {
81
+ let handle;
82
+ try {
83
+ signal?.throwIfAborted();
84
+ const opened = await openRepoFile(cwd, path, {
85
+ exec,
86
+ signal,
87
+ inventory: admitted,
88
+ });
89
+ if (!opened.ok)
90
+ return { path, kind: "unreadable" };
91
+ handle = opened.handle;
92
+ const stat = { size: opened.size };
93
+ if (stat.size > TEST_SOURCE_MAX_BYTES)
94
+ return { path, kind: "too_large" };
95
+ const bytes = Buffer.alloc(Math.min(stat.size + 1, TEST_SOURCE_MAX_BYTES + 1));
96
+ let count = 0;
97
+ while (count < bytes.length) {
98
+ signal?.throwIfAborted();
99
+ const part = await handle.read(bytes, count, bytes.length - count, null);
100
+ if (!part.bytesRead)
101
+ break;
102
+ count += part.bytesRead;
103
+ }
104
+ if (count === bytes.length)
105
+ return { path, kind: "too_large" };
106
+ if (bytes.subarray(0, count).includes(0))
107
+ return { path, text: "" };
108
+ const decoded = decodeUtf8(bytes.subarray(0, count));
109
+ if (!decoded.ok)
110
+ return { path, kind: "not UTF-8 text" };
111
+ const text = decoded.text;
112
+ return { path, text };
113
+ }
114
+ catch {
115
+ return { path, kind: "unreadable" };
116
+ }
117
+ finally {
118
+ await handle?.close();
119
+ }
120
+ })().then((file) => {
121
+ if (file.kind) {
122
+ limits.push({ path: file.path, kind: file.kind });
123
+ return undefined;
124
+ }
125
+ return { path: file.path, text: file.text };
126
+ });
127
+ cache.set(path, pending);
128
+ return pending;
129
+ };
130
+ return { ok: true, cwd, paths, read, limits };
131
+ }
@@ -0,0 +1,20 @@
1
+ export function hostUsage(isOmp, usage) {
2
+ if (isOmp || !usage)
3
+ return {};
4
+ return {
5
+ usage: {
6
+ input: usage.inputTokens,
7
+ output: 0,
8
+ cacheRead: 0,
9
+ cacheWrite: 0,
10
+ totalTokens: usage.inputTokens,
11
+ cost: {
12
+ input: usage.costUsd,
13
+ output: 0,
14
+ cacheRead: 0,
15
+ cacheWrite: 0,
16
+ total: usage.costUsd,
17
+ },
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,47 @@
1
+ import { isUtf8 } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ /** Callers enforce byte budgets before decoding. BOM and legitimate controls are preserved. */
4
+ export function decodeUtf8(bytes) {
5
+ return isUtf8(bytes)
6
+ ? {
7
+ ok: true,
8
+ text: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8"),
9
+ }
10
+ : { ok: false, error: "not UTF-8 text" };
11
+ }
12
+ /** Keep incomplete code points between chunks, but reject them at EOF. */
13
+ export function createUtf8Decoder() {
14
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
15
+ return (bytes) => {
16
+ try {
17
+ return {
18
+ ok: true,
19
+ text: decoder.decode(bytes, { stream: bytes !== undefined }),
20
+ };
21
+ }
22
+ catch {
23
+ return { ok: false, error: "not UTF-8 text" };
24
+ }
25
+ };
26
+ }
27
+ /** Verify host-decoded text against the raw Git object; replacement cannot silently become evidence. */
28
+ export function verifyGitUtf8(text, id, size) {
29
+ const bytes = Buffer.from(text, "utf8");
30
+ if (size !== undefined && bytes.length !== size)
31
+ return { ok: false, error: "not UTF-8 text" };
32
+ if (id && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id)) {
33
+ const actual = createHash(id.length === 64 ? "sha256" : "sha1")
34
+ .update(`blob ${bytes.length}\0`)
35
+ .update(bytes)
36
+ .digest("hex");
37
+ if (actual !== id)
38
+ return { ok: false, error: "not UTF-8 text" };
39
+ }
40
+ else if (text.includes("\uFFFD")) {
41
+ return {
42
+ ok: false,
43
+ error: "not verifiable as UTF-8 text (contains U+FFFD after host decoding)",
44
+ };
45
+ }
46
+ return decodeUtf8(bytes);
47
+ }
@@ -0,0 +1,257 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { privateStorage, } from "./adapters/private-storage.js";
7
+ import { createJevClient } from "./jev/client.js";
8
+ const fields = ["url", "apiKey", "model"];
9
+ const defaults = { url: "", apiKey: "", model: "openjev" };
10
+ const storageError = () => new Error("Cannot read or save Jev configuration. Check that the configuration directory and config.json are owned by you, private, regular storage without symlinks, and contain valid configuration JSON.");
11
+ function nonempty(value) {
12
+ return value !== undefined && value.trim().length > 0;
13
+ }
14
+ function validate(values) {
15
+ let validUrl = false;
16
+ try {
17
+ const url = new URL(values.url);
18
+ validUrl =
19
+ /^https?:\/\//i.test(values.url) &&
20
+ (url.protocol === "http:" || url.protocol === "https:") &&
21
+ url.hostname !== "" &&
22
+ url.username === "" &&
23
+ url.password === "";
24
+ }
25
+ catch {
26
+ // Keep user-controlled URL and credentials out of error messages.
27
+ }
28
+ if (!validUrl ||
29
+ !nonempty(values.apiKey) ||
30
+ /[^\x20-\x7e\x80-\xff]/.test(values.apiKey) ||
31
+ !nonempty(values.model)) {
32
+ throw new Error("Jev configuration requires a full HTTP(S) URL without embedded credentials, a nonempty HTTP-header-compatible API key, and a nonempty model.");
33
+ }
34
+ }
35
+ /** Effective configuration and its client move together, only after a save succeeds. */
36
+ export class ConfigController {
37
+ environment = {};
38
+ flags = {};
39
+ saved = {};
40
+ session = {};
41
+ directory;
42
+ storage;
43
+ initialization;
44
+ currentClient;
45
+ constructor(options = {}) {
46
+ const env = options.env ?? process.env;
47
+ this.storage = options.storage ?? privateStorage();
48
+ for (const [field, name] of [
49
+ ["url", "JEV_TOOLS_URL"],
50
+ ["apiKey", "JEV_TOOLS_API_KEY"],
51
+ ["model", "JEV_TOOLS_MODEL"],
52
+ ]) {
53
+ if (nonempty(env[name]))
54
+ this.environment[field] = env[name];
55
+ }
56
+ const base = nonempty(env.XDG_CONFIG_HOME) && isAbsolute(env.XDG_CONFIG_HOME)
57
+ ? env.XDG_CONFIG_HOME
58
+ : join(homedir(), ".config");
59
+ this.directory = resolve(options.directory ?? join(base, "jev-agent-tools"));
60
+ if (nonempty(this.environment.url) && nonempty(this.environment.apiKey)) {
61
+ const effective = this.values();
62
+ validate(effective);
63
+ this.currentClient = createJevClient(effective);
64
+ }
65
+ }
66
+ get client() {
67
+ return this.currentClient;
68
+ }
69
+ initialize(flags) {
70
+ if (this.initialization)
71
+ return this.initialization;
72
+ this.initialization = this.load(flags).catch((error) => {
73
+ this.initialization = undefined;
74
+ throw error;
75
+ });
76
+ return this.initialization;
77
+ }
78
+ values() {
79
+ return {
80
+ ...defaults,
81
+ ...this.saved,
82
+ ...this.session,
83
+ ...this.flags,
84
+ ...this.environment,
85
+ };
86
+ }
87
+ locked(field) {
88
+ return (this.environment[field] !== undefined || this.flags[field] !== undefined);
89
+ }
90
+ async apply(values, persist) {
91
+ if (!this.initialization) {
92
+ throw new Error("Initialize Jev configuration before applying changes.");
93
+ }
94
+ await this.initialization;
95
+ const session = { ...this.session };
96
+ for (const field of fields) {
97
+ if (!this.locked(field))
98
+ session[field] = values[field];
99
+ }
100
+ const effective = {
101
+ ...defaults,
102
+ ...this.saved,
103
+ ...session,
104
+ ...this.flags,
105
+ ...this.environment,
106
+ };
107
+ validate(effective);
108
+ const saved = { ...this.saved, ...session };
109
+ if (persist)
110
+ await this.save(saved);
111
+ const previous = this.values();
112
+ this.session = session;
113
+ if (persist)
114
+ this.saved = saved;
115
+ if (fields.some((field) => previous[field] !== effective[field])) {
116
+ this.currentClient?.clearCache();
117
+ this.currentClient = createJevClient(effective);
118
+ }
119
+ else {
120
+ this.currentClient ??= createJevClient(effective);
121
+ }
122
+ }
123
+ async load(flags) {
124
+ const saved = await this.readSaved();
125
+ const supplied = {};
126
+ if (nonempty(flags.url))
127
+ supplied.url = flags.url;
128
+ if (nonempty(flags.model))
129
+ supplied.model = flags.model;
130
+ const effective = {
131
+ ...defaults,
132
+ ...saved,
133
+ ...supplied,
134
+ ...this.environment,
135
+ };
136
+ if (nonempty(effective.url) && nonempty(effective.apiKey)) {
137
+ validate(effective);
138
+ this.currentClient?.clearCache();
139
+ this.currentClient = createJevClient(effective);
140
+ }
141
+ this.saved = saved;
142
+ this.flags = supplied;
143
+ }
144
+ async checkDirectory() {
145
+ // Check ancestors too: recursive mkdir and path-based reads must not follow symlinks.
146
+ // Privacy of the directory itself is checked with its file in readSaved,
147
+ // using the operating system's own permission model.
148
+ let path = this.directory;
149
+ let exists = true;
150
+ while (true) {
151
+ try {
152
+ const stat = await lstat(path);
153
+ if (!stat.isDirectory() || stat.isSymbolicLink())
154
+ throw storageError();
155
+ }
156
+ catch (error) {
157
+ if (error?.code !== "ENOENT")
158
+ throw storageError();
159
+ if (path === this.directory)
160
+ exists = false;
161
+ }
162
+ const parent = dirname(path);
163
+ if (parent === path)
164
+ break;
165
+ path = parent;
166
+ }
167
+ return exists;
168
+ }
169
+ async readSaved() {
170
+ try {
171
+ if (!(await this.checkDirectory()))
172
+ return {};
173
+ const directory = {
174
+ path: this.directory,
175
+ stat: await lstat(this.directory),
176
+ };
177
+ const path = join(this.directory, "config.json");
178
+ try {
179
+ const stat = await lstat(path);
180
+ if (!stat.isFile() || stat.isSymbolicLink())
181
+ throw storageError();
182
+ }
183
+ catch (error) {
184
+ if (error?.code === "ENOENT") {
185
+ if (!(await this.storage.isPrivate([directory])))
186
+ throw storageError();
187
+ return {};
188
+ }
189
+ throw error;
190
+ }
191
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
192
+ try {
193
+ const stat = await file.stat();
194
+ if (!stat.isFile() ||
195
+ stat.nlink !== 1 ||
196
+ !(await this.storage.isPrivate([directory, { path, stat }]))) {
197
+ throw storageError();
198
+ }
199
+ const value = JSON.parse(await file.readFile("utf8"));
200
+ if (value === null ||
201
+ typeof value !== "object" ||
202
+ Array.isArray(value) ||
203
+ Object.entries(value).some(([key, entry]) => !fields.some((field) => field === key) ||
204
+ typeof entry !== "string")) {
205
+ throw storageError();
206
+ }
207
+ return value;
208
+ }
209
+ finally {
210
+ await file.close();
211
+ }
212
+ }
213
+ catch {
214
+ throw storageError();
215
+ }
216
+ }
217
+ async save(values) {
218
+ let temporary;
219
+ try {
220
+ const existed = await this.checkDirectory();
221
+ if (!existed) {
222
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
223
+ }
224
+ // Windows ignores mkdir's mode, so restrict the ACL before any secret is
225
+ // written: always for a new directory, and for an existing one only while
226
+ // it holds no configuration (e.g. left behind by an earlier failed save).
227
+ // Existing configuration is never re-permissioned; readSaved refuses it.
228
+ if (!existed ||
229
+ !(await lstat(join(this.directory, "config.json")).then(() => true, () => false)))
230
+ await this.storage.restrictDirectory(this.directory);
231
+ // Refuse to replace malformed, insecure or newly introduced storage.
232
+ await this.readSaved();
233
+ temporary = join(this.directory, `.config-${randomUUID()}.tmp`);
234
+ const file = await open(temporary, constants.O_WRONLY |
235
+ constants.O_CREAT |
236
+ constants.O_EXCL |
237
+ constants.O_NOFOLLOW, 0o600);
238
+ try {
239
+ await file.writeFile(`${JSON.stringify(values, null, 2)}\n`, "utf8");
240
+ await file.sync();
241
+ }
242
+ finally {
243
+ await file.close();
244
+ }
245
+ await this.readSaved();
246
+ await rename(temporary, join(this.directory, "config.json"));
247
+ temporary = undefined;
248
+ }
249
+ catch {
250
+ throw storageError();
251
+ }
252
+ finally {
253
+ if (temporary)
254
+ await unlink(temporary).catch(() => { });
255
+ }
256
+ }
257
+ }