@nanhara/hara 0.125.1 → 0.126.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 +76 -0
- package/README.md +22 -8
- package/SECURITY.md +6 -4
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +413 -77
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +439 -227
- package/dist/mcp/client.js +73 -6
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/models.js +39 -6
- package/dist/providers/openai.js +6 -1
- package/dist/providers/reasoning.js +12 -0
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +157 -40
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +22 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +49 -17
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
package/dist/mcp/client.js
CHANGED
|
@@ -4,11 +4,12 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
4
4
|
import { registerTool } from "../tools/registry.js";
|
|
5
5
|
import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
6
6
|
import { sensitiveStructuredInputReason } from "../security/sensitive-files.js";
|
|
7
|
-
const clients =
|
|
7
|
+
const clients = new Map();
|
|
8
8
|
const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
|
|
9
9
|
const MIN_STARTUP_TIMEOUT_MS = 50;
|
|
10
10
|
const MAX_STARTUP_TIMEOUT_MS = 60_000;
|
|
11
11
|
const safeDiagnosticName = (name) => name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128) || "server";
|
|
12
|
+
const NPM_UNKNOWN_USER_CONFIG_NOISE = /^npm\s+warn\s+Unknown user config "(?:always-auth|home)"(?:\s|\(|\.|$)/i;
|
|
12
13
|
function startupTimeout(value) {
|
|
13
14
|
if (value === undefined || !Number.isFinite(value))
|
|
14
15
|
return DEFAULT_STARTUP_TIMEOUT_MS;
|
|
@@ -60,6 +61,12 @@ function mcpStderrRedactor(name, explicitEnv, log) {
|
|
|
60
61
|
.replace(/\r?\n$/, "");
|
|
61
62
|
if (!clean)
|
|
62
63
|
return;
|
|
64
|
+
// npm 11 prints these host-user `.npmrc` deprecation notices before an npx-backed MCP server writes
|
|
65
|
+
// anything. They are neither server diagnostics nor actionable inside Hara, and repeating them in the
|
|
66
|
+
// TUI makes a healthy MCP startup look broken. Keep the filter deliberately exact: all other npm/MCP
|
|
67
|
+
// warnings and every error still cross the bounded redactor. Never read or rewrite the user's `.npmrc`.
|
|
68
|
+
if (NPM_UNKNOWN_USER_CONFIG_NOISE.test(clean))
|
|
69
|
+
return;
|
|
63
70
|
const message = `mcp: ${safeName} stderr: ${clean}`;
|
|
64
71
|
if (emitted + message.length > totalLimit) {
|
|
65
72
|
log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
|
|
@@ -124,15 +131,20 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
124
131
|
return 0;
|
|
125
132
|
}
|
|
126
133
|
const timeoutMs = startupTimeout(options.timeoutMs);
|
|
127
|
-
const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs };
|
|
134
|
+
const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal: options.signal };
|
|
128
135
|
let count = 0;
|
|
129
136
|
for (const [name, cfg] of Object.entries(servers)) {
|
|
130
137
|
const diagnosticName = safeDiagnosticName(name);
|
|
138
|
+
if (clients.has(name)) {
|
|
139
|
+
log(`mcp: ${diagnosticName} already connected`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
131
142
|
let flushStderr;
|
|
132
143
|
let transport;
|
|
133
144
|
let client;
|
|
134
145
|
let stage = "connect";
|
|
135
146
|
try {
|
|
147
|
+
options.signal?.throwIfAborted();
|
|
136
148
|
transport = new StdioClientTransport({
|
|
137
149
|
command: cfg.command,
|
|
138
150
|
args: cfg.args ?? [],
|
|
@@ -152,8 +164,10 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
152
164
|
client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
|
|
153
165
|
await client.connect(transport, requestOptions);
|
|
154
166
|
stage = "list tools";
|
|
167
|
+
options.signal?.throwIfAborted();
|
|
155
168
|
const activeClient = client;
|
|
156
169
|
const { tools } = await activeClient.listTools(undefined, requestOptions);
|
|
170
|
+
options.signal?.throwIfAborted();
|
|
157
171
|
for (const t of tools) {
|
|
158
172
|
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
159
173
|
registerTool({
|
|
@@ -161,6 +175,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
161
175
|
description: t.description ?? `${name}/${t.name}`,
|
|
162
176
|
input_schema: schema,
|
|
163
177
|
kind: "exec",
|
|
178
|
+
visibility: "deferred",
|
|
164
179
|
trustBoundary: "external",
|
|
165
180
|
async run(input, ctx) {
|
|
166
181
|
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
@@ -169,7 +184,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
169
184
|
const protectedReason = sensitiveStructuredInputReason(input, ctx.cwd);
|
|
170
185
|
if (protectedReason)
|
|
171
186
|
return `Blocked: MCP input names protected ${protectedReason}.`;
|
|
172
|
-
const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} });
|
|
187
|
+
const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} }, undefined, { signal: ctx.signal });
|
|
173
188
|
const blocks = Array.isArray(res?.content) ? res.content : [];
|
|
174
189
|
const text = blocks.map((b) => (b?.type === "text" ? b.text : JSON.stringify(b))).join("\n");
|
|
175
190
|
return redactToolSubprocessOutput(text || "(no output)", process.env, cfg.env ?? {});
|
|
@@ -177,7 +192,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
177
192
|
});
|
|
178
193
|
count++;
|
|
179
194
|
}
|
|
180
|
-
clients.
|
|
195
|
+
clients.set(name, activeClient);
|
|
181
196
|
log(`mcp: ${diagnosticName} → ${tools.length} tool(s)`);
|
|
182
197
|
}
|
|
183
198
|
catch (e) {
|
|
@@ -193,8 +208,60 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
193
208
|
}
|
|
194
209
|
return count;
|
|
195
210
|
}
|
|
211
|
+
/** Register a zero-side-effect launcher for configured MCP servers. The model sees only this bounded index
|
|
212
|
+
* until a relevant task calls it; connecting one server dynamically registers its real tools for the next
|
|
213
|
+
* provider round (runAgent rebuilds tool specs every round). */
|
|
214
|
+
export function registerLazyMcpServers(servers, log) {
|
|
215
|
+
const names = Object.keys(servers);
|
|
216
|
+
if (!names.length)
|
|
217
|
+
return;
|
|
218
|
+
const display = names
|
|
219
|
+
.slice(0, 24)
|
|
220
|
+
.map((name) => safeDiagnosticName(name))
|
|
221
|
+
.join(", ");
|
|
222
|
+
const more = names.length > 24 ? `, … (${names.length - 24} more)` : "";
|
|
223
|
+
registerTool({
|
|
224
|
+
name: "mcp_connect",
|
|
225
|
+
description: "Connect exactly one configured MCP server when the current task first needs it. " +
|
|
226
|
+
"Do not call speculatively: launching it executes reviewed external code outside Hara's protected-file boundary. " +
|
|
227
|
+
`Configured servers: ${display}${more}. After success, its mcp__<server>__* tools appear on the next turn.`,
|
|
228
|
+
input_schema: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
server: {
|
|
232
|
+
type: "string",
|
|
233
|
+
enum: names,
|
|
234
|
+
description: "Exact configured server name to connect.",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
required: ["server"],
|
|
238
|
+
},
|
|
239
|
+
kind: "exec",
|
|
240
|
+
trustBoundary: "external",
|
|
241
|
+
async run(input, ctx) {
|
|
242
|
+
const name = typeof input?.server === "string" ? input.server : "";
|
|
243
|
+
const cfg = servers[name];
|
|
244
|
+
if (!cfg) {
|
|
245
|
+
return `Error: unknown MCP server '${safeDiagnosticName(name)}'. Available: ${display}${more}.`;
|
|
246
|
+
}
|
|
247
|
+
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
248
|
+
return ("Blocked: MCP is a trusted extension outside Hara's file boundary and is disabled in " +
|
|
249
|
+
"non-interactive runs. Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only after reviewing the server.");
|
|
250
|
+
}
|
|
251
|
+
if (clients.has(name))
|
|
252
|
+
return `MCP server '${safeDiagnosticName(name)}' is already connected.`;
|
|
253
|
+
const runtimeLog = ctx.ui ? (message) => ctx.ui.notice(message) : log;
|
|
254
|
+
const count = await connectMcpServers({ [name]: cfg }, runtimeLog, { approved: true, signal: ctx.signal });
|
|
255
|
+
if (!clients.has(name)) {
|
|
256
|
+
return `Error: MCP server '${safeDiagnosticName(name)}' failed to connect. Review its redacted startup diagnostics.`;
|
|
257
|
+
}
|
|
258
|
+
return (`Connected MCP server '${safeDiagnosticName(name)}'; ${count} tool(s) are now available under ` +
|
|
259
|
+
`mcp__${safeDiagnosticName(name)}__*. Continue with the specific tool needed for the task.`);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
196
263
|
export async function closeMcp() {
|
|
197
|
-
for (const cl of clients) {
|
|
264
|
+
for (const cl of clients.values()) {
|
|
198
265
|
try {
|
|
199
266
|
await cl.close();
|
|
200
267
|
}
|
|
@@ -202,5 +269,5 @@ export async function closeMcp() {
|
|
|
202
269
|
/* ignore */
|
|
203
270
|
}
|
|
204
271
|
}
|
|
205
|
-
clients.
|
|
272
|
+
clients.clear();
|
|
206
273
|
}
|
|
@@ -33,6 +33,12 @@ export async function boundedProviderTurn(provider, args, options) {
|
|
|
33
33
|
// microtask starts. Re-check here so an already-cancelled auxiliary call never incurs a request/cost.
|
|
34
34
|
.then(() => controller.signal.aborted ? errorResult(`${label} cancelled`) : provider.turn({ ...args, signal: controller.signal }))
|
|
35
35
|
.catch((error) => errorResult(error instanceof Error ? error.message : String(error)));
|
|
36
|
+
try {
|
|
37
|
+
options.onProviderTurn?.(turn);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Observability must never weaken the provider boundary.
|
|
41
|
+
}
|
|
36
42
|
const result = await Promise.race([turn, stopped]);
|
|
37
43
|
clearTimeout(timer);
|
|
38
44
|
parent?.removeEventListener("abort", onParentAbort);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Shared provider construction for the interactive CLI, Desktop serve, gateway approval/judge calls, and
|
|
2
|
+
// connection tests. Every path must interpret auth:none/OAuth/wire-protocol targets identically.
|
|
3
|
+
import { providerIsLocal } from "../config.js";
|
|
4
|
+
import { getValidQwenAuth } from "./qwen-oauth.js";
|
|
5
|
+
import { createAnthropicProvider } from "./anthropic.js";
|
|
6
|
+
import { createOpenAIProvider } from "./openai.js";
|
|
7
|
+
import { resolvePlatform } from "./registry.js";
|
|
8
|
+
export async function createProviderForTarget(target, reasoningEffort) {
|
|
9
|
+
const { provider, apiKey, model, baseURL } = target;
|
|
10
|
+
if (provider === "qwen-oauth") {
|
|
11
|
+
const auth = await getValidQwenAuth();
|
|
12
|
+
if (!auth)
|
|
13
|
+
return null;
|
|
14
|
+
return createOpenAIProvider({
|
|
15
|
+
apiKey: auth.accessToken,
|
|
16
|
+
baseURL: auth.baseURL,
|
|
17
|
+
model,
|
|
18
|
+
label: provider,
|
|
19
|
+
reasoningEffort,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// The OpenAI SDK requires a non-empty constructor value even when a compatible local endpoint has no
|
|
23
|
+
// authentication. This sentinel never leaves the process for cloud targets and local targets have already
|
|
24
|
+
// discarded all user credentials at target resolution.
|
|
25
|
+
const transportKey = apiKey ?? (providerIsLocal(provider) ? "hara-local-no-secret" : undefined);
|
|
26
|
+
if (!transportKey)
|
|
27
|
+
return null;
|
|
28
|
+
const wire = resolvePlatform(provider, baseURL).wireApi;
|
|
29
|
+
if (wire === "anthropic") {
|
|
30
|
+
return createAnthropicProvider({ apiKey: transportKey, model, baseURL, reasoningEffort });
|
|
31
|
+
}
|
|
32
|
+
if (wire === "responses") {
|
|
33
|
+
return {
|
|
34
|
+
id: provider,
|
|
35
|
+
model,
|
|
36
|
+
async turn() {
|
|
37
|
+
return {
|
|
38
|
+
text: "",
|
|
39
|
+
toolUses: [],
|
|
40
|
+
stop: "error",
|
|
41
|
+
errorMsg: "This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at an OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or an Anthropic-compatible endpoint (…/apps/anthropic).",
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return createOpenAIProvider({
|
|
47
|
+
apiKey: transportKey,
|
|
48
|
+
model,
|
|
49
|
+
baseURL,
|
|
50
|
+
label: provider,
|
|
51
|
+
reasoningEffort,
|
|
52
|
+
omitAuthorization: providerIsLocal(provider),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/dist/providers/models.js
CHANGED
|
@@ -1,21 +1,54 @@
|
|
|
1
|
+
// Alibaba Coding Plan's documented exact ids (verified 2026-07-18). Live `/models` remains authoritative;
|
|
2
|
+
// this list is only a usability fallback because the coding endpoint/key combinations do not all enumerate.
|
|
3
|
+
// Keep exact casing: Coding Plan explicitly forbids guessing compatible/version-like aliases.
|
|
4
|
+
export const CODING_PLAN_FALLBACK_MODELS = Object.freeze([
|
|
5
|
+
"qwen3.7-plus",
|
|
6
|
+
"qwen3.6-plus",
|
|
7
|
+
"kimi-k2.5",
|
|
8
|
+
"glm-5",
|
|
9
|
+
"MiniMax-M2.5",
|
|
10
|
+
"qwen3.5-plus",
|
|
11
|
+
"qwen3-max-2026-01-23",
|
|
12
|
+
"qwen3-coder-next",
|
|
13
|
+
"qwen3-coder-plus",
|
|
14
|
+
"glm-4.7",
|
|
15
|
+
]);
|
|
16
|
+
export function codingPlanFallbackModels(baseURL) {
|
|
17
|
+
if (!baseURL)
|
|
18
|
+
return [];
|
|
19
|
+
try {
|
|
20
|
+
const host = new URL(baseURL).hostname.toLowerCase();
|
|
21
|
+
return host === "coding.dashscope.aliyuncs.com" || host === "coding-intl.dashscope.aliyuncs.com"
|
|
22
|
+
? [...CODING_PLAN_FALLBACK_MODELS]
|
|
23
|
+
: [];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
1
29
|
// Model discovery — "what can this key run?" A coding-plan / OpenAI-compatible key usually exposes many
|
|
2
30
|
// models (Qwen, GLM, Kimi, …) via `GET {baseURL}/models`; the /model picker lists them so you switch by
|
|
3
|
-
// arrow keys, not by memorizing ids.
|
|
4
|
-
//
|
|
31
|
+
// arrow keys, not by memorizing ids. Live results win. A bounded request falls back to Alibaba's documented
|
|
32
|
+
// exact Coding Plan ids only on the two official coding hosts; other endpoints keep the existing [] →
|
|
33
|
+
// type-an-id behavior. `fetchImpl` is injected so this stays pure/testable.
|
|
5
34
|
export async function listModels(baseURL, apiKey, fetchImpl = fetch) {
|
|
6
35
|
if (!baseURL)
|
|
7
36
|
return []; // SDK-default hosts (anthropic/openai) — no custom endpoint to enumerate
|
|
37
|
+
const fallback = codingPlanFallbackModels(baseURL);
|
|
8
38
|
try {
|
|
9
39
|
const url = baseURL.replace(/\/+$/, "") + "/models";
|
|
10
|
-
const
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (apiKey)
|
|
42
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
43
|
+
const r = await fetchImpl(url, { headers, signal: AbortSignal.timeout(5_000) });
|
|
11
44
|
if (!r.ok)
|
|
12
|
-
return
|
|
45
|
+
return fallback;
|
|
13
46
|
const j = (await r.json());
|
|
14
47
|
const ids = (j?.data ?? []).map((m) => m?.id).filter((x) => typeof x === "string" && x.length > 0);
|
|
15
48
|
// Stable order + de-dup so the picker list doesn't jump around between opens.
|
|
16
|
-
return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
|
|
49
|
+
return ids.length ? [...new Set(ids)].sort((a, b) => a.localeCompare(b)) : fallback;
|
|
17
50
|
}
|
|
18
51
|
catch {
|
|
19
|
-
return
|
|
52
|
+
return fallback;
|
|
20
53
|
}
|
|
21
54
|
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -80,7 +80,12 @@ export function toOpenAI(system, history) {
|
|
|
80
80
|
}
|
|
81
81
|
/** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
|
|
82
82
|
export function createOpenAIProvider(opts) {
|
|
83
|
-
const client = new OpenAI({
|
|
83
|
+
const client = new OpenAI({
|
|
84
|
+
apiKey: opts.apiKey,
|
|
85
|
+
maxRetries: 4,
|
|
86
|
+
...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
|
|
87
|
+
...(opts.omitAuthorization ? { defaultHeaders: { Authorization: null } } : {}),
|
|
88
|
+
});
|
|
84
89
|
return {
|
|
85
90
|
id: opts.label ?? "openai",
|
|
86
91
|
model: opts.model,
|
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
export function isReasoningModel(model) {
|
|
9
9
|
return /^(o1|o3|o4|gpt-5)/i.test(model);
|
|
10
10
|
}
|
|
11
|
+
/** Endpoint capability is not enough: Alibaba's Coding Plan serves qwen3-coder-next/plus on the same
|
|
12
|
+
* DashScope URL as thinking models, but documents both coder ids as not supporting thinking mode. */
|
|
13
|
+
export function supportsReasoningStyle(style, model = "") {
|
|
14
|
+
if (style === "none")
|
|
15
|
+
return false;
|
|
16
|
+
if (style === "enable_thinking" && /^qwen3-coder-(?:next|plus)(?:-|$)/i.test(model.split("/").at(-1) ?? model)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
11
21
|
/** Translate the dial into request params to MERGE into the wire body (chat/responses styles). Returns an
|
|
12
22
|
* empty object — leave the request untouched — when the dial is UNSET (keep the model's own default; zero
|
|
13
23
|
* impact, the safe default) or the style/model has nothing to add. Anthropic's `thinking_budget` is built
|
|
@@ -17,6 +27,8 @@ export function reasoningParams(style, effort, model = "") {
|
|
|
17
27
|
return {};
|
|
18
28
|
switch (style) {
|
|
19
29
|
case "enable_thinking":
|
|
30
|
+
if (!supportsReasoningStyle(style, model))
|
|
31
|
+
return {};
|
|
20
32
|
// off → false (stop the thinking phase, fast); any explicit level → true (keep it on).
|
|
21
33
|
return { enable_thinking: effort !== "off" };
|
|
22
34
|
case "ollama_think":
|
|
@@ -32,6 +32,7 @@ const BY_PROVIDER = {
|
|
|
32
32
|
glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
|
|
33
33
|
deepseek: { wireApi: "chat", reasoning: "deepseek", cache: "auto" }, // V4: thinking:{type} + reasoning_effort(high|max)
|
|
34
34
|
ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
|
|
35
|
+
lmstudio: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local OpenAI-compatible server
|
|
35
36
|
openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
|
|
36
37
|
openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
|
|
37
38
|
"hara-gateway": { wireApi: "chat", reasoning: "none", cache: "auto" },
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { providerDefaultBaseURL, providerDefaultModel, providerEnvKey, isProviderId, providerIsLocal, } from "../config.js";
|
|
2
|
+
import { getProfile, PERSONAL_ID, resolveActive, } from "../profile/profile.js";
|
|
3
|
+
/** Resolve the identity profile for the same cwd used to load config. */
|
|
4
|
+
export function profileForConfig(cfg) {
|
|
5
|
+
const resolution = resolveActive(cfg.cwd);
|
|
6
|
+
if (resolution.id !== PERSONAL_ID) {
|
|
7
|
+
const profile = getProfile(resolution.id);
|
|
8
|
+
if (profile)
|
|
9
|
+
return { profile, resolution };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
resolution: resolution.id === PERSONAL_ID
|
|
13
|
+
? resolution
|
|
14
|
+
: { id: PERSONAL_ID, source: "fallback" },
|
|
15
|
+
profile: {
|
|
16
|
+
id: PERSONAL_ID,
|
|
17
|
+
kind: "byok",
|
|
18
|
+
label: "Personal",
|
|
19
|
+
provider: cfg.provider,
|
|
20
|
+
apiKey: cfg.apiKey,
|
|
21
|
+
baseURL: cfg.baseURL,
|
|
22
|
+
defaultModel: cfg.model,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve one BYOK/local transport target.
|
|
28
|
+
*
|
|
29
|
+
* A named Profile is an identity boundary: its vendor, credential and endpoint must not be replaced by
|
|
30
|
+
* the always-populated personal/global config. Explicit HARA_* values remain one-shot overrides. The
|
|
31
|
+
* personal profile and explicit sidecars continue to use the merged config.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveByokProviderTarget(cfg, profile, sidecarOverride, env = process.env) {
|
|
34
|
+
const personalOrOverride = profile.id === "personal" || sidecarOverride;
|
|
35
|
+
const profileProvider = profile.provider && profile.provider !== "hara-gateway" ? profile.provider : "anthropic";
|
|
36
|
+
const environmentProvider = isProviderId(env.HARA_PROVIDER) && env.HARA_PROVIDER !== "hara-gateway"
|
|
37
|
+
? env.HARA_PROVIDER
|
|
38
|
+
: undefined;
|
|
39
|
+
const provider = personalOrOverride
|
|
40
|
+
? (cfg.provider !== "hara-gateway" ? cfg.provider : profileProvider)
|
|
41
|
+
: environmentProvider ?? profileProvider;
|
|
42
|
+
const namedProviderChanged = !personalOrOverride && provider !== profileProvider;
|
|
43
|
+
const envKey = providerEnvKey(provider);
|
|
44
|
+
const providerEnvApiKey = envKey ? env[envKey] : undefined;
|
|
45
|
+
const candidateApiKey = personalOrOverride
|
|
46
|
+
? cfg.apiKey ?? profile.apiKey
|
|
47
|
+
: env.HARA_API_KEY ?? providerEnvApiKey ?? (namedProviderChanged ? undefined : profile.apiKey);
|
|
48
|
+
// Ollama/LM Studio declare auth:none. Never forward a stale cloud key (including HARA_API_KEY) to a
|
|
49
|
+
// loopback process that happens to occupy the configured port.
|
|
50
|
+
const apiKey = providerIsLocal(provider) ? undefined : candidateApiKey;
|
|
51
|
+
const baseURL = personalOrOverride
|
|
52
|
+
? cfg.baseURL ?? profile.baseURL ?? providerDefaultBaseURL(provider)
|
|
53
|
+
: env.HARA_BASE_URL
|
|
54
|
+
?? (namedProviderChanged ? undefined : profile.baseURL)
|
|
55
|
+
?? providerDefaultBaseURL(provider);
|
|
56
|
+
const profileModel = profile.model || profile.defaultModel || "";
|
|
57
|
+
const model = personalOrOverride
|
|
58
|
+
? cfg.model || env.HARA_MODEL || profileModel
|
|
59
|
+
: env.HARA_MODEL
|
|
60
|
+
|| (namedProviderChanged ? "" : profileModel)
|
|
61
|
+
|| providerDefaultModel(provider);
|
|
62
|
+
return { provider, apiKey, baseURL, model };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Apply an explicit runtime selection (session model, role model, vision/route sidecar, fallback).
|
|
66
|
+
*
|
|
67
|
+
* A provider switch starts a fresh credential/endpoint boundary; absent fields use that provider's public
|
|
68
|
+
* defaults and never inherit the previous profile's key or host.
|
|
69
|
+
*/
|
|
70
|
+
export function overrideProviderTarget(base, override) {
|
|
71
|
+
if (!override)
|
|
72
|
+
return base;
|
|
73
|
+
const owns = (key) => Object.prototype.hasOwnProperty.call(override, key);
|
|
74
|
+
const provider = override.provider ?? base.provider;
|
|
75
|
+
const providerChanged = provider !== base.provider;
|
|
76
|
+
const apiKey = providerIsLocal(provider)
|
|
77
|
+
? undefined
|
|
78
|
+
: owns("apiKey")
|
|
79
|
+
? override.apiKey
|
|
80
|
+
: providerChanged
|
|
81
|
+
? undefined
|
|
82
|
+
: base.apiKey;
|
|
83
|
+
const baseURL = owns("baseURL")
|
|
84
|
+
? override.baseURL
|
|
85
|
+
: providerChanged
|
|
86
|
+
? providerDefaultBaseURL(provider)
|
|
87
|
+
: base.baseURL;
|
|
88
|
+
const model = owns("model") && override.model
|
|
89
|
+
? override.model
|
|
90
|
+
: providerChanged
|
|
91
|
+
? providerDefaultModel(provider)
|
|
92
|
+
: base.model;
|
|
93
|
+
return { provider, apiKey, baseURL, model };
|
|
94
|
+
}
|
|
95
|
+
/** Gateway profiles own their default, while an explicit session/role model remains selectable. */
|
|
96
|
+
export function resolveGatewayModel(cfg, profile, env = process.env, requestedModel) {
|
|
97
|
+
return env.HARA_MODEL || requestedModel || profile.model || profile.defaultModel || cfg.model;
|
|
98
|
+
}
|
|
@@ -223,7 +223,12 @@ export async function guardianVeto(provider, action, history, opts = {}) {
|
|
|
223
223
|
history: [{ role: "user", content: prompt }],
|
|
224
224
|
tools: [],
|
|
225
225
|
onText: () => { },
|
|
226
|
-
}, {
|
|
226
|
+
}, {
|
|
227
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
228
|
+
signal: opts.signal,
|
|
229
|
+
label: "security guardian",
|
|
230
|
+
onProviderTurn: opts.onProviderTurn,
|
|
231
|
+
});
|
|
227
232
|
if (r.stop === "error")
|
|
228
233
|
return { decision: "allow", reason: "" }; // fail-open on model error
|
|
229
234
|
return parseVerdict(r.text);
|
|
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
9
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
10
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
11
|
-
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
|
|
11
|
+
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results"]);
|
|
12
12
|
const tightenedHomes = new Set();
|
|
13
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
14
14
|
function isMissing(error) {
|
package/dist/security/secrets.js
CHANGED
|
@@ -87,6 +87,25 @@ export function redactSensitiveText(text) {
|
|
|
87
87
|
}
|
|
88
88
|
return { text: out, redactions };
|
|
89
89
|
}
|
|
90
|
+
/** Remove caller-known secret values before pattern matching. Provider/server errors may echo an opaque key
|
|
91
|
+
* that has no recognizable prefix, so field-name and token-shape heuristics alone cannot uphold no-echo APIs. */
|
|
92
|
+
export function redactKnownSecrets(text, secrets) {
|
|
93
|
+
let out = text;
|
|
94
|
+
const exactHits = [];
|
|
95
|
+
for (const value of secrets) {
|
|
96
|
+
if (!value)
|
|
97
|
+
continue;
|
|
98
|
+
const variants = new Set([value, encodeURIComponent(value)]);
|
|
99
|
+
for (const variant of variants) {
|
|
100
|
+
if (!variant || !out.includes(variant))
|
|
101
|
+
continue;
|
|
102
|
+
out = out.split(variant).join("***");
|
|
103
|
+
exactHits.push("known-secret");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const patterned = redactSensitiveText(out);
|
|
107
|
+
return { text: patterned.text, redactions: [...exactHits, ...patterned.redactions] };
|
|
108
|
+
}
|
|
90
109
|
/** Structured tool/config data often carries an opaque value that has no recognizable token prefix. In that
|
|
91
110
|
* case the FIELD name is the evidence (`apiKey`, `access_token`, `FEISHU_APP_SECRET`, …). Keep this narrow
|
|
92
111
|
* enough not to erase ordinary fields such as `tokenCount` or `secretary`. */
|
package/dist/serve/protocol.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// Everything here is PURE (parse + frame builders + error codes) and unit-tested.
|
|
4
4
|
//
|
|
5
5
|
// Client → server requests:
|
|
6
|
-
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
|
|
6
|
+
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,setupState,
|
|
7
7
|
// capabilities:{methods:[…]}} (feature detection)
|
|
8
|
+
// server.shutdown {} → {accepted:true} (authenticated graceful local shutdown;
|
|
9
|
+
// BUSY while any client work/approval is active)
|
|
8
10
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
9
11
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
10
12
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
@@ -19,6 +21,9 @@
|
|
|
19
21
|
// automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
|
|
20
22
|
// sessions:[{id,title,source,sourceName,updatedAt,…}]}
|
|
21
23
|
// models.list {} → {models:[…], current, effortLevels:[…]}
|
|
24
|
+
// settings.providers.list {} → redacted provider catalog + current profile state
|
|
25
|
+
// settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
|
|
26
|
+
// settings.providers.save {provider,model,…} → redacted state (credential is never returned)
|
|
22
27
|
// automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
|
|
23
28
|
// automation.toggle {id,enabled} → {id,enabled}
|
|
24
29
|
// automation.delete {id} → {id,deleted}
|
|
@@ -37,7 +42,7 @@ export const ERR = {
|
|
|
37
42
|
PARAMS: -32602,
|
|
38
43
|
INTERNAL: -32603,
|
|
39
44
|
UNAUTHORIZED: -32001, // initialize first (or bad token)
|
|
40
|
-
BUSY: -32002, //
|
|
45
|
+
BUSY: -32002, // requested operation conflicts with active server/session work
|
|
41
46
|
NO_SESSION: -32003, // unknown/expired sessionId
|
|
42
47
|
LOCKED: -32004, // session held by another live hara process (single-writer lock)
|
|
43
48
|
};
|