@nathapp/nax-agent 0.3.0 → 0.84.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 +27 -0
- package/README.md +7 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -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 +5 -0
- package/dist/internal.js +5 -0
- package/dist/native/auth.d.ts +2 -0
- package/dist/native/auth.js +11 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +1 -1
- 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/turn-types.d.ts +4 -5
- package/dist/native/session/turn-types.js +8 -8
- 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-errors.d.ts +1 -1
- package/dist/session/agent-session-turn.js +12 -5
- 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 +20 -0
- package/dist/session/turn-event.d.ts +7 -0
- package/dist/session/turn-spend.d.ts +18 -0
- package/dist/session/turn-spend.js +9 -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/index.d.ts +1 -1
- 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 +2 -2
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
* primitive that surface calls into.
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { mkdir, readFile, stat
|
|
22
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
23
23
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
24
|
+
import { NaxError } from "#src/infra/index";
|
|
25
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
24
26
|
import { withPathFileLock } from "#src/internal/path-file-lock";
|
|
25
27
|
const EMPTY_FILE = { entries: [], taint: undefined };
|
|
26
28
|
/**
|
|
@@ -146,11 +148,11 @@ export async function readApprovalsFile(path) {
|
|
|
146
148
|
export async function readApprovals(path) {
|
|
147
149
|
return (await readApprovalsFile(path)).entries;
|
|
148
150
|
}
|
|
149
|
-
/** Serialize the whole store. Callers hold the path lock. */
|
|
151
|
+
/** Serialize the whole store, atomically (#16): a torn store would lose every remembered approval. Callers hold the path lock. */
|
|
150
152
|
export async function writeApprovalsFile(path, file) {
|
|
151
153
|
const body = file.taint === undefined ? { entries: file.entries } : { taint: file.taint, entries: file.entries };
|
|
152
154
|
await mkdir(dirname(path), { recursive: true });
|
|
153
|
-
await
|
|
155
|
+
await writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`);
|
|
154
156
|
}
|
|
155
157
|
/**
|
|
156
158
|
* Lock the read-modify-write. Worktree-isolated parallel stories share one
|
|
@@ -161,12 +163,23 @@ export async function writeApprovalsFile(path, file) {
|
|
|
161
163
|
*
|
|
162
164
|
* The taint marker is PRESERVED: a "remember" tapped during a forge-capable run
|
|
163
165
|
* lands in a store that stays untrusted.
|
|
166
|
+
*
|
|
167
|
+
* An unparseable store is refused, never replaced (the removal path does the same).
|
|
164
168
|
*/
|
|
165
169
|
export async function appendApproval(path, entry) {
|
|
166
170
|
await mkdir(dirname(path), { recursive: true });
|
|
167
171
|
await withPathFileLock(path, async () => {
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
// Detailed read, not readApprovalsFile: that one reads failure AS empty (right for a
|
|
173
|
+
// lookup), and writing "empty + this entry" back would destroy the store (#16). A read
|
|
174
|
+
// error propagates; the caller logs it and still allows the approved call.
|
|
175
|
+
const read = await readApprovalsFileDetailed(path);
|
|
176
|
+
if (read.state === "unparseable") {
|
|
177
|
+
throw new NaxError("approvals.json could not be parsed; not rewriting it", "APPROVALS_STORE_UNPARSEABLE", {
|
|
178
|
+
stage: "permissions",
|
|
179
|
+
path,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
await writeApprovalsFile(path, { taint: read.file.taint, entries: [...read.file.entries, entry] });
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
/**
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* The parent is `/tmp/nax`, one shared directory that is easier to find and
|
|
13
13
|
* list than a flat `/tmp/nax-<runId>`. It is shared, so it cannot be created
|
|
14
14
|
* once and trusted: a `/tmp/nax` made by one OS user is not writable by
|
|
15
|
-
* another. The parent is therefore re-resolved on EVERY call from
|
|
16
|
-
* facts the `_sessionTmpDeps` seam exposes
|
|
17
|
-
*
|
|
15
|
+
* another. The parent is therefore re-resolved on EVERY call from four host
|
|
16
|
+
* facts the `_sessionTmpDeps` seam exposes — including the uid that owns it,
|
|
17
|
+
* because a world-writable directory another user created is still theirs to
|
|
18
|
+
* plant symlinks in — with no cache, and anything nax cannot vouch for falls
|
|
19
|
+
* back to a per-user `/tmp/nax-<uid>`.
|
|
18
20
|
*
|
|
19
21
|
* The root is the literal `/tmp` (not `os.tmpdir()`): callers record and assert
|
|
20
22
|
* this machine-stable location, and the OS inherits the rest.
|
|
@@ -38,16 +40,20 @@ export const _sessionTmpDeps = {
|
|
|
38
40
|
uid: () => process.getuid?.() ?? 0,
|
|
39
41
|
};
|
|
40
42
|
/**
|
|
41
|
-
* True when `/tmp/nax` is a real directory the current user can write
|
|
42
|
-
* search. Absence is NOT this case: an absent parent is a usable shared
|
|
43
|
-
* (the launcher creates it), and every other failure means the path is
|
|
44
|
-
* a shape nax cannot vouch for.
|
|
43
|
+
* True when `/tmp/nax` is a real directory the current user OWNS and can write
|
|
44
|
+
* and search. Absence is NOT this case: an absent parent is a usable shared
|
|
45
|
+
* parent (the launcher creates it), and every other failure means the path is
|
|
46
|
+
* there in a shape nax cannot vouch for.
|
|
45
47
|
*/
|
|
46
48
|
function isUsableSharedParent() {
|
|
47
49
|
try {
|
|
48
50
|
const stats = _sessionTmpDeps.lstat(SHARED_TMP_PARENT);
|
|
49
51
|
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
50
52
|
return false;
|
|
53
|
+
// Writability is not trust: a 0777 /tmp/nax another user created lets that user
|
|
54
|
+
// pre-create the run directories or read what sandboxed commands write there.
|
|
55
|
+
if (stats.uid !== _sessionTmpDeps.uid())
|
|
56
|
+
return false;
|
|
51
57
|
}
|
|
52
58
|
catch (err) {
|
|
53
59
|
// ENOENT is the one failure that means "create /tmp/nax".
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* or registerCodingTool's TOOL_NAME_RESERVED.
|
|
5
5
|
*/
|
|
6
6
|
import { NaxError } from "#src/infra/nax-error";
|
|
7
|
-
export type AgentSessionErrorCode = "AGENT_SESSION_INVALID_OPTIONS" | "AGENT_SESSION_EXISTS" | "AGENT_SESSION_BUSY" | "AGENT_SESSION_CLOSED" | "AGENT_SESSION_INVALID_ANSWER" | "AGENT_SESSION_NOT_FOUND" | "AGENT_SESSION_SCHEMA_UNSUPPORTED" | "AGENT_SESSION_MODEL_MISMATCH" | "AGENT_SESSION_SANDBOX_UNAVAILABLE" | "AGENT_SESSION_TOOL_NAME_RESERVED" | "AGENT_SESSION_BACKEND_UNAVAILABLE" | "AGENT_SESSION_AUTH_REQUIRED" | "AGENT_SESSION_CAPABILITY_UNSUPPORTED" | "AGENT_SESSION_BACKEND_MISMATCH";
|
|
7
|
+
export type AgentSessionErrorCode = "AGENT_SESSION_INVALID_OPTIONS" | "AGENT_SESSION_EXISTS" | "AGENT_SESSION_BUSY" | "AGENT_SESSION_CLOSED" | "AGENT_SESSION_INVALID_ANSWER" | "AGENT_SESSION_NOT_FOUND" | "AGENT_SESSION_SCHEMA_UNSUPPORTED" | "AGENT_SESSION_MODEL_MISMATCH" | "AGENT_SESSION_SANDBOX_UNAVAILABLE" | "AGENT_SESSION_TOOL_NAME_RESERVED" | "AGENT_SESSION_BACKEND_UNAVAILABLE" | "AGENT_SESSION_AUTH_REQUIRED" | "AGENT_SESSION_RATE_LIMITED" | "AGENT_SESSION_CAPABILITY_UNSUPPORTED" | "AGENT_SESSION_BACKEND_MISMATCH";
|
|
8
8
|
export declare class AgentSessionError extends NaxError {
|
|
9
9
|
readonly code: AgentSessionErrorCode;
|
|
10
10
|
constructor(message: string, code: AgentSessionErrorCode, context?: Record<string, unknown>);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { NaxError } from "#src/infra/nax-error";
|
|
2
2
|
import { redactSecrets } from "#src/internal/redact";
|
|
3
|
-
import { readNativeTurnFailureUsage } from "#src/native/session/turn-types";
|
|
4
3
|
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
5
4
|
import { AgentSessionError } from "./agent-session-errors.js";
|
|
6
5
|
import { createSessionEventChannel } from "./session-event-channel.js";
|
|
7
6
|
import { SessionTurnError } from "./session-types.js";
|
|
7
|
+
import { readTurnSpend } from "./turn-spend.js";
|
|
8
8
|
/** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
|
|
9
9
|
export const ASK_HUMAN_BUDGET = 10;
|
|
10
10
|
/** Codes of the turn signal's abort reasons. AbortSignal keeps the first reason, so the first cause wins. */
|
|
@@ -57,10 +57,13 @@ function errorOf(err) {
|
|
|
57
57
|
return failure(err.code, message);
|
|
58
58
|
return failure("AGENT_SESSION_TURN_FAILED", message);
|
|
59
59
|
}
|
|
60
|
+
/** A failed turn's spend: attached to its error (#2367), carried by a SessionTurnError, or zero. */
|
|
60
61
|
function spendOf(err) {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
|
|
62
|
+
const attached = readTurnSpend(err);
|
|
63
|
+
if (attached !== undefined) {
|
|
64
|
+
const { tokenUsage: usage, costUsd, costSource } = attached;
|
|
65
|
+
return costSource === undefined ? { usage, costUsd } : { usage, costUsd, costSource };
|
|
66
|
+
}
|
|
64
67
|
if (err instanceof SessionTurnError)
|
|
65
68
|
return { usage: err.tokenUsage ?? ZERO_USAGE, costUsd: err.estimatedCostUsd ?? 0 };
|
|
66
69
|
return { usage: ZERO_USAGE, costUsd: 0 };
|
|
@@ -84,7 +87,11 @@ function sendTurn(ctx, live, message) {
|
|
|
84
87
|
maxInteractions: ASK_HUMAN_BUDGET,
|
|
85
88
|
turnId: live.turnId,
|
|
86
89
|
signal: live.signal,
|
|
87
|
-
|
|
90
|
+
// tool_progress is a liveness beat for S1 callers (nax's idle watchdog), not a session event.
|
|
91
|
+
onTurnEvent: (event) => {
|
|
92
|
+
if (event.type !== "tool_progress")
|
|
93
|
+
live.emit(event);
|
|
94
|
+
},
|
|
88
95
|
});
|
|
89
96
|
}
|
|
90
97
|
/** Writes the turn marker; a failure is returned, not thrown. */
|
|
@@ -117,6 +117,8 @@ export type SessionEventBody = {
|
|
|
117
117
|
readonly command?: string;
|
|
118
118
|
readonly reason: string;
|
|
119
119
|
readonly expiresAt: string;
|
|
120
|
+
/** `false`: decided already (by the profile); informational. Absent: a person's answer is awaited. */
|
|
121
|
+
readonly answerable?: false;
|
|
120
122
|
} | {
|
|
121
123
|
readonly type: "approval_resolved";
|
|
122
124
|
readonly requestId: string;
|
|
@@ -127,6 +129,8 @@ export type SessionEventBody = {
|
|
|
127
129
|
readonly requestId: string;
|
|
128
130
|
readonly text: string;
|
|
129
131
|
readonly expiresAt: string;
|
|
132
|
+
/** `false`: informational (`noteQuestion`); `answer()` on it does nothing. Absent: an answer is awaited. */
|
|
133
|
+
readonly answerable?: false;
|
|
130
134
|
} | {
|
|
131
135
|
readonly type: "usage";
|
|
132
136
|
readonly round: number;
|
|
@@ -5,12 +5,16 @@ import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
|
|
|
5
5
|
import type { AgentSessionHostPorts, AgentSessionProfile } from "./agent-session-types.ts";
|
|
6
6
|
export interface NativeBackendOptions {
|
|
7
7
|
readonly model: string;
|
|
8
|
+
/** Repository instruction basename; defaults to AGENTS.md. */
|
|
9
|
+
readonly instructionFileName?: string;
|
|
8
10
|
readonly credentials?: CredentialSource;
|
|
9
11
|
readonly catalogOverrides?: NativeCatalogOverrides;
|
|
10
12
|
readonly loopHandlers?: LoopHandlerSet;
|
|
11
13
|
readonly hostPorts?: AgentSessionHostPorts;
|
|
12
14
|
readonly bashApproval?: BashApprovalMode;
|
|
13
15
|
readonly allowUnsandboxed?: boolean;
|
|
16
|
+
/** Keep a resumed or continued conversation when the model differs (ACP server model switch, S5-3). */
|
|
17
|
+
readonly carryHistoryAcrossModels?: boolean;
|
|
14
18
|
}
|
|
15
19
|
export interface ResolvedNativeOptions {
|
|
16
20
|
/** The caller's object, unchanged. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { parseNativeModel } from "#src/native/models";
|
|
9
|
+
import { isInstructionFileName } from "#src/native/session/instruction-file-name";
|
|
9
10
|
import { AgentSessionError } from "./agent-session-errors.js";
|
|
10
11
|
function isRecord(value) {
|
|
11
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -16,6 +17,10 @@ function hasMethods(names) {
|
|
|
16
17
|
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
17
18
|
const OptionsSchema = z.strictObject({
|
|
18
19
|
model: z.string().min(1),
|
|
20
|
+
instructionFileName: z
|
|
21
|
+
.string()
|
|
22
|
+
.refine(isInstructionFileName, "must be a nonhidden Markdown basename without path separators, colon or control characters")
|
|
23
|
+
.optional(),
|
|
19
24
|
credentials: z
|
|
20
25
|
.custom((value) => isRecord(value) && (value.kind === "memory" || value.kind === "exec"), "must be a memory or exec source")
|
|
21
26
|
.optional(),
|
|
@@ -31,6 +36,7 @@ const OptionsSchema = z.strictObject({
|
|
|
31
36
|
.optional(),
|
|
32
37
|
bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
|
|
33
38
|
allowUnsandboxed: z.boolean().optional(),
|
|
39
|
+
carryHistoryAcrossModels: z.boolean().optional(),
|
|
34
40
|
});
|
|
35
41
|
function invalid(message, context = {}) {
|
|
36
42
|
return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
|
|
@@ -15,7 +15,10 @@ import { createSessionInteractionHandler, embedderToolDescriptor } from "./sessi
|
|
|
15
15
|
import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
|
|
16
16
|
export const NATIVE_BACKEND_KIND = "native";
|
|
17
17
|
/** nax's agent.native.transportRetry default (S3 spec 5.3). */
|
|
18
|
-
const SESSION_TRANSPORT_RETRY = {
|
|
18
|
+
const SESSION_TRANSPORT_RETRY = {
|
|
19
|
+
maxAttempts: 3,
|
|
20
|
+
baseDelayMs: 2000,
|
|
21
|
+
};
|
|
19
22
|
function createAdapter(raw) {
|
|
20
23
|
const overrides = raw.catalogOverrides ?? [];
|
|
21
24
|
const owns = overrides.length > 0 || raw.credentials !== undefined;
|
|
@@ -32,10 +35,13 @@ function checkResumeModel(doc, sessionId, model) {
|
|
|
32
35
|
}
|
|
33
36
|
async function openNative(resolved, ctx) {
|
|
34
37
|
const raw = resolved.raw;
|
|
35
|
-
if (ctx.resume !== undefined)
|
|
38
|
+
if (ctx.resume !== undefined && raw.carryHistoryAcrossModels !== true)
|
|
36
39
|
checkResumeModel(ctx.resume.doc, ctx.sessionId, raw.model);
|
|
37
40
|
const { bashApproval, allowUnsandboxed } = nativeProfileRules(ctx.profile, raw);
|
|
38
|
-
const protectedPaths = {
|
|
41
|
+
const protectedPaths = {
|
|
42
|
+
...defaultProtectedPaths(raw.credentials !== undefined),
|
|
43
|
+
...raw.hostPorts?.protectedPaths,
|
|
44
|
+
};
|
|
39
45
|
const launcher = await resolveSessionLauncher({
|
|
40
46
|
profile: ctx.profile,
|
|
41
47
|
root: ctx.workdir,
|
|
@@ -68,6 +74,8 @@ async function openNative(resolved, ctx) {
|
|
|
68
74
|
const handle = await adapter.openSession(ctx.sessionId, {
|
|
69
75
|
agentName: NATIVE_AGENT,
|
|
70
76
|
workdir: ctx.workdir,
|
|
77
|
+
instructionProtectedPaths: protectedPaths,
|
|
78
|
+
instructionFileName: raw.instructionFileName,
|
|
71
79
|
resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval },
|
|
72
80
|
modelDef: { provider: resolved.provider, model: raw.model },
|
|
73
81
|
timeoutSeconds: ctx.turnTimeoutSeconds,
|
|
@@ -76,6 +84,7 @@ async function openNative(resolved, ctx) {
|
|
|
76
84
|
resume: ctx.resume !== undefined,
|
|
77
85
|
spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
|
|
78
86
|
transportRetry: SESSION_TRANSPORT_RETRY,
|
|
87
|
+
...(raw.carryHistoryAcrossModels === true ? { carryHistoryAcrossModels: true } : {}),
|
|
79
88
|
...(ctx.instructions !== undefined && ctx.instructions !== "" ? { systemPrompt: ctx.instructions } : {}),
|
|
80
89
|
});
|
|
81
90
|
const codingTools = [...support.tools, ...ctx.tools.map(embedderToolDescriptor)];
|
|
@@ -102,5 +111,8 @@ async function openNative(resolved, ctx) {
|
|
|
102
111
|
}
|
|
103
112
|
export function nativeBackend(options) {
|
|
104
113
|
const resolved = parseNativeBackendOptions(options);
|
|
105
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
kind: NATIVE_BACKEND_KIND,
|
|
116
|
+
open: (ctx) => openNative(resolved, ctx),
|
|
117
|
+
};
|
|
106
118
|
}
|
|
@@ -41,6 +41,7 @@ export function createSessionAskPort(deps) {
|
|
|
41
41
|
summary: req.summary,
|
|
42
42
|
reason: req.reason,
|
|
43
43
|
expiresAt: now(),
|
|
44
|
+
answerable: false,
|
|
44
45
|
});
|
|
45
46
|
deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: "profile" });
|
|
46
47
|
},
|
|
@@ -58,7 +59,7 @@ export function createSessionAskPort(deps) {
|
|
|
58
59
|
if (deps.turn() === undefined)
|
|
59
60
|
return;
|
|
60
61
|
const { requestId } = deps.table.issue("question", ALREADY_ABORTED);
|
|
61
|
-
deps.emit({ type: "question", requestId, text, expiresAt: now() });
|
|
62
|
+
deps.emit({ type: "question", requestId, text, expiresAt: now(), answerable: false });
|
|
62
63
|
},
|
|
63
64
|
};
|
|
64
65
|
}
|
|
@@ -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
|
}
|
|
@@ -136,6 +136,16 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
136
136
|
* scratch dir reaches the adapter otherwise. ACP ignores it.
|
|
137
137
|
*/
|
|
138
138
|
transcriptDir?: string;
|
|
139
|
+
/**
|
|
140
|
+
* S4b spec 7.4: where an S1 adapter that writes its own tool-audit ledger (nax's
|
|
141
|
+
* ACP adapter) writes it, and the run-scoped header stamped on it. nax fills it
|
|
142
|
+
* from the same resolution the native coding tools use. nax-agent itself does not
|
|
143
|
+
* read it; native takes its sink from coding-tool support.
|
|
144
|
+
*/
|
|
145
|
+
toolAudit?: {
|
|
146
|
+
readonly dir: string;
|
|
147
|
+
readonly header: import("#src/tools/tool-audit").ToolAuditHeader;
|
|
148
|
+
};
|
|
139
149
|
/**
|
|
140
150
|
* Native: identity that owns this session's transcript — the op invocation's
|
|
141
151
|
* `scopeId ?? callId`. Stable across the retries and hops of one invocation,
|
|
@@ -165,6 +175,14 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
165
175
|
* facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
|
|
166
176
|
*/
|
|
167
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;
|
|
168
186
|
/**
|
|
169
187
|
* Native: resolved compaction settings. A resolved primitive, never NaxConfig —
|
|
170
188
|
* src/agents/native/ must not read config (check:adapter-no-config-import).
|
|
@@ -182,6 +200,8 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
182
200
|
* NaxConfig. ACP ignores it; its loop is bounded by `maxInteractions`.
|
|
183
201
|
*/
|
|
184
202
|
spinBreaker?: import("#src/infra/spin-breaker/index").ResolvedSpinBreakerSettings;
|
|
203
|
+
/** Native: keep history written by another model (S5-3). */
|
|
204
|
+
readonly carryHistoryAcrossModels?: boolean;
|
|
185
205
|
}
|
|
186
206
|
/** Options for sendTurn(). */
|
|
187
207
|
export interface SendTurnOpts {
|
|
@@ -43,6 +43,13 @@ export type TurnEvent = {
|
|
|
43
43
|
readonly callId: string;
|
|
44
44
|
readonly isError: boolean;
|
|
45
45
|
readonly preview: string;
|
|
46
|
+
/** UTF-8 byte length of the full result before the preview cap (S4b spec 7.4). Absent when unknown. */
|
|
47
|
+
readonly resultBytes?: number;
|
|
48
|
+
}
|
|
49
|
+
/** A running tool's liveness beat (an ACP heartbeat); carries no content. Not a session event. */
|
|
50
|
+
| {
|
|
51
|
+
readonly type: "tool_progress";
|
|
52
|
+
readonly callId: string;
|
|
46
53
|
} | {
|
|
47
54
|
readonly type: "usage";
|
|
48
55
|
readonly round: number;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spend a failed turn had already incurred, attached to the error it throws
|
|
3
|
+
* (#2367). Keyed on the error's identity, so the error itself is rethrown
|
|
4
|
+
* unchanged and keeps its own code. The facade's turn_end reads it on every
|
|
5
|
+
* error path; the native loop writes it through recordNativeTurnFailureUsage
|
|
6
|
+
* (nax#1840), an ACP backend through attachTurnSpend.
|
|
7
|
+
*/
|
|
8
|
+
import type { TokenUsage } from "#src/cost/standard-types";
|
|
9
|
+
import type { CostSource } from "./agent-session-types.ts";
|
|
10
|
+
export interface FailedTurnSpend {
|
|
11
|
+
readonly tokenUsage: TokenUsage;
|
|
12
|
+
readonly costUsd: number;
|
|
13
|
+
readonly costSource?: CostSource;
|
|
14
|
+
}
|
|
15
|
+
/** Attaches the spend a failed turn had already incurred to the error it throws. */
|
|
16
|
+
export declare function attachTurnSpend(err: object, spend: FailedTurnSpend): void;
|
|
17
|
+
/** The spend attached to `err`, if any. */
|
|
18
|
+
export declare function readTurnSpend(err: unknown): FailedTurnSpend | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const spendByError = new WeakMap();
|
|
2
|
+
/** Attaches the spend a failed turn had already incurred to the error it throws. */
|
|
3
|
+
export function attachTurnSpend(err, spend) {
|
|
4
|
+
spendByError.set(err, Object.freeze({ ...spend }));
|
|
5
|
+
}
|
|
6
|
+
/** The spend attached to `err`, if any. */
|
|
7
|
+
export function readTurnSpend(err) {
|
|
8
|
+
return typeof err === "object" && err !== null ? spendByError.get(err) : undefined;
|
|
9
|
+
}
|
|
@@ -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>;
|