@nanhara/hara 0.125.3 → 0.126.1

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 (43) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README.md +10 -4
  3. package/SECURITY.md +7 -3
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +270 -98
  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 +372 -159
  10. package/dist/mcp/client.js +2 -0
  11. package/dist/plugins/manifest.js +317 -0
  12. package/dist/plugins/plugins.js +476 -139
  13. package/dist/providers/bounded-turn.js +6 -0
  14. package/dist/providers/factory.js +54 -0
  15. package/dist/providers/openai.js +6 -1
  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 +139 -35
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/tools/agent.js +1 -0
  26. package/dist/tools/all.js +1 -0
  27. package/dist/tools/ask_user.js +8 -3
  28. package/dist/tools/builtin.js +19 -1
  29. package/dist/tools/codebase.js +1 -0
  30. package/dist/tools/computer.js +1 -0
  31. package/dist/tools/cron.js +10 -0
  32. package/dist/tools/external_agent.js +1 -0
  33. package/dist/tools/memory.js +2 -0
  34. package/dist/tools/registry.js +95 -4
  35. package/dist/tools/result-limit.js +172 -2
  36. package/dist/tools/runtime.js +67 -0
  37. package/dist/tools/search.js +3 -0
  38. package/dist/tools/skill.js +1 -0
  39. package/dist/tools/task.js +5 -0
  40. package/dist/tools/todo.js +3 -2
  41. package/dist/tools/web.js +4 -0
  42. package/dist/tui/App.js +5 -1
  43. package/package.json +1 -1
package/dist/config.js CHANGED
@@ -33,8 +33,49 @@ const PROVIDER_DEFAULTS = {
33
33
  baseURL: "https://openrouter.ai/api/v1",
34
34
  envKey: "OPENROUTER_API_KEY",
35
35
  },
36
+ ollama: {
37
+ model: "qwen3",
38
+ baseURL: "http://127.0.0.1:11434/v1",
39
+ envKey: "",
40
+ },
41
+ lmstudio: {
42
+ model: "local-model",
43
+ baseURL: "http://127.0.0.1:1234/v1",
44
+ envKey: "",
45
+ },
36
46
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
37
47
  };
48
+ const PROVIDER_LABELS = {
49
+ anthropic: { label: "Anthropic", location: "cloud", auth: "api-key", customBaseURL: true },
50
+ openai: { label: "OpenAI / compatible", location: "cloud", auth: "api-key", customBaseURL: true },
51
+ qwen: { label: "Qwen (DashScope)", location: "cloud", auth: "api-key", customBaseURL: true },
52
+ "qwen-oauth": { label: "Qwen Coding (browser sign-in)", location: "cloud", auth: "oauth", customBaseURL: false },
53
+ glm: { label: "GLM (Zhipu)", location: "cloud", auth: "api-key", customBaseURL: true },
54
+ deepseek: { label: "DeepSeek", location: "cloud", auth: "api-key", customBaseURL: true },
55
+ openrouter: { label: "OpenRouter", location: "cloud", auth: "api-key", customBaseURL: true },
56
+ ollama: { label: "Ollama", location: "local", auth: "none", customBaseURL: true },
57
+ lmstudio: { label: "LM Studio", location: "local", auth: "none", customBaseURL: true },
58
+ "hara-gateway": { label: "Hara Enterprise Gateway", location: "managed", auth: "managed", customBaseURL: false },
59
+ };
60
+ export const PROVIDER_IDS = Object.freeze(Object.keys(PROVIDER_DEFAULTS));
61
+ export function isProviderId(value) {
62
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(PROVIDER_DEFAULTS, value);
63
+ }
64
+ export function providerRequiresApiKey(provider) {
65
+ return PROVIDER_LABELS[provider].auth === "api-key";
66
+ }
67
+ export function providerIsLocal(provider) {
68
+ return PROVIDER_LABELS[provider].location === "local";
69
+ }
70
+ /** Redacted, deterministic catalog shared by CLI setup, serve and Desktop. */
71
+ export function providerCatalog() {
72
+ return PROVIDER_IDS.map((id) => ({
73
+ id,
74
+ ...PROVIDER_LABELS[id],
75
+ defaultModel: PROVIDER_DEFAULTS[id].model,
76
+ ...(PROVIDER_DEFAULTS[id].baseURL ? { defaultBaseURL: PROVIDER_DEFAULTS[id].baseURL } : {}),
77
+ }));
78
+ }
38
79
  export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
39
80
  export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
40
81
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
@@ -251,6 +292,127 @@ export function writeConfigValue(key, value) {
251
292
  config[key] = value;
252
293
  });
253
294
  }
295
+ function cleanProviderModel(value) {
296
+ const model = value.trim();
297
+ if (!model || model.length > 256 || /[\u0000-\u001f\u007f]/.test(model)) {
298
+ throw new Error("model must be 1–256 printable characters");
299
+ }
300
+ return model;
301
+ }
302
+ function loopbackHost(hostname) {
303
+ const host = hostname.toLowerCase();
304
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
305
+ }
306
+ function cleanProviderBaseURL(provider, value) {
307
+ const raw = value?.trim() || PROVIDER_DEFAULTS[provider].baseURL;
308
+ if (!raw)
309
+ return undefined;
310
+ if (raw.length > 2_048 || /[\u0000-\u001f\u007f]/.test(raw))
311
+ throw new Error("base URL is invalid");
312
+ let url;
313
+ try {
314
+ url = new URL(raw);
315
+ }
316
+ catch {
317
+ throw new Error("base URL must be an absolute http(s) URL");
318
+ }
319
+ if (!["http:", "https:"].includes(url.protocol))
320
+ throw new Error("base URL must use http or https");
321
+ if (url.username || url.password || url.search || url.hash) {
322
+ throw new Error("base URL cannot contain credentials, query parameters, or a fragment");
323
+ }
324
+ if (url.protocol === "http:" && !loopbackHost(url.hostname)) {
325
+ throw new Error("non-loopback provider endpoints must use https");
326
+ }
327
+ if (providerIsLocal(provider) && !loopbackHost(url.hostname)) {
328
+ throw new Error(`${provider} is labeled local and must use localhost/127.0.0.1/::1; use an OpenAI-compatible cloud profile for a remote host`);
329
+ }
330
+ return raw.replace(/\/+$/, "");
331
+ }
332
+ function providerEndpointIdentity(provider, value) {
333
+ const cleaned = cleanProviderBaseURL(provider, value);
334
+ if (!cleaned)
335
+ return "";
336
+ const url = new URL(cleaned);
337
+ const pathname = url.pathname.replace(/\/+$/, "");
338
+ return `${url.protocol}//${url.host.toLowerCase()}${pathname}`;
339
+ }
340
+ /** Validate a Desktop/serve candidate without persisting it. Kept beside the writer so a connection test
341
+ * and the eventual save cannot disagree about endpoint or credential safety. */
342
+ export function normalizePersonalProviderConfig(input) {
343
+ if (!isProviderId(input.provider) || input.provider === "hara-gateway") {
344
+ throw new Error("provider is not a configurable personal provider");
345
+ }
346
+ const model = cleanProviderModel(input.model);
347
+ const baseURL = cleanProviderBaseURL(input.provider, input.baseURL);
348
+ const apiKey = input.apiKey?.trim();
349
+ if (apiKey && (apiKey.length > 32 * 1024 || /[\u0000-\u001f\u007f]/.test(apiKey))) {
350
+ throw new Error("API key is invalid");
351
+ }
352
+ if (apiKey && !providerRequiresApiKey(input.provider)) {
353
+ throw new Error(`${input.provider} does not accept an API key`);
354
+ }
355
+ return {
356
+ provider: input.provider,
357
+ model,
358
+ ...(baseURL ? { baseURL } : {}),
359
+ ...(apiKey ? { apiKey } : {}),
360
+ ...(input.clearApiKey === true ? { clearApiKey: true } : {}),
361
+ };
362
+ }
363
+ /**
364
+ * Resolve a credential that may safely be reused for a Desktop connection test/save.
365
+ *
366
+ * A provider id is not a credential boundary by itself: OpenAI-compatible providers allow a custom
367
+ * endpoint. Never replay a stored or environment key when that endpoint changes, even when the provider id
368
+ * stays the same. Local/OAuth providers never receive this flat API-key slot.
369
+ */
370
+ export function reusablePersonalProviderApiKey(input, raw, env = process.env) {
371
+ if (!providerRequiresApiKey(input.provider))
372
+ return undefined;
373
+ if (input.apiKey)
374
+ return input.apiKey;
375
+ if (input.clearApiKey)
376
+ return undefined;
377
+ const previousProvider = isProviderId(raw.provider) ? raw.provider : "anthropic";
378
+ if (previousProvider !== input.provider)
379
+ return undefined;
380
+ const previousEndpoint = providerEndpointIdentity(previousProvider, typeof raw.baseURL === "string" ? raw.baseURL : undefined);
381
+ const candidateEndpoint = providerEndpointIdentity(input.provider, input.baseURL);
382
+ if (previousEndpoint !== candidateEndpoint)
383
+ return undefined;
384
+ const envKey = providerEnvKey(input.provider);
385
+ return nonBlankEnv(env.HARA_API_KEY)
386
+ ?? (envKey ? nonBlankEnv(env[envKey]) : undefined)
387
+ ?? nonBlankEnv(typeof raw.apiKey === "string" ? raw.apiKey : undefined);
388
+ }
389
+ /** Atomically update the legacy/personal provider slot without returning or logging its credential. */
390
+ export function updatePersonalProviderConfig(input) {
391
+ const normalized = normalizePersonalProviderConfig(input);
392
+ const { model, baseURL, apiKey } = normalized;
393
+ updateRawConfig((config) => {
394
+ const previousProvider = isProviderId(config.provider) ? config.provider : "anthropic";
395
+ const previousEndpoint = providerEndpointIdentity(previousProvider, typeof config.baseURL === "string" ? config.baseURL : undefined);
396
+ const nextEndpoint = providerEndpointIdentity(normalized.provider, baseURL);
397
+ const endpointChanged = previousProvider !== normalized.provider || previousEndpoint !== nextEndpoint;
398
+ config.provider = normalized.provider;
399
+ config.model = model;
400
+ if (baseURL)
401
+ config.baseURL = baseURL;
402
+ else
403
+ delete config.baseURL;
404
+ if (!providerRequiresApiKey(normalized.provider) || normalized.clearApiKey === true) {
405
+ delete config.apiKey;
406
+ }
407
+ else if (apiKey) {
408
+ config.apiKey = apiKey;
409
+ }
410
+ else if (endpointChanged) {
411
+ // A flat legacy key belongs to one exact endpoint, not merely a provider label.
412
+ delete config.apiKey;
413
+ }
414
+ });
415
+ }
254
416
  /** Record (or clear, with cap=null) a confirmed per-model vision capability in `modelVision`. */
255
417
  export function setModelVisionOverride(model, cap) {
256
418
  updateRawConfig((config) => {
@@ -289,11 +451,13 @@ export function loadConfig(opts = {}) {
289
451
  ...withoutBlankRoutingValues(overlay),
290
452
  ...withoutBlankRoutingValues(project),
291
453
  };
292
- const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
293
- const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
454
+ const requestedProvider = nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic";
455
+ const provider = isProviderId(requestedProvider) ? requestedProvider : "anthropic";
456
+ const d = PROVIDER_DEFAULTS[provider];
294
457
  const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
295
458
  const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
296
- const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
459
+ const providerEnvApiKey = d.envKey ? nonBlankEnv(process.env[d.envKey]) : undefined;
460
+ const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? providerEnvApiKey ?? merged.apiKey;
297
461
  const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
298
462
  const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
299
463
  const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
@@ -329,7 +493,8 @@ export function loadConfig(opts = {}) {
329
493
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
330
494
  const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
331
495
  const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
332
- const fallbackProvider = (nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider);
496
+ const requestedFallbackProvider = nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider;
497
+ const fallbackProvider = isProviderId(requestedFallbackProvider) ? requestedFallbackProvider : undefined;
333
498
  const fallbackBaseURL = nonBlankEnv(process.env.HARA_FALLBACK_BASE_URL) ?? merged.fallbackBaseURL;
334
499
  const fallbackApiKey = nonBlankEnv(process.env.HARA_FALLBACK_API_KEY) ?? merged.fallbackApiKey;
335
500
  const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
@@ -346,3 +511,6 @@ export function providerEnvKey(provider) {
346
511
  export function providerDefaultBaseURL(provider) {
347
512
  return PROVIDER_DEFAULTS[provider]?.baseURL;
348
513
  }
514
+ export function providerDefaultModel(provider) {
515
+ return PROVIDER_DEFAULTS[provider]?.model ?? PROVIDER_DEFAULTS.anthropic.model;
516
+ }
@@ -15,12 +15,9 @@ import { randomUUID } from "node:crypto";
15
15
  import { deliverResult } from "../cron/deliver.js";
16
16
  import { selfArgv } from "../cron/runner.js";
17
17
  import { resolveAgent } from "../org/projects.js";
18
- import { loadConfig, providerDefaultBaseURL } from "../config.js";
19
- import { loadActiveProfile, effectiveModel } from "../profile/profile.js";
20
- import { createAnthropicProvider } from "../providers/anthropic.js";
21
- import { createOpenAIProvider } from "../providers/openai.js";
22
- import { getValidQwenAuth } from "../providers/qwen-oauth.js";
23
- import { resolvePlatform } from "../providers/registry.js";
18
+ import { loadConfig } from "../config.js";
19
+ import { createProviderForTarget } from "../providers/factory.js";
20
+ import { profileForConfig, resolveByokProviderTarget, resolveGatewayModel, } from "../providers/target.js";
24
21
  import { validateAgainstSchema } from "../agent/structured.js";
25
22
  import { terminateSubprocessTree } from "../security/subprocess-env.js";
26
23
  import { sleepSync } from "../sync-sleep.js";
@@ -550,37 +547,18 @@ function spawnOrgTask(role, home, task, notify, context, origin, pendingId, opti
550
547
  * project context, or approval mode exists for untrusted flow/judge input to reach. */
551
548
  async function buildNoToolProvider() {
552
549
  const cfg = loadConfig();
553
- const active = loadActiveProfile();
554
- if (active.kind === "gateway") {
555
- if (!active.gatewayUrl || !active.deviceToken)
550
+ const { profile } = profileForConfig(cfg);
551
+ if (profile.kind === "gateway") {
552
+ if (!profile.gatewayUrl || !profile.deviceToken)
556
553
  return null;
557
- const baseURL = active.baseURL || `${active.gatewayUrl.replace(/\/$/, "")}/v1`;
558
- return createOpenAIProvider({
559
- apiKey: active.deviceToken,
560
- baseURL,
561
- model: cfg.model || effectiveModel(active),
562
- label: "hara-gateway",
563
- reasoningEffort: cfg.reasoningEffort,
564
- });
565
- }
566
- const provider = (cfg.provider && cfg.provider !== "hara-gateway" ? cfg.provider : active.provider) || "anthropic";
567
- const model = cfg.model || effectiveModel(active);
568
- if (provider === "qwen-oauth") {
569
- const auth = await getValidQwenAuth();
570
- if (!auth)
571
- return null;
572
- return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model, label: provider, reasoningEffort: cfg.reasoningEffort });
573
- }
574
- const apiKey = cfg.apiKey ?? active.apiKey;
575
- if (!apiKey)
576
- return null;
577
- const baseURL = cfg.baseURL ?? active.baseURL ?? providerDefaultBaseURL(provider);
578
- const wire = resolvePlatform(provider, baseURL).wireApi;
579
- if (wire === "responses")
580
- return null; // unsupported transport: fail closed, never fall back to the CLI agent
581
- if (wire === "anthropic")
582
- return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
583
- return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
554
+ return createProviderForTarget({
555
+ provider: "hara-gateway",
556
+ apiKey: profile.deviceToken,
557
+ baseURL: profile.baseURL || `${profile.gatewayUrl.replace(/\/$/, "")}/v1`,
558
+ model: resolveGatewayModel(cfg, profile),
559
+ }, cfg.reasoningEffort);
560
+ }
561
+ return createProviderForTarget(resolveByokProviderTarget(cfg, profile, false), cfg.reasoningEffort);
584
562
  }
585
563
  function extractJson(raw) {
586
564
  const trimmed = raw.trim();
@@ -6,10 +6,14 @@
6
6
  // reconnecting when `disconnected_event` says another connection has replaced this one.
7
7
  import { basename, extname } from "node:path";
8
8
  import { createDecipheriv, createHash, randomUUID } from "node:crypto";
9
+ import WebSocket from "ws";
9
10
  import { chunkText, outboundTransferTimeoutMs, PerChatOutboundLane, withOutboundDeadline, } from "./telegram.js";
10
11
  import { InboundMediaBudget, cleanupTransientMedia, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
11
12
  const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
12
- const WSImpl = globalThis.WebSocket;
13
+ // WeCom's production endpoint rejects Node 22's native/undici WebSocket handshake even though a
14
+ // standards-compliant `ws` handshake succeeds. Keep this adapter on the already-bundled `ws` client;
15
+ // the local protocol tests deliberately model that production compatibility boundary.
16
+ const WSImpl = WebSocket;
13
17
  const WS_CONNECTING = 0;
14
18
  const WS_OPEN = 1;
15
19
  const CMD_SUBSCRIBE = "aibot_subscribe";
@@ -555,6 +559,10 @@ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownlo
555
559
  }
556
560
  };
557
561
  const handleMessage = async (event) => {
562
+ // An auth ACK can already be queued when shutdown wins the race. Never let that late frame
563
+ // re-authenticate the settled connection or install a heartbeat interval after cleanup.
564
+ if (settled || signal.aborted)
565
+ return;
558
566
  const data = event?.data;
559
567
  if (typeof data === "string" && Buffer.byteLength(data, "utf8") > MAX_FRAME_BYTES) {
560
568
  finish("closed", "received an oversized WeCom frame");