@nathapp/nax-agent 0.3.1 → 0.85.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 +18 -0
- package/README.md +34 -1
- package/dist/config/catalog-overrides.d.ts +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal/atomic-write.d.ts +16 -0
- package/dist/internal/atomic-write.js +34 -0
- package/dist/internal/command-spec/index.js +1 -1
- package/dist/internal/git-env.d.ts +4 -3
- package/dist/internal/git-env.js +11 -4
- package/dist/internal.d.ts +6 -0
- package/dist/internal.js +6 -0
- package/dist/mcp/connect.d.ts +3 -0
- package/dist/mcp/connect.js +144 -0
- package/dist/mcp/errors.d.ts +9 -0
- package/dist/mcp/errors.js +16 -0
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.js +8 -0
- package/dist/mcp/result-text.d.ts +8 -0
- package/dist/mcp/result-text.js +38 -0
- package/dist/mcp/stdio-close.d.ts +1 -0
- package/dist/mcp/stdio-close.js +31 -0
- package/dist/mcp/transport.d.ts +14 -0
- package/dist/mcp/transport.js +41 -0
- package/dist/mcp/types.d.ts +48 -0
- package/dist/mcp/types.js +1 -0
- package/dist/native/auth.d.ts +2 -0
- package/dist/native/auth.js +11 -1
- package/dist/native/index.d.ts +2 -1
- package/dist/native/index.js +2 -1
- package/dist/native/provider-models.d.ts +13 -0
- package/dist/native/provider-models.js +22 -0
- package/dist/native/session/handle-invalid-tool-call.js +1 -0
- package/dist/native/session/instruction-file-name.d.ts +4 -0
- package/dist/native/session/instruction-file-name.js +21 -0
- package/dist/native/session/instruction-interaction.d.ts +7 -0
- package/dist/native/session/instruction-interaction.js +22 -0
- package/dist/native/session/instruction-render.d.ts +6 -0
- package/dist/native/session/instruction-render.js +22 -0
- package/dist/native/session/instruction-session.d.ts +4 -0
- package/dist/native/session/instruction-session.js +31 -0
- package/dist/native/session/instruction-transcript.d.ts +4 -0
- package/dist/native/session/instruction-transcript.js +14 -0
- package/dist/native/session/repository-instructions.d.ts +35 -0
- package/dist/native/session/repository-instructions.js +155 -0
- package/dist/native/session/session.d.ts +8 -0
- package/dist/native/session/session.js +29 -3
- package/dist/native/session/transcript-identity.d.ts +2 -0
- package/dist/native/session/transcript-identity.js +1 -1
- package/dist/native/session/transcript-store.js +4 -2
- package/dist/native/session/transcript-types.d.ts +3 -0
- package/dist/native/session/turn-complete-step.js +4 -2
- package/dist/native/session/turn-event-emitter.d.ts +4 -0
- package/dist/native/session/turn-event-emitter.js +6 -4
- package/dist/native/session/turn-loop-round-trip.js +20 -1
- package/dist/native/session/turn-loop.js +1 -0
- package/dist/native/session-adapter.js +21 -6
- package/dist/permissions/approvals-store.d.ts +3 -1
- package/dist/permissions/approvals-store.js +18 -5
- package/dist/sandbox/session-tmp.js +13 -7
- package/dist/session/agent-session-types.d.ts +4 -0
- package/dist/session/native-backend-options.d.ts +4 -0
- package/dist/session/native-backend-options.js +6 -0
- package/dist/session/native-backend.js +16 -4
- package/dist/session/session-ask-port.js +2 -1
- package/dist/session/session-interaction.js +29 -13
- package/dist/session/session-types.d.ts +10 -0
- package/dist/terminal-auth/index.d.ts +4 -0
- package/dist/terminal-auth/index.js +4 -0
- package/dist/terminal-auth/interaction.d.ts +15 -0
- package/dist/terminal-auth/interaction.js +55 -0
- package/dist/terminal-auth/open-url.d.ts +17 -0
- package/dist/terminal-auth/open-url.js +44 -0
- package/dist/terminal-auth/prompt.d.ts +51 -0
- package/dist/terminal-auth/prompt.js +196 -0
- package/dist/tools/denial-redirect.js +1 -1
- package/dist/tools/git.js +21 -5
- package/dist/tools/instruction-access.d.ts +13 -0
- package/dist/tools/instruction-access.js +30 -0
- package/dist/tools/runtime.js +6 -1
- package/package.json +7 -2
|
@@ -72,10 +72,29 @@ function runSafely(tool, input, ctx) {
|
|
|
72
72
|
return { content: `Tool "${tool.name}" failed: ${cause}`, isError: true };
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Settle with `work`, or with `onAbort()` the moment `signal` aborts. The tool
|
|
77
|
+
* batch awaits interaction handlers with no abort race of its own, so a tool
|
|
78
|
+
* that ignores its signal would otherwise hang the turn (and `close()`, which
|
|
79
|
+
* awaits the turn). The abandoned work's late settlement is ignored.
|
|
80
|
+
*/
|
|
81
|
+
async function raceAbort(work, signal, onAbort) {
|
|
82
|
+
let listener = () => { };
|
|
83
|
+
const aborted = new Promise((resolve) => {
|
|
84
|
+
listener = () => resolve(onAbort());
|
|
85
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
return await Promise.race([work, aborted]);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
signal.removeEventListener("abort", listener);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
75
94
|
/**
|
|
76
95
|
* Runs the tool, but answers at once when the turn aborts: the batch awaits
|
|
77
96
|
* this handler with no abort race of its own, so a run that ignores its signal
|
|
78
|
-
* would otherwise hang the turn.
|
|
97
|
+
* would otherwise hang the turn. See `raceAbort`.
|
|
79
98
|
*/
|
|
80
99
|
async function invoke(tool, input, ctx) {
|
|
81
100
|
const abandoned = {
|
|
@@ -84,17 +103,7 @@ async function invoke(tool, input, ctx) {
|
|
|
84
103
|
};
|
|
85
104
|
if (ctx.signal.aborted)
|
|
86
105
|
return abandoned;
|
|
87
|
-
|
|
88
|
-
const aborted = new Promise((resolve) => {
|
|
89
|
-
onAbort = () => resolve(abandoned);
|
|
90
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
91
|
-
});
|
|
92
|
-
try {
|
|
93
|
-
return await Promise.race([runSafely(tool, input, ctx), aborted]);
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
97
|
-
}
|
|
106
|
+
return raceAbort(runSafely(tool, input, ctx), ctx.signal, () => abandoned);
|
|
98
107
|
}
|
|
99
108
|
async function runEmbedderTool(deps, tool, request) {
|
|
100
109
|
const toolCallId = request.toolCallId ?? "";
|
|
@@ -131,7 +140,14 @@ function toolCallContext(request) {
|
|
|
131
140
|
async function runCodingTool(deps, request) {
|
|
132
141
|
deps.setCurrentCallId(request.toolCallId);
|
|
133
142
|
try {
|
|
134
|
-
const
|
|
143
|
+
const signal = request.signal ?? deps.turnSignal();
|
|
144
|
+
const abandoned = {
|
|
145
|
+
kind: "error",
|
|
146
|
+
content: `Tool "${request.name}" was abandoned: the turn ended.`,
|
|
147
|
+
};
|
|
148
|
+
const outcome = signal.aborted
|
|
149
|
+
? abandoned
|
|
150
|
+
: await raceAbort(deps.runtime.callTool(request.name, request.input ?? {}, { ...toolCallContext(request), signal }), signal, () => abandoned);
|
|
135
151
|
if (outcome.kind === "denied") {
|
|
136
152
|
return { answer: `Denied: ${outcome.reason}`, denied: { reason: outcome.reason, breach: outcome.breach } };
|
|
137
153
|
}
|
|
@@ -175,6 +175,14 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
175
175
|
* facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
|
|
176
176
|
*/
|
|
177
177
|
systemPrompt?: string;
|
|
178
|
+
/** Native: repository-relative instruction scopes, independent of the file-tool root. */
|
|
179
|
+
instructionDirectories?: readonly string[];
|
|
180
|
+
/** Native: instruction Markdown basename; defaults to AGENTS.md and its legacy fallback chain. */
|
|
181
|
+
instructionFileName?: string;
|
|
182
|
+
/** Native: repository-relative paths excluded from automatic instruction reads. */
|
|
183
|
+
instructionDenyPaths?: readonly string[];
|
|
184
|
+
/** Native: host credential and trust-store paths excluded from instruction discovery. */
|
|
185
|
+
instructionProtectedPaths?: import("#src/tools/protected-paths").ProtectedPathsPolicy;
|
|
178
186
|
/**
|
|
179
187
|
* Native: resolved compaction settings. A resolved primitive, never NaxConfig —
|
|
180
188
|
* src/agents/native/ must not read config (check:adapter-no-config-import).
|
|
@@ -192,6 +200,8 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
192
200
|
* NaxConfig. ACP ignores it; its loop is bounded by `maxInteractions`.
|
|
193
201
|
*/
|
|
194
202
|
spinBreaker?: import("#src/infra/spin-breaker/index").ResolvedSpinBreakerSettings;
|
|
203
|
+
/** Native: keep history written by another model (S5-3). */
|
|
204
|
+
readonly carryHistoryAcrossModels?: boolean;
|
|
195
205
|
}
|
|
196
206
|
/** Options for sendTurn(). */
|
|
197
207
|
export interface SendTurnOpts {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Terminal login UI shared by `nax auth login` and `nax-agent login` (S5-4 M-28). */
|
|
2
|
+
export { createTerminalAuthInteraction, type TerminalAuthOptions } from "./interaction.ts";
|
|
3
|
+
export { _openUrlDeps, openUrl, spawnDetached } from "./open-url.ts";
|
|
4
|
+
export { _terminalPromptDeps, PLAIN_STYLE, PromptCancelledError, type PromptStdin, promptForLine, promptForSecret, promptForSelect, type SelectChoice, type TerminalStyle, } from "./prompt.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Terminal login UI shared by `nax auth login` and `nax-agent login` (S5-4 M-28). */
|
|
2
|
+
export { createTerminalAuthInteraction } from "./interaction.js";
|
|
3
|
+
export { _openUrlDeps, openUrl, spawnDetached } from "./open-url.js";
|
|
4
|
+
export { _terminalPromptDeps, PLAIN_STYLE, PromptCancelledError, promptForLine, promptForSecret, promptForSelect, } from "./prompt.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal's side of a login (moved from nax's `terminalInteraction`, S5-4
|
|
3
|
+
* M-28). Shared by `nax auth login` and `nax-agent login`. Secrets go through
|
|
4
|
+
* the non-echoing prompt; any prompt type this mirror does not recognise is
|
|
5
|
+
* treated as a secret.
|
|
6
|
+
*/
|
|
7
|
+
import type { AuthInteraction } from "#src/native/auth-types";
|
|
8
|
+
import { type TerminalStyle } from "#src/terminal-auth/prompt";
|
|
9
|
+
export interface TerminalAuthOptions {
|
|
10
|
+
/** One line of output (no trailing newline). */
|
|
11
|
+
readonly log: (text: string) => void;
|
|
12
|
+
readonly style?: TerminalStyle;
|
|
13
|
+
readonly openUrl?: (url: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function createTerminalAuthInteraction(options: TerminalAuthOptions): AuthInteraction;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { openUrl as defaultOpenUrl } from "#src/terminal-auth/open-url";
|
|
2
|
+
import { PLAIN_STYLE, promptForLine, promptForSecret, promptForSelect, } from "#src/terminal-auth/prompt";
|
|
3
|
+
export function createTerminalAuthInteraction(options) {
|
|
4
|
+
const style = options.style ?? PLAIN_STYLE;
|
|
5
|
+
const open = options.openUrl ?? defaultOpenUrl;
|
|
6
|
+
// notify() is synchronous and the flow fires auth-url right before racing a
|
|
7
|
+
// manual-code prompt against its callback server, so the URL is parked and
|
|
8
|
+
// spent by the next prompt's Enter-on-empty.
|
|
9
|
+
let pendingUrl;
|
|
10
|
+
const manualCode = (message) => {
|
|
11
|
+
const url = pendingUrl;
|
|
12
|
+
if (url === undefined)
|
|
13
|
+
return promptForLine(message, undefined, style);
|
|
14
|
+
pendingUrl = undefined;
|
|
15
|
+
options.log(style.dim("Press Enter to open it in your browser."));
|
|
16
|
+
return promptForLine(message, () => {
|
|
17
|
+
options.log(style.dim("Opening your browser..."));
|
|
18
|
+
open(url);
|
|
19
|
+
}, style);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
prompt: async (prompt) => {
|
|
23
|
+
if (prompt.type === "manual-code")
|
|
24
|
+
return manualCode(prompt.message);
|
|
25
|
+
if (prompt.type === "text")
|
|
26
|
+
return promptForLine(prompt.message, undefined, style);
|
|
27
|
+
if (prompt.type === "select") {
|
|
28
|
+
return promptForSelect(prompt.message, prompt.options.map((option) => ({ id: option.id, label: option.label })), style);
|
|
29
|
+
}
|
|
30
|
+
return promptForSecret(prompt.message, style);
|
|
31
|
+
},
|
|
32
|
+
notify: (event) => {
|
|
33
|
+
switch (event.type) {
|
|
34
|
+
case "auth-url":
|
|
35
|
+
// The flow's own instructions are dropped: nothing has opened a browser yet.
|
|
36
|
+
options.log(`\n${style.bold("Open this URL to continue:")}\n ${event.url}`);
|
|
37
|
+
pendingUrl = event.url;
|
|
38
|
+
return;
|
|
39
|
+
case "device-code":
|
|
40
|
+
options.log(`\nGo to ${event.verificationUri} and enter code ${style.bold(event.userCode)}`);
|
|
41
|
+
return;
|
|
42
|
+
case "info":
|
|
43
|
+
options.log(event.message);
|
|
44
|
+
for (const link of event.links ?? [])
|
|
45
|
+
options.log(` ${link.label ?? "Link"}: ${link.url}`);
|
|
46
|
+
return;
|
|
47
|
+
case "progress":
|
|
48
|
+
options.log(style.dim(event.message));
|
|
49
|
+
return;
|
|
50
|
+
default:
|
|
51
|
+
// An event type this mirror does not recognise: say nothing.
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a URL in the user's browser (moved from the nax CLI, S5-4 M-28).
|
|
3
|
+
*
|
|
4
|
+
* Best-effort: the caller always prints the URL first, so a failure costs a
|
|
5
|
+
* copy-paste, not the login. The URL is its own argv entry, never a shell
|
|
6
|
+
* string. Node reports a missing opener asynchronously (an `error` event), so
|
|
7
|
+
* the child gets an error listener; without it a container with no xdg-open
|
|
8
|
+
* would crash the login (S5-4 Review Focus 2).
|
|
9
|
+
*/
|
|
10
|
+
import { type ChildProcess } from "node:child_process";
|
|
11
|
+
export declare function spawnDetached(command: readonly string[]): ChildProcess;
|
|
12
|
+
/** Test seam. */
|
|
13
|
+
export declare const _openUrlDeps: {
|
|
14
|
+
spawn: (command: readonly string[]) => void;
|
|
15
|
+
platform: () => string;
|
|
16
|
+
};
|
|
17
|
+
export declare function openUrl(url: string): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a URL in the user's browser (moved from the nax CLI, S5-4 M-28).
|
|
3
|
+
*
|
|
4
|
+
* Best-effort: the caller always prints the URL first, so a failure costs a
|
|
5
|
+
* copy-paste, not the login. The URL is its own argv entry, never a shell
|
|
6
|
+
* string. Node reports a missing opener asynchronously (an `error` event), so
|
|
7
|
+
* the child gets an error listener; without it a container with no xdg-open
|
|
8
|
+
* would crash the login (S5-4 Review Focus 2).
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
export function spawnDetached(command) {
|
|
12
|
+
const [file, ...args] = command;
|
|
13
|
+
// nax-git-env-allow: not git: browser opener argv
|
|
14
|
+
const child = spawn(file ?? "", args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
15
|
+
child.on("error", () => {
|
|
16
|
+
// The URL is already on screen; a missing opener is not news.
|
|
17
|
+
});
|
|
18
|
+
child.unref();
|
|
19
|
+
return child;
|
|
20
|
+
}
|
|
21
|
+
/** Test seam. */
|
|
22
|
+
export const _openUrlDeps = {
|
|
23
|
+
spawn: (command) => {
|
|
24
|
+
spawnDetached(command);
|
|
25
|
+
},
|
|
26
|
+
platform: () => process.platform,
|
|
27
|
+
};
|
|
28
|
+
/** The opener for a platform. Windows needs the empty "" title argument. */
|
|
29
|
+
function openerFor(platform, url) {
|
|
30
|
+
if (platform === "darwin")
|
|
31
|
+
return ["open", url];
|
|
32
|
+
if (platform === "win32")
|
|
33
|
+
return ["cmd", "/c", "start", "", url];
|
|
34
|
+
return ["xdg-open", url];
|
|
35
|
+
}
|
|
36
|
+
export function openUrl(url) {
|
|
37
|
+
try {
|
|
38
|
+
// nax-git-env-allow: not git: browser opener argv
|
|
39
|
+
_openUrlDeps.spawn(openerFor(_openUrlDeps.platform(), url));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Deliberately silent: the URL is already on screen.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal prompts for credential entry (moved from the nax CLI, S5-4 M-28).
|
|
3
|
+
*
|
|
4
|
+
* Nothing typed into a secret prompt is echoed, and the terminal is restored on
|
|
5
|
+
* every exit path: submit, cancel, stream end and stream error. Ctrl+D cancels;
|
|
6
|
+
* it never submits (it once fell through to "submit" in nax's confirm prompt).
|
|
7
|
+
*/
|
|
8
|
+
/** Colours for the prompts; the caller supplies them (nax passes chalk). */
|
|
9
|
+
export interface TerminalStyle {
|
|
10
|
+
accent(text: string): string;
|
|
11
|
+
dim(text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
export declare const PLAIN_STYLE: TerminalStyle;
|
|
15
|
+
/** The slice of process.stdin these prompts drive. Injected so tests can stand one up. */
|
|
16
|
+
export interface PromptStdin {
|
|
17
|
+
isTTY?: boolean;
|
|
18
|
+
setRawMode(mode: boolean): unknown;
|
|
19
|
+
resume(): unknown;
|
|
20
|
+
pause(): unknown;
|
|
21
|
+
setEncoding(encoding: string): unknown;
|
|
22
|
+
on(event: string, listener: (chunk: string) => void): unknown;
|
|
23
|
+
once(event: string, listener: () => void): unknown;
|
|
24
|
+
removeListener(event: string, listener: (...args: never[]) => void): unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare class PromptCancelledError extends Error {
|
|
27
|
+
constructor();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Test seam. `stdin` is unset by default and resolved to `process.stdin` only
|
|
31
|
+
* when a prompt runs: this module is reachable from the public barrel, and
|
|
32
|
+
* touching `process.stdin` at import would open it for every consumer.
|
|
33
|
+
*/
|
|
34
|
+
export declare const _terminalPromptDeps: {
|
|
35
|
+
stdin: PromptStdin | undefined;
|
|
36
|
+
write: (text: string) => boolean;
|
|
37
|
+
};
|
|
38
|
+
/** Reads a secret. Nothing is echoed, not even a masking character. */
|
|
39
|
+
export declare function promptForSecret(message: string, style?: TerminalStyle): Promise<string>;
|
|
40
|
+
/** Reads a visible line, for non-secret answers such as a pasted auth code. */
|
|
41
|
+
export declare function promptForLine(message: string, onEmptySubmit?: () => void, style?: TerminalStyle): Promise<string>;
|
|
42
|
+
export interface SelectChoice {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly label: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reads a choice with the arrow keys and returns the chosen option's id. The
|
|
48
|
+
* option block is redrawn in place. Enter commits the highlighted row, so a
|
|
49
|
+
* value that is not an option can never be returned.
|
|
50
|
+
*/
|
|
51
|
+
export declare function promptForSelect(message: string, choices: readonly SelectChoice[], style?: TerminalStyle): Promise<string>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal prompts for credential entry (moved from the nax CLI, S5-4 M-28).
|
|
3
|
+
*
|
|
4
|
+
* Nothing typed into a secret prompt is echoed, and the terminal is restored on
|
|
5
|
+
* every exit path: submit, cancel, stream end and stream error. Ctrl+D cancels;
|
|
6
|
+
* it never submits (it once fell through to "submit" in nax's confirm prompt).
|
|
7
|
+
*/
|
|
8
|
+
/** Ctrl+C. */
|
|
9
|
+
const ETX = "\u0003";
|
|
10
|
+
/** Ctrl+D. Conventionally cancel, never submit. */
|
|
11
|
+
const EOT = "\u0004";
|
|
12
|
+
const CR = "\r";
|
|
13
|
+
const LF = "\n";
|
|
14
|
+
const BACKSPACE = "\u007F";
|
|
15
|
+
const ARROW_UP = "\u001b[A";
|
|
16
|
+
const ARROW_DOWN = "\u001b[B";
|
|
17
|
+
const identity = (text) => text;
|
|
18
|
+
export const PLAIN_STYLE = { accent: identity, dim: identity, bold: identity };
|
|
19
|
+
export class PromptCancelledError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("Prompt cancelled");
|
|
22
|
+
this.name = "PromptCancelledError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Test seam. `stdin` is unset by default and resolved to `process.stdin` only
|
|
27
|
+
* when a prompt runs: this module is reachable from the public barrel, and
|
|
28
|
+
* touching `process.stdin` at import would open it for every consumer.
|
|
29
|
+
*/
|
|
30
|
+
export const _terminalPromptDeps = {
|
|
31
|
+
stdin: undefined,
|
|
32
|
+
write: (text) => process.stdout.write(text),
|
|
33
|
+
};
|
|
34
|
+
function stdinOf() {
|
|
35
|
+
return _terminalPromptDeps.stdin ?? process.stdin;
|
|
36
|
+
}
|
|
37
|
+
/** What one typed character does to a line prompt. */
|
|
38
|
+
function keyAction(char, bufferEmpty, hasEmptyHook) {
|
|
39
|
+
if (char === ETX || char === EOT)
|
|
40
|
+
return { kind: "cancel" };
|
|
41
|
+
if (char === CR || char === LF) {
|
|
42
|
+
return bufferEmpty && hasEmptyHook ? { kind: "empty-submit" } : { kind: "submit" };
|
|
43
|
+
}
|
|
44
|
+
if (char === BACKSPACE)
|
|
45
|
+
return { kind: "erase" };
|
|
46
|
+
return { kind: "append" };
|
|
47
|
+
}
|
|
48
|
+
/** Applies a non-terminal key to the buffer; echoes when the prompt is visible. */
|
|
49
|
+
function applyEdit(action, char, buffer, echo, onEmptySubmit) {
|
|
50
|
+
if (action.kind === "empty-submit") {
|
|
51
|
+
onEmptySubmit?.();
|
|
52
|
+
return buffer;
|
|
53
|
+
}
|
|
54
|
+
if (action.kind === "erase") {
|
|
55
|
+
if (echo)
|
|
56
|
+
_terminalPromptDeps.write("\b \b");
|
|
57
|
+
return buffer.slice(0, -1);
|
|
58
|
+
}
|
|
59
|
+
if (echo)
|
|
60
|
+
_terminalPromptDeps.write(char);
|
|
61
|
+
return buffer + char;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `onEmptySubmit` turns Enter-on-an-empty-buffer into an action rather than a
|
|
65
|
+
* submission. An empty answer is meaningless for the prompts that use it (a
|
|
66
|
+
* pasted auth code), and spending the keystroke here keeps the whole login on a
|
|
67
|
+
* single stdin reader.
|
|
68
|
+
*/
|
|
69
|
+
function read(message, echo, onEmptySubmit, style) {
|
|
70
|
+
const stdin = stdinOf();
|
|
71
|
+
if (stdin.isTTY !== true)
|
|
72
|
+
return Promise.reject(new PromptCancelledError());
|
|
73
|
+
_terminalPromptDeps.write(`${style.accent("?")} ${message} `);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let buffer = "";
|
|
76
|
+
let settled = false;
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
if (settled)
|
|
79
|
+
return;
|
|
80
|
+
settled = true;
|
|
81
|
+
stdin.removeListener("data", onData);
|
|
82
|
+
stdin.removeListener("end", onEnd);
|
|
83
|
+
stdin.removeListener("error", onEnd);
|
|
84
|
+
stdin.setRawMode(false);
|
|
85
|
+
stdin.pause();
|
|
86
|
+
_terminalPromptDeps.write("\n");
|
|
87
|
+
};
|
|
88
|
+
const onEnd = () => {
|
|
89
|
+
cleanup();
|
|
90
|
+
reject(new PromptCancelledError());
|
|
91
|
+
};
|
|
92
|
+
const onData = (chunk) => {
|
|
93
|
+
for (const char of chunk) {
|
|
94
|
+
const action = keyAction(char, buffer.length === 0, onEmptySubmit !== undefined);
|
|
95
|
+
if (action.kind === "cancel") {
|
|
96
|
+
onEnd();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (action.kind === "submit") {
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve(buffer);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
buffer = applyEdit(action, char, buffer, echo, onEmptySubmit);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
stdin.setRawMode(true);
|
|
108
|
+
stdin.resume();
|
|
109
|
+
stdin.setEncoding("utf8");
|
|
110
|
+
stdin.on("data", onData);
|
|
111
|
+
stdin.once("end", onEnd);
|
|
112
|
+
stdin.once("error", onEnd);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Reads a secret. Nothing is echoed, not even a masking character. */
|
|
116
|
+
export function promptForSecret(message, style = PLAIN_STYLE) {
|
|
117
|
+
return read(message, false, undefined, style);
|
|
118
|
+
}
|
|
119
|
+
/** Reads a visible line, for non-secret answers such as a pasted auth code. */
|
|
120
|
+
export function promptForLine(message, onEmptySubmit, style = PLAIN_STYLE) {
|
|
121
|
+
return read(message, true, onEmptySubmit, style);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Reads a choice with the arrow keys and returns the chosen option's id. The
|
|
125
|
+
* option block is redrawn in place. Enter commits the highlighted row, so a
|
|
126
|
+
* value that is not an option can never be returned.
|
|
127
|
+
*/
|
|
128
|
+
export function promptForSelect(message, choices, style = PLAIN_STYLE) {
|
|
129
|
+
const stdin = stdinOf();
|
|
130
|
+
if (stdin.isTTY !== true)
|
|
131
|
+
return Promise.reject(new PromptCancelledError());
|
|
132
|
+
// Never silently pick for the user: an empty list is a caller bug.
|
|
133
|
+
if (choices.length === 0)
|
|
134
|
+
return Promise.reject(new PromptCancelledError());
|
|
135
|
+
_terminalPromptDeps.write(`${style.accent("?")} ${message}\n`);
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
let index = 0;
|
|
138
|
+
let settled = false;
|
|
139
|
+
let drawn = false;
|
|
140
|
+
const render = () => {
|
|
141
|
+
if (drawn)
|
|
142
|
+
_terminalPromptDeps.write(`\u001b[${choices.length}A`);
|
|
143
|
+
drawn = true;
|
|
144
|
+
for (const [i, choice] of choices.entries()) {
|
|
145
|
+
const active = i === index;
|
|
146
|
+
const marker = active ? style.accent(">") : " ";
|
|
147
|
+
const label = active ? style.accent(choice.label) : choice.label;
|
|
148
|
+
_terminalPromptDeps.write(`\r\u001b[2K${marker} ${label}\n`);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const cleanup = () => {
|
|
152
|
+
if (settled)
|
|
153
|
+
return;
|
|
154
|
+
settled = true;
|
|
155
|
+
stdin.removeListener("data", onData);
|
|
156
|
+
stdin.removeListener("end", onEnd);
|
|
157
|
+
stdin.removeListener("error", onEnd);
|
|
158
|
+
stdin.setRawMode(false);
|
|
159
|
+
stdin.pause();
|
|
160
|
+
};
|
|
161
|
+
const onEnd = () => {
|
|
162
|
+
cleanup();
|
|
163
|
+
reject(new PromptCancelledError());
|
|
164
|
+
};
|
|
165
|
+
const onData = (chunk) => {
|
|
166
|
+
// Whole-chunk matching: an arrow key is a three-byte escape sequence.
|
|
167
|
+
if (chunk.includes(ETX) || chunk.includes(EOT)) {
|
|
168
|
+
onEnd();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (chunk.includes(ARROW_UP)) {
|
|
172
|
+
index = (index - 1 + choices.length) % choices.length;
|
|
173
|
+
render();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (chunk.includes(ARROW_DOWN)) {
|
|
177
|
+
index = (index + 1) % choices.length;
|
|
178
|
+
render();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (chunk.includes(CR) || chunk.includes(LF)) {
|
|
182
|
+
const chosen = choices[index];
|
|
183
|
+
cleanup();
|
|
184
|
+
// biome-ignore lint/style/noNonNullAssertion: index is held in range by the modulo above.
|
|
185
|
+
resolve(chosen.id);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
stdin.setRawMode(true);
|
|
189
|
+
stdin.resume();
|
|
190
|
+
stdin.setEncoding("utf8");
|
|
191
|
+
stdin.on("data", onData);
|
|
192
|
+
stdin.once("end", onEnd);
|
|
193
|
+
stdin.once("error", onEnd);
|
|
194
|
+
render();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `grep -n x y` did not. 20 of 34 denials in the two runs after #1971 carried
|
|
20
20
|
* no redirect (nax#1999). A single table cannot drift against itself.
|
|
21
21
|
*/
|
|
22
|
-
const GIT_READ_VERBS = new Set(["diff", "log", "show", "status", "blame"]);
|
|
22
|
+
const GIT_READ_VERBS = new Set(["diff", "log", "show", "status", "blame", "rev-parse"]);
|
|
23
23
|
/**
|
|
24
24
|
* Git verbs that stage or record a commit. Deliberately NOT every write verb:
|
|
25
25
|
* `git restore` reverts, which `GitCommit` does not do, and naming it would be
|
package/dist/tools/git.js
CHANGED
|
@@ -24,7 +24,7 @@ import { cutToByteCap, READ_CEILING } from "./truncate.js";
|
|
|
24
24
|
* Note what bounds these: the argv shape below, not the verb list. A read-only
|
|
25
25
|
* verb still spans the whole repository unless its scope is stated.
|
|
26
26
|
*/
|
|
27
|
-
export const GIT_READ_VERBS = ["diff", "log", "show", "status", "blame"];
|
|
27
|
+
export const GIT_READ_VERBS = ["diff", "log", "show", "status", "blame", "rev-parse"];
|
|
28
28
|
/**
|
|
29
29
|
* Typed flag fields, and the verbs each one is valid on.
|
|
30
30
|
*
|
|
@@ -164,6 +164,16 @@ export { GIT_ESCAPE_FLAGS } from "#src/tools/git-flags/index";
|
|
|
164
164
|
function looksLikeFlag(value) {
|
|
165
165
|
return value.startsWith("-");
|
|
166
166
|
}
|
|
167
|
+
function buildGitRootArgv(input) {
|
|
168
|
+
const extraFields = Object.keys(input).filter((field) => field !== "subcommand");
|
|
169
|
+
if (extraFields.length > 0) {
|
|
170
|
+
return { error: `rev-parse accepts no additional fields (received: ${extraFields.join(", ")})` };
|
|
171
|
+
}
|
|
172
|
+
return ["rev-parse", "--show-toplevel"];
|
|
173
|
+
}
|
|
174
|
+
function defaultGitExcludes(subcommand, excludes) {
|
|
175
|
+
return subcommand === "blame" ? [] : excludes;
|
|
176
|
+
}
|
|
167
177
|
/**
|
|
168
178
|
* `defaultExcludes`: pathspecs appended to an unscoped call (the host's own
|
|
169
179
|
* state, nax#2007); none by default.
|
|
@@ -173,6 +183,9 @@ export function buildGitArgv(input, defaultExcludes = []) {
|
|
|
173
183
|
if (typeof subcommand !== "string" || !GIT_READ_VERBS.includes(subcommand)) {
|
|
174
184
|
return { error: `subcommand must be one of: ${GIT_READ_VERBS.join(", ")}` };
|
|
175
185
|
}
|
|
186
|
+
if (subcommand === "rev-parse") {
|
|
187
|
+
return buildGitRootArgv(input);
|
|
188
|
+
}
|
|
176
189
|
const refs = Array.isArray(input.refs) ? input.refs : [];
|
|
177
190
|
const paths = Array.isArray(input.paths) ? input.paths : [];
|
|
178
191
|
// No `--relative` is injected. Since the single-frame redesign the permitted
|
|
@@ -281,8 +294,7 @@ export function buildGitArgv(input, defaultExcludes = []) {
|
|
|
281
294
|
// requested path would be a worse failure than the one being fixed.
|
|
282
295
|
// `blame` is exempt because git rejects exclude pathspecs on it and exits
|
|
283
296
|
// 128; do not "unify" it back in.
|
|
284
|
-
|
|
285
|
-
argv.push(...defaultExcludes);
|
|
297
|
+
argv.push(...defaultGitExcludes(subcommand, defaultExcludes));
|
|
286
298
|
return argv;
|
|
287
299
|
}
|
|
288
300
|
for (const path of paths) {
|
|
@@ -296,11 +308,15 @@ export function buildGitArgv(input, defaultExcludes = []) {
|
|
|
296
308
|
}
|
|
297
309
|
export const gitTool = {
|
|
298
310
|
name: "Git",
|
|
299
|
-
description: "Run a read-only git command (diff, log, show, status, blame) in the repository. Supply refs and pathspecs as arrays, not as a command line. Command-line flags are not accepted in any field; use the nameOnly, diffFilter, oneline, fullMessage and maxCount fields instead.",
|
|
311
|
+
description: "Run a read-only git command (diff, log, show, status, blame, rev-parse) in the repository. rev-parse is fixed to `git rev-parse --show-toplevel` and accepts no other fields. Supply refs and pathspecs as arrays, not as a command line. Command-line flags are not accepted in any field; use the nameOnly, diffFilter, oneline, fullMessage and maxCount fields instead.",
|
|
300
312
|
inputSchema: {
|
|
301
313
|
type: "object",
|
|
302
314
|
properties: {
|
|
303
|
-
subcommand: {
|
|
315
|
+
subcommand: {
|
|
316
|
+
type: "string",
|
|
317
|
+
enum: [...GIT_READ_VERBS],
|
|
318
|
+
description: "Read-only git subcommand; rev-parse is fixed to --show-toplevel",
|
|
319
|
+
},
|
|
304
320
|
refs: { type: "array", items: { type: "string" }, description: "Refs, e.g. ['HEAD~1','HEAD']" },
|
|
305
321
|
paths: { type: "array", items: { type: "string" }, description: "Pathspecs, relative to the permitted root" },
|
|
306
322
|
nameOnly: {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Transport-neutral scope hook, invoked only after coding-tool authorization. */
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import type { ProtectedPathsPolicy } from "./protected-paths.ts";
|
|
4
|
+
import type { ToolPolicy } from "./types.ts";
|
|
5
|
+
type InstructionAccess = (paths: readonly string[], canRead: (path: string) => boolean, protectedPaths?: ProtectedPathsPolicy) => Promise<string | undefined>;
|
|
6
|
+
export declare const _instructionAccessDeps: {
|
|
7
|
+
stat: typeof stat;
|
|
8
|
+
};
|
|
9
|
+
export declare function withInstructionAccess<T>(observer: InstructionAccess, run: () => Promise<T>): Promise<T>;
|
|
10
|
+
export declare function discoverAuthorizedInstructions(name: string, paths: readonly string[], policy: ToolPolicy, protectedPaths?: ProtectedPathsPolicy): Promise<string | undefined>;
|
|
11
|
+
/** A mutation must be proposed again after the model has received new guidance. */
|
|
12
|
+
export declare function enforceInstructionAccess(name: string, paths: readonly string[], policy: ToolPolicy, protectedPaths?: ProtectedPathsPolicy): Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Transport-neutral scope hook, invoked only after coding-tool authorization. */
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
6
|
+
import { credentialReadRefusal } from "./credential-read-deny.js";
|
|
7
|
+
export const _instructionAccessDeps = { stat };
|
|
8
|
+
const access = new AsyncLocalStorage();
|
|
9
|
+
export function withInstructionAccess(observer, run) {
|
|
10
|
+
return access.run(observer, run);
|
|
11
|
+
}
|
|
12
|
+
export async function discoverAuthorizedInstructions(name, paths, policy, protectedPaths) {
|
|
13
|
+
if (!["Read", "Write", "Edit"].includes(name))
|
|
14
|
+
return;
|
|
15
|
+
const observer = access.getStore();
|
|
16
|
+
if (observer === undefined || paths.some((path) => credentialReadRefusal(protectedPaths, path) !== undefined))
|
|
17
|
+
return;
|
|
18
|
+
const targets = name === "Write" ? paths.map((path) => dirname(path)) : paths;
|
|
19
|
+
const present = await Promise.all(targets.map((path) => _instructionAccessDeps.stat(path).then((metadata) => (name === "Write" ? metadata.isDirectory() : metadata.isFile()), () => false)));
|
|
20
|
+
if (present.some((found) => !found))
|
|
21
|
+
return;
|
|
22
|
+
const guidance = await observer(paths, (path) => policy.check("Read", { pathFields: ["path"] }, { path }).allowed, protectedPaths);
|
|
23
|
+
return name === "Read" ? undefined : guidance || undefined;
|
|
24
|
+
}
|
|
25
|
+
/** A mutation must be proposed again after the model has received new guidance. */
|
|
26
|
+
export async function enforceInstructionAccess(name, paths, policy, protectedPaths) {
|
|
27
|
+
const guidance = await discoverAuthorizedInstructions(name, paths, policy, protectedPaths);
|
|
28
|
+
if (guidance !== undefined)
|
|
29
|
+
throw new NaxError(guidance, "REPOSITORY_INSTRUCTIONS_DISCOVERED", { stage: "coding-tool" });
|
|
30
|
+
}
|
package/dist/tools/runtime.js
CHANGED
|
@@ -17,6 +17,7 @@ import { gitTool } from "./git.js";
|
|
|
17
17
|
import { gitCommitTool } from "./git-commit.js";
|
|
18
18
|
import { globTool } from "./glob.js";
|
|
19
19
|
import { grepTool } from "./grep.js";
|
|
20
|
+
import { enforceInstructionAccess } from "./instruction-access.js";
|
|
20
21
|
import { readTool } from "./read.js";
|
|
21
22
|
import { getCodingTool, registerBuiltinTool } from "./registry.js";
|
|
22
23
|
import { requestCapabilityTool } from "./request-capability.js";
|
|
@@ -224,7 +225,10 @@ export function createCodingToolRuntime(opts) {
|
|
|
224
225
|
log(tool, outcome, resultBytes, input, context, breach, reason, routineErrors, audit, resultBytesPreTruncation);
|
|
225
226
|
tap?.settle(outcome, audit?.approval?.decidedBy, audit?.executed === undefined
|
|
226
227
|
? undefined
|
|
227
|
-
: {
|
|
228
|
+
: {
|
|
229
|
+
executed: audit.executed,
|
|
230
|
+
...(audit.cwd !== undefined ? { cwd: audit.cwd } : {}),
|
|
231
|
+
});
|
|
228
232
|
};
|
|
229
233
|
/**
|
|
230
234
|
* Executes a permitted call and records its outcome. Shared by the
|
|
@@ -249,6 +253,7 @@ export function createCodingToolRuntime(opts) {
|
|
|
249
253
|
// scope.
|
|
250
254
|
const callSignal = context?.signal ?? signal;
|
|
251
255
|
try {
|
|
256
|
+
await enforceInstructionAccess(target.name, resolvedPaths, opts.policy, opts.protectedPaths);
|
|
252
257
|
const result = await target.run(callInput, {
|
|
253
258
|
root: opts.policy.root,
|
|
254
259
|
resolvedPaths,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.0",
|
|
4
4
|
"description": "nax's native coding agent: session contract, native loop, tools, permissions, sandbox, command-safety.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "William Khoo",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"./internal": {
|
|
30
30
|
"types": "./dist/internal.d.ts",
|
|
31
31
|
"import": "./dist/internal.js"
|
|
32
|
+
},
|
|
33
|
+
"./mcp": {
|
|
34
|
+
"types": "./dist/mcp/index.d.ts",
|
|
35
|
+
"import": "./dist/mcp/index.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"imports": {
|
|
@@ -42,7 +46,8 @@
|
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {
|
|
44
48
|
"@anthropic-ai/sandbox-runtime": "0.0.77",
|
|
45
|
-
"@
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
50
|
+
"@nathapp/nax-ai": "0.85.0",
|
|
46
51
|
"zod": "^4.3.6"
|
|
47
52
|
},
|
|
48
53
|
"publishConfig": {
|