@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +22 -8
  3. package/SECURITY.md +6 -4
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +413 -77
  6. package/dist/config.js +172 -4
  7. package/dist/gateway/flows-pending.js +14 -36
  8. package/dist/gateway/wecom.js +9 -1
  9. package/dist/index.js +439 -227
  10. package/dist/mcp/client.js +73 -6
  11. package/dist/providers/bounded-turn.js +6 -0
  12. package/dist/providers/factory.js +54 -0
  13. package/dist/providers/models.js +39 -6
  14. package/dist/providers/openai.js +6 -1
  15. package/dist/providers/reasoning.js +12 -0
  16. package/dist/providers/registry.js +1 -0
  17. package/dist/providers/target.js +98 -0
  18. package/dist/security/guardian.js +6 -1
  19. package/dist/security/private-state.js +1 -1
  20. package/dist/security/secrets.js +19 -0
  21. package/dist/serve/protocol.js +7 -2
  22. package/dist/serve/server.js +157 -40
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/session/task.js +78 -0
  26. package/dist/statusbar.js +15 -2
  27. package/dist/tools/agent.js +1 -0
  28. package/dist/tools/all.js +1 -0
  29. package/dist/tools/ask_user.js +8 -3
  30. package/dist/tools/builtin.js +22 -1
  31. package/dist/tools/codebase.js +1 -0
  32. package/dist/tools/computer.js +1 -0
  33. package/dist/tools/cron.js +10 -0
  34. package/dist/tools/external_agent.js +1 -0
  35. package/dist/tools/memory.js +2 -0
  36. package/dist/tools/registry.js +95 -4
  37. package/dist/tools/result-limit.js +172 -2
  38. package/dist/tools/runtime.js +67 -0
  39. package/dist/tools/search.js +3 -0
  40. package/dist/tools/skill.js +1 -0
  41. package/dist/tools/task.js +5 -0
  42. package/dist/tools/todo.js +3 -2
  43. package/dist/tools/web.js +4 -0
  44. package/dist/tui/App.js +49 -17
  45. package/dist/tui/model-picker.js +11 -8
  46. package/package.json +1 -1
@@ -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.push(activeClient);
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.length = 0;
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
+ }
@@ -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. Best-effort: many endpoints don't implement it [] and the picker
4
- // falls back to typing an id. `fetchImpl` is injected so this stays pure/testable.
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 r = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
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
  }
@@ -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({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
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
- }, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal: opts.signal, label: "security guardian" });
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) {
@@ -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`. */
@@ -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, // session already has a turn in flight
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
  };