nolo-cli 0.1.16 → 0.1.18
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/agent-runtime/localLoop.ts +36 -2
- package/agent-runtime/types.ts +1 -0
- package/agentAliases.ts +26 -0
- package/agentPullCommand.ts +2 -1
- package/agentRunCommand.ts +73 -5
- package/client/agentRun.ts +2 -0
- package/client/localRuntimeAdapter.test.ts +68 -1
- package/client/localRuntimeAdapter.ts +88 -2
- package/client/localToolPolicy.test.ts +10 -5
- package/client/localToolPolicy.ts +12 -1
- package/package.json +2 -1
|
@@ -12,6 +12,7 @@ export type LocalAgentTurnInput = {
|
|
|
12
12
|
agentRef: string;
|
|
13
13
|
input: AgentRuntimeMessageContent;
|
|
14
14
|
continueDialogId?: string;
|
|
15
|
+
maxToolRounds?: number;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export type LocalAgentTurnResult = AgentRuntimeResult & {
|
|
@@ -49,15 +50,48 @@ export async function runLocalAgentTurn(
|
|
|
49
50
|
input: input.input,
|
|
50
51
|
});
|
|
51
52
|
const provider = await input.adapter.resolveProvider(agentConfig);
|
|
52
|
-
const
|
|
53
|
+
const maxToolRounds = input.maxToolRounds ?? 6;
|
|
54
|
+
let toolCallCount = 0;
|
|
55
|
+
let result: AgentRuntimeResult;
|
|
56
|
+
for (let round = 0; round <= maxToolRounds; round += 1) {
|
|
57
|
+
result = await provider.complete(messages);
|
|
58
|
+
const toolCalls = result.tool_calls ?? [];
|
|
59
|
+
if (toolCalls.length === 0) break;
|
|
60
|
+
if (round === maxToolRounds) {
|
|
61
|
+
throw new Error(`Local agent exceeded max tool rounds: ${maxToolRounds}`);
|
|
62
|
+
}
|
|
63
|
+
toolCallCount += toolCalls.length;
|
|
64
|
+
messages.push({
|
|
65
|
+
role: "assistant",
|
|
66
|
+
content: result.content || null,
|
|
67
|
+
tool_calls: toolCalls,
|
|
68
|
+
});
|
|
69
|
+
for (const toolCall of toolCalls) {
|
|
70
|
+
const toolResult = await input.adapter.executeTool({
|
|
71
|
+
id: toolCall.id,
|
|
72
|
+
name: toolCall.function.name,
|
|
73
|
+
arguments: toolCall.function.arguments,
|
|
74
|
+
});
|
|
75
|
+
messages.push({
|
|
76
|
+
role: "tool",
|
|
77
|
+
content: toolResult.content,
|
|
78
|
+
tool_call_id: toolCall.id,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
result = result!;
|
|
53
83
|
const saved = await input.adapter.saveTurn({
|
|
54
84
|
agentKey: agentConfig.key,
|
|
55
85
|
messages,
|
|
56
|
-
result
|
|
86
|
+
result: {
|
|
87
|
+
...result,
|
|
88
|
+
...(toolCallCount > 0 ? { toolCallCount } : {}),
|
|
89
|
+
},
|
|
57
90
|
});
|
|
58
91
|
|
|
59
92
|
return {
|
|
60
93
|
...result,
|
|
94
|
+
...(toolCallCount > 0 ? { toolCallCount } : {}),
|
|
61
95
|
dialogId: saved.dialogId,
|
|
62
96
|
};
|
|
63
97
|
}
|
package/agent-runtime/types.ts
CHANGED
package/agentAliases.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const PLATFORM_DEMO_USER_ID = "b2e06f801f";
|
|
2
|
+
export const FRONTEND_IMPLEMENTER_AGENT_ID = "01FRONTENDAG0000000115N4E1";
|
|
3
|
+
export const FRONTEND_IMPLEMENTER_AGENT_KEY =
|
|
4
|
+
`agent-${PLATFORM_DEMO_USER_ID}-${FRONTEND_IMPLEMENTER_AGENT_ID}`;
|
|
5
|
+
|
|
6
|
+
const AGENT_ALIAS_TO_KEY: Record<string, string> = {
|
|
7
|
+
"frontend-implementer": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
8
|
+
"frontend-agent": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
9
|
+
frontend: FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
10
|
+
"front-end": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
11
|
+
"前端": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
12
|
+
"前端实现员": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseAgentKeyFromInput(raw: string): string {
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
18
|
+
const url = new URL(trimmed);
|
|
19
|
+
return url.pathname.replace(/^\/+/, "");
|
|
20
|
+
}
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveCliAgentKeyInput(raw: string): string {
|
|
25
|
+
return AGENT_ALIAS_TO_KEY[raw.trim().toLowerCase()] ?? parseAgentKeyFromInput(raw);
|
|
26
|
+
}
|
package/agentPullCommand.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
2
2
|
import { getDefaultCliLocalRuntimeDb } from "./localRuntimeDb";
|
|
3
3
|
import type { CliLocalRuntimeDb } from "./client/localRuntimeAdapter";
|
|
4
|
+
import { resolveCliAgentKeyInput } from "./agentAliases";
|
|
4
5
|
|
|
5
6
|
type EnvLike = Record<string, string | undefined>;
|
|
6
7
|
|
|
@@ -40,7 +41,7 @@ function positionalArgs(args: string[]) {
|
|
|
40
41
|
export function parseAgentPullArgs(args: string[]): ParsedAgentPullArgs | null {
|
|
41
42
|
const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
|
|
42
43
|
if (!agentKey?.trim()) return null;
|
|
43
|
-
return { agentKey: agentKey
|
|
44
|
+
return { agentKey: resolveCliAgentKeyInput(agentKey) };
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
function resolveServerUrl(env: EnvLike) {
|
package/agentRunCommand.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
|
2
2
|
import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
|
|
3
3
|
import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { extname, resolve } from "node:path";
|
|
5
|
+
import { extname, join, resolve } from "node:path";
|
|
6
|
+
import { resolveCliAgentKeyInput } from "./agentAliases";
|
|
6
7
|
|
|
7
8
|
type EnvLike = Record<string, string | undefined>;
|
|
8
9
|
|
|
@@ -15,12 +16,14 @@ type AgentRunCommandDeps = {
|
|
|
15
16
|
scriptDir: string;
|
|
16
17
|
output?: OutputLike;
|
|
17
18
|
runner?: typeof runAgentTurn;
|
|
19
|
+
prepareShellWorktree?: typeof prepareShellWorktree;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
type ParsedAgentRunArgs = {
|
|
21
23
|
agentKey: string;
|
|
22
24
|
message: string;
|
|
23
25
|
imageUrls: string[];
|
|
26
|
+
allowShell: boolean;
|
|
24
27
|
runtimeMode?: AgentRuntimeRequestedMode;
|
|
25
28
|
continueDialogId?: string;
|
|
26
29
|
};
|
|
@@ -51,9 +54,15 @@ function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefi
|
|
|
51
54
|
|
|
52
55
|
function positionalArgs(args: string[]) {
|
|
53
56
|
const values: string[] = [];
|
|
57
|
+
const valuelessFlags = new Set([
|
|
58
|
+
"--local",
|
|
59
|
+
"--server",
|
|
60
|
+
"--auto",
|
|
61
|
+
"--dangerously-allow-shell",
|
|
62
|
+
]);
|
|
54
63
|
for (let index = 0; index < args.length; index += 1) {
|
|
55
64
|
const arg = args[index];
|
|
56
|
-
if (arg
|
|
65
|
+
if (valuelessFlags.has(arg)) continue;
|
|
57
66
|
if (arg.startsWith("--")) {
|
|
58
67
|
index += 1;
|
|
59
68
|
continue;
|
|
@@ -75,9 +84,10 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
|
|
|
75
84
|
...readRepeatedFlagValues(args, "--image-url"),
|
|
76
85
|
];
|
|
77
86
|
return {
|
|
78
|
-
agentKey,
|
|
87
|
+
agentKey: resolveCliAgentKeyInput(agentKey),
|
|
79
88
|
message: message.trim(),
|
|
80
89
|
imageUrls,
|
|
90
|
+
allowShell: args.includes("--dangerously-allow-shell"),
|
|
81
91
|
...(runtimeMode ? { runtimeMode } : {}),
|
|
82
92
|
...(continueDialogId ? { continueDialogId } : {}),
|
|
83
93
|
};
|
|
@@ -109,10 +119,53 @@ function resolveServerUrl(env: EnvLike) {
|
|
|
109
119
|
return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
|
|
110
120
|
}
|
|
111
121
|
|
|
122
|
+
function safePathSegment(input: string) {
|
|
123
|
+
return input.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createTaskId() {
|
|
127
|
+
return Date.now().toString(36).toUpperCase();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runGit(args: string[], cwd: string) {
|
|
131
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
132
|
+
cwd,
|
|
133
|
+
stdout: "pipe",
|
|
134
|
+
stderr: "pipe",
|
|
135
|
+
stdin: "ignore",
|
|
136
|
+
});
|
|
137
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
138
|
+
new Response(proc.stdout).text(),
|
|
139
|
+
new Response(proc.stderr).text(),
|
|
140
|
+
proc.exited,
|
|
141
|
+
]);
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`);
|
|
144
|
+
}
|
|
145
|
+
return stdout.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function prepareShellWorktree(args: {
|
|
149
|
+
agentKey: string;
|
|
150
|
+
cwd?: string;
|
|
151
|
+
env?: EnvLike;
|
|
152
|
+
}) {
|
|
153
|
+
if (args.env?.NOLO_LOCAL_WORKTREE) return args.env.NOLO_LOCAL_WORKTREE;
|
|
154
|
+
const cwd = args.cwd ?? process.cwd();
|
|
155
|
+
const root = await runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
156
|
+
const parent = join(root, ".worktrees");
|
|
157
|
+
const safeAgent = safePathSegment(args.agentKey);
|
|
158
|
+
const taskId = createTaskId();
|
|
159
|
+
const worktreePath = join(parent, `nolo-agent-${safeAgent}-${taskId}`);
|
|
160
|
+
const branchName = `nolo-agent-${safeAgent}-${taskId}`;
|
|
161
|
+
await runGit(["worktree", "add", worktreePath, "-b", branchName, "HEAD"], root);
|
|
162
|
+
return worktreePath;
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
function writeUsage(output: OutputLike) {
|
|
113
166
|
output.write(
|
|
114
167
|
"Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
|
|
115
|
-
" nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--local|--server|--auto]\n"
|
|
168
|
+
" nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--dangerously-allow-shell] [--local|--server|--auto]\n"
|
|
116
169
|
);
|
|
117
170
|
}
|
|
118
171
|
|
|
@@ -126,6 +179,20 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
|
|
|
126
179
|
}
|
|
127
180
|
|
|
128
181
|
const runner = deps.runner ?? runAgentTurn;
|
|
182
|
+
let localRuntimeCwd: string | undefined;
|
|
183
|
+
if (parsed.allowShell) {
|
|
184
|
+
localRuntimeCwd = await (deps.prepareShellWorktree ?? prepareShellWorktree)({
|
|
185
|
+
agentKey: parsed.agentKey,
|
|
186
|
+
env,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const runEnv = parsed.allowShell
|
|
190
|
+
? {
|
|
191
|
+
...env,
|
|
192
|
+
NOLO_LOCAL_SHELL_MODE: "worktree",
|
|
193
|
+
...(localRuntimeCwd ? { NOLO_LOCAL_WORKTREE: localRuntimeCwd } : {}),
|
|
194
|
+
}
|
|
195
|
+
: env;
|
|
129
196
|
const result: RunAgentTurnResult = await runner({
|
|
130
197
|
agentName: parsed.agentKey,
|
|
131
198
|
agentKey: parsed.agentKey,
|
|
@@ -133,8 +200,9 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
|
|
|
133
200
|
message: parsed.message,
|
|
134
201
|
imageUrls: parsed.imageUrls.map(normalizeCliImageInput),
|
|
135
202
|
scriptDir: deps.scriptDir,
|
|
136
|
-
env,
|
|
203
|
+
env: runEnv,
|
|
137
204
|
output,
|
|
205
|
+
...(localRuntimeCwd ? { localRuntimeCwd } : {}),
|
|
138
206
|
...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
|
|
139
207
|
...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
|
|
140
208
|
});
|
package/client/agentRun.ts
CHANGED
|
@@ -24,6 +24,7 @@ type RunAgentTurnOptions = {
|
|
|
24
24
|
runtimeMode?: AgentRuntimeRequestedMode;
|
|
25
25
|
localRuntimeAdapter?: AgentRuntimeHostAdapter;
|
|
26
26
|
localRuntimeAdapterFactory?: (env: EnvLike) => AgentRuntimeHostAdapter;
|
|
27
|
+
localRuntimeCwd?: string;
|
|
27
28
|
scriptPathExists?: (path: string) => boolean;
|
|
28
29
|
fetchImpl?: typeof fetch;
|
|
29
30
|
};
|
|
@@ -61,6 +62,7 @@ function buildDefaultLocalRuntimeAdapter(options: RunAgentTurnOptions) {
|
|
|
61
62
|
return createCliLocalRuntimeAdapter({
|
|
62
63
|
env: options.env,
|
|
63
64
|
fetchImpl: options.fetchImpl,
|
|
65
|
+
cwd: options.localRuntimeCwd,
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -173,7 +173,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
173
173
|
id: "call-1",
|
|
174
174
|
name: "execShell",
|
|
175
175
|
arguments: "{}",
|
|
176
|
-
})).rejects.toThrow("
|
|
176
|
+
})).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE");
|
|
177
177
|
});
|
|
178
178
|
|
|
179
179
|
test("executes explicitly allowed registered local tools declared by the agent", async () => {
|
|
@@ -214,4 +214,71 @@ describe("CLI local runtime adapter", () => {
|
|
|
214
214
|
|
|
215
215
|
expect(result.content).toContain("README.md");
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
test("advertises execShell to OpenAI-compatible providers when shell mode is enabled", async () => {
|
|
219
|
+
const requests: Array<{ body: any }> = [];
|
|
220
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
221
|
+
env: {
|
|
222
|
+
OPENAI_API_KEY: "sk-local",
|
|
223
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
224
|
+
NOLO_LOCAL_SHELL_MODE: "worktree",
|
|
225
|
+
},
|
|
226
|
+
db: {
|
|
227
|
+
get: async () => ({
|
|
228
|
+
dbKey: "agent-local-shell",
|
|
229
|
+
prompt: "Use shell.",
|
|
230
|
+
model: "gpt-4.1-mini",
|
|
231
|
+
}),
|
|
232
|
+
put: async () => {},
|
|
233
|
+
batch: async () => {},
|
|
234
|
+
iterator: () => (async function* () {})(),
|
|
235
|
+
},
|
|
236
|
+
fetchImpl: async (_url, init) => {
|
|
237
|
+
requests.push({ body: JSON.parse(String(init?.body)) });
|
|
238
|
+
return Response.json({
|
|
239
|
+
choices: [{ message: { content: "done" } }],
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await runLocalAgentTurn({
|
|
245
|
+
adapter,
|
|
246
|
+
agentRef: "shell",
|
|
247
|
+
input: "pwd",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(requests[0]?.body.tools).toEqual([{
|
|
251
|
+
type: "function",
|
|
252
|
+
function: expect.objectContaining({
|
|
253
|
+
name: "execShell",
|
|
254
|
+
}),
|
|
255
|
+
}]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("runs execShell locally in explicit worktree shell mode", async () => {
|
|
259
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
260
|
+
env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
|
|
261
|
+
db: {
|
|
262
|
+
get: async () => ({
|
|
263
|
+
dbKey: "agent-local-shell",
|
|
264
|
+
toolNames: ["execShell"],
|
|
265
|
+
}),
|
|
266
|
+
put: async () => {},
|
|
267
|
+
batch: async () => {},
|
|
268
|
+
iterator: () => (async function* () {})(),
|
|
269
|
+
},
|
|
270
|
+
cwd: import.meta.dir,
|
|
271
|
+
fetchImpl: async () => Response.json({}),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await adapter.loadAgentConfig("shell");
|
|
275
|
+
const result = await adapter.executeTool({
|
|
276
|
+
id: "call-1",
|
|
277
|
+
name: "execShell",
|
|
278
|
+
arguments: "{\"cmd\":\"pwd\"}",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(result.content).toContain(import.meta.dir);
|
|
282
|
+
expect(result.metadata).toMatchObject({ exitCode: 0 });
|
|
283
|
+
});
|
|
217
284
|
});
|
|
@@ -22,6 +22,7 @@ export type CliLocalRuntimeAdapterDeps = {
|
|
|
22
22
|
now?: () => number;
|
|
23
23
|
createId?: () => string;
|
|
24
24
|
fetchImpl?: typeof fetch;
|
|
25
|
+
cwd?: string;
|
|
25
26
|
localToolExecutors?: Record<string, (call: any) => Promise<{ content: string; metadata?: Record<string, unknown> }>>;
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -46,6 +47,83 @@ function resolveLocalUserId(env: EnvLike) {
|
|
|
46
47
|
return env.NOLO_LOCAL_USER_ID || env.NOLO_USER_ID || "local";
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function buildExecShellTool() {
|
|
51
|
+
return {
|
|
52
|
+
type: "function",
|
|
53
|
+
function: {
|
|
54
|
+
name: "execShell",
|
|
55
|
+
description: "Run a shell command in the current isolated local workspace.",
|
|
56
|
+
parameters: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
cmd: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Shell command to run.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ["cmd"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function shellModeEnabled(env: EnvLike) {
|
|
71
|
+
return ["worktree", "dangerous", "1", "true"].includes(env.NOLO_LOCAL_SHELL_MODE || "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildOpenAiTools(args: { toolNames?: string[]; env: EnvLike }) {
|
|
75
|
+
return args.toolNames?.includes("execShell") || shellModeEnabled(args.env)
|
|
76
|
+
? [buildExecShellTool()]
|
|
77
|
+
: [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseToolArguments(raw: string) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(raw || "{}");
|
|
83
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
84
|
+
} catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function executeShellCommand(args: {
|
|
90
|
+
call: any;
|
|
91
|
+
cwd: string;
|
|
92
|
+
}) {
|
|
93
|
+
const parsed = parseToolArguments(String(args.call.arguments ?? ""));
|
|
94
|
+
const cmd = String(parsed.cmd ?? parsed.command ?? "").trim();
|
|
95
|
+
if (!cmd) throw new Error("execShell requires a non-empty cmd argument.");
|
|
96
|
+
const proc = Bun.spawn(["/bin/sh", "-lc", cmd], {
|
|
97
|
+
cwd: args.cwd,
|
|
98
|
+
stdout: "pipe",
|
|
99
|
+
stderr: "pipe",
|
|
100
|
+
stdin: "ignore",
|
|
101
|
+
});
|
|
102
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
103
|
+
new Response(proc.stdout).text(),
|
|
104
|
+
new Response(proc.stderr).text(),
|
|
105
|
+
proc.exited,
|
|
106
|
+
]);
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
stdout.trim() ? `stdout:\n${stdout.trim()}` : "",
|
|
110
|
+
stderr.trim() ? `stderr:\n${stderr.trim()}` : "",
|
|
111
|
+
`exitCode: ${exitCode}`,
|
|
112
|
+
].filter(Boolean).join("\n\n"),
|
|
113
|
+
metadata: { exitCode },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildLocalToolExecutors(deps: CliLocalRuntimeAdapterDeps) {
|
|
118
|
+
return {
|
|
119
|
+
execShell: (call: any) => executeShellCommand({
|
|
120
|
+
call,
|
|
121
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
122
|
+
}),
|
|
123
|
+
...(deps.localToolExecutors ?? {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
49
127
|
async function resolveDb(deps: CliLocalRuntimeAdapterDeps) {
|
|
50
128
|
return deps.db ?? defaultLocalRuntimeDb();
|
|
51
129
|
}
|
|
@@ -191,6 +269,7 @@ export function createCliLocalRuntimeAdapter(
|
|
|
191
269
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
192
270
|
const userId = resolveLocalUserId(deps.env);
|
|
193
271
|
let activeAgentToolNames: string[] = [];
|
|
272
|
+
const localToolExecutors = buildLocalToolExecutors(deps);
|
|
194
273
|
|
|
195
274
|
return {
|
|
196
275
|
host: "cli",
|
|
@@ -218,6 +297,10 @@ export function createCliLocalRuntimeAdapter(
|
|
|
218
297
|
resolveProvider: async (agentConfig) => ({
|
|
219
298
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
220
299
|
complete: async (messages) => {
|
|
300
|
+
const tools = buildOpenAiTools({
|
|
301
|
+
toolNames: agentConfig.toolNames,
|
|
302
|
+
env: deps.env,
|
|
303
|
+
});
|
|
221
304
|
const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
|
|
222
305
|
method: "POST",
|
|
223
306
|
headers: {
|
|
@@ -230,17 +313,20 @@ export function createCliLocalRuntimeAdapter(
|
|
|
230
313
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
231
314
|
messages: toOpenAiMessages(messages),
|
|
232
315
|
stream: false,
|
|
316
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
233
317
|
}),
|
|
234
318
|
});
|
|
235
319
|
const data = await res.json().catch(() => ({}));
|
|
236
320
|
if (!res.ok) {
|
|
237
321
|
throw new Error(`local provider failed: HTTP ${res.status} ${JSON.stringify(data)}`);
|
|
238
322
|
}
|
|
239
|
-
const
|
|
323
|
+
const choiceMessage = data?.choices?.[0]?.message ?? {};
|
|
324
|
+
const content = String(choiceMessage?.content ?? "");
|
|
240
325
|
return {
|
|
241
326
|
content,
|
|
242
327
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
243
328
|
provider: agentConfig.provider || "openai-compatible",
|
|
329
|
+
...(Array.isArray(choiceMessage?.tool_calls) ? { tool_calls: choiceMessage.tool_calls } : {}),
|
|
244
330
|
usage: data?.usage,
|
|
245
331
|
trace: messages,
|
|
246
332
|
};
|
|
@@ -250,7 +336,7 @@ export function createCliLocalRuntimeAdapter(
|
|
|
250
336
|
env: deps.env,
|
|
251
337
|
agentToolNames: activeAgentToolNames,
|
|
252
338
|
call,
|
|
253
|
-
executors:
|
|
339
|
+
executors: localToolExecutors,
|
|
254
340
|
}),
|
|
255
341
|
};
|
|
256
342
|
}
|
|
@@ -3,15 +3,20 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import { executeLocalToolWithPolicy, resolveLocalToolPolicy } from "./localToolPolicy";
|
|
4
4
|
|
|
5
5
|
describe("CLI local tool policy", () => {
|
|
6
|
-
test("
|
|
6
|
+
test("allows execShell in explicit worktree shell mode", () => {
|
|
7
|
+
expect(resolveLocalToolPolicy({
|
|
8
|
+
env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
|
|
9
|
+
agentToolNames: [],
|
|
10
|
+
toolName: "execShell",
|
|
11
|
+
})).toEqual({ allowed: true, toolName: "execShell" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("blocks execShell unless explicit shell mode is enabled", () => {
|
|
7
15
|
expect(resolveLocalToolPolicy({
|
|
8
16
|
env: { NOLO_LOCAL_ALLOWED_TOOLS: "execShell" },
|
|
9
17
|
agentToolNames: ["execShell"],
|
|
10
18
|
toolName: "execShell",
|
|
11
|
-
})).toMatchObject({
|
|
12
|
-
allowed: false,
|
|
13
|
-
reason: expect.stringContaining("blocked"),
|
|
14
|
-
});
|
|
19
|
+
})).toMatchObject({ allowed: false });
|
|
15
20
|
});
|
|
16
21
|
|
|
17
22
|
test("requires both env allowlist and agent declaration", () => {
|
|
@@ -7,7 +7,6 @@ export type LocalToolPolicyDecision =
|
|
|
7
7
|
| { allowed: false; toolName: string; reason: string };
|
|
8
8
|
|
|
9
9
|
const NEVER_LOCAL_TOOLS = new Set([
|
|
10
|
-
"execShell",
|
|
11
10
|
"deleteSpaces",
|
|
12
11
|
"updateAgent",
|
|
13
12
|
"updateSelf",
|
|
@@ -26,6 +25,18 @@ export function resolveLocalToolPolicy(args: {
|
|
|
26
25
|
agentToolNames?: string[];
|
|
27
26
|
toolName: string;
|
|
28
27
|
}): LocalToolPolicyDecision {
|
|
28
|
+
if (args.toolName === "execShell") {
|
|
29
|
+
const shellMode = args.env.NOLO_LOCAL_SHELL_MODE || "";
|
|
30
|
+
if (["worktree", "dangerous", "1", "true"].includes(shellMode)) {
|
|
31
|
+
return { allowed: true, toolName: args.toolName };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
allowed: false,
|
|
35
|
+
toolName: args.toolName,
|
|
36
|
+
reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
if (NEVER_LOCAL_TOOLS.has(args.toolName)) {
|
|
30
41
|
return {
|
|
31
42
|
allowed: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nolo-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agent-first terminal workspace for Nolo",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.ts",
|
|
12
12
|
"agentRuntimeLocal.ts",
|
|
13
|
+
"agentAliases.ts",
|
|
13
14
|
"localRuntimeDb.ts",
|
|
14
15
|
"agentRuntimeCommands.ts",
|
|
15
16
|
"agentPullCommand.ts",
|