@nanhara/hara 0.122.3 → 0.122.5

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 (61) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +15 -2
  3. package/SECURITY.md +4 -0
  4. package/dist/agent/limits.js +51 -0
  5. package/dist/agent/loop.js +367 -112
  6. package/dist/agent/repeat-guard.js +49 -12
  7. package/dist/checkpoints.js +9 -0
  8. package/dist/cli.js +2 -1
  9. package/dist/config.js +10 -2
  10. package/dist/context/agents-md.js +21 -2
  11. package/dist/context/mentions.js +84 -1
  12. package/dist/context/workspace-scope.js +49 -0
  13. package/dist/cron/deliver.js +61 -38
  14. package/dist/cron/runner.js +423 -65
  15. package/dist/cron/schedule.js +23 -7
  16. package/dist/cron/store.js +709 -43
  17. package/dist/fs-walk.js +279 -7
  18. package/dist/fs-write.js +9 -0
  19. package/dist/gateway/feishu.js +351 -64
  20. package/dist/gateway/flows-pending.js +28 -14
  21. package/dist/gateway/flows.js +306 -31
  22. package/dist/gateway/outbound-files.js +20 -6
  23. package/dist/gateway/runtime-state.js +1485 -0
  24. package/dist/gateway/serve.js +550 -242
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/process-identity.js +52 -0
  32. package/dist/providers/bounded-turn.js +42 -0
  33. package/dist/recall.js +69 -1
  34. package/dist/runtime.js +24 -0
  35. package/dist/sandbox.js +54 -4
  36. package/dist/search/embed.js +13 -2
  37. package/dist/search/hybrid.js +6 -3
  38. package/dist/search/semindex.js +87 -5
  39. package/dist/security/guardian.js +3 -15
  40. package/dist/security/permissions.js +11 -0
  41. package/dist/serve/server.js +70 -42
  42. package/dist/serve/sessions.js +4 -3
  43. package/dist/tools/agent.js +1 -1
  44. package/dist/tools/ask_user.js +5 -1
  45. package/dist/tools/builtin.js +5 -2
  46. package/dist/tools/codebase.js +67 -18
  47. package/dist/tools/computer.js +149 -68
  48. package/dist/tools/cron.js +72 -18
  49. package/dist/tools/edit.js +3 -2
  50. package/dist/tools/external_agent.js +66 -12
  51. package/dist/tools/memory.js +25 -7
  52. package/dist/tools/patch.js +11 -2
  53. package/dist/tools/registry.js +16 -1
  54. package/dist/tools/search.js +68 -9
  55. package/dist/tools/send.js +1 -1
  56. package/dist/tools/skill.js +1 -1
  57. package/dist/tools/web.js +43 -13
  58. package/dist/tui/App.js +93 -25
  59. package/dist/vision.js +5 -6
  60. package/package.json +3 -3
  61. package/runtime-bootstrap.cjs +22 -0
@@ -18,34 +18,71 @@ function scopedSeen(scope) {
18
18
  * so a space separator is unambiguous). */
19
19
  export function keyOf(name, input) {
20
20
  try {
21
- return name + " " + JSON.stringify(input ?? {});
21
+ const seen = new WeakSet();
22
+ const canonical = (value) => {
23
+ if (!value || typeof value !== "object")
24
+ return value;
25
+ if (seen.has(value))
26
+ throw new TypeError("circular tool input");
27
+ seen.add(value);
28
+ try {
29
+ if (Array.isArray(value))
30
+ return value.map(canonical);
31
+ const out = {};
32
+ for (const key of Object.keys(value).sort()) {
33
+ const next = value[key];
34
+ if (next !== undefined && typeof next !== "function" && typeof next !== "symbol")
35
+ out[key] = canonical(next);
36
+ }
37
+ return out;
38
+ }
39
+ finally {
40
+ seen.delete(value);
41
+ }
42
+ };
43
+ return name + " " + JSON.stringify(canonical(input ?? {}));
22
44
  }
23
45
  catch {
24
46
  return name + " <unserializable>";
25
47
  }
26
48
  }
27
49
  /** Does a tool RESULT string look like a failure? hara tools report failures as ordinary strings
28
- * (bash -> "Command failed: ...", file tools -> "Error: ...", net guard -> "Skipped without running: ..."),
29
- * so the loop's isError flag alone misses them. Pure exported for tests. */
30
- export function looksFailed(content) {
31
- return /^\s*(Command failed|Error\b|Skipped without running)/.test(content);
50
+ * (bash -> "Command failed: ...", file tools -> "Error: ...", safety gates -> "Blocked: ..."),
51
+ * so the loop's isError flag alone misses them. Tool-specific shapes are intentionally keyed by `name`:
52
+ * a read_file/web_fetch result can legitimately begin with prose such as "Search failed ..." and must not
53
+ * be mistaken for the web_search tool's own diagnostic. Pure — exported for tests. */
54
+ export function looksFailed(content, name) {
55
+ const text = content.trimStart();
56
+ if (/^(Command failed|Error:|Failed:|Blocked:|Skipped without running)/.test(text))
57
+ return true;
58
+ if (name === "web_search")
59
+ return /^Search failed across available providers\b/.test(text);
60
+ if (name === "external_agent") {
61
+ return /^(?:external_agent is disabled\b|Unknown backend\b|'[^'\r\n]+' CLI not found\b|\[[^\]\r\n]+\]\s+failed\b|\[[^\]\r\n]+\s+exit\s+(?!0\b)[^\]\r\n]+\])/.test(text);
62
+ }
63
+ if (name === "cronjob" || name === "cron")
64
+ return /^✗\s+\S+\s+failed\s*:/.test(text);
65
+ if (name === "computer") {
66
+ return /^(?:Refused:|Screen control is off\.|No apps allowlisted\b|Grounding needs a vision model\b|'[^'\r\n]+' needs a higher tier\b|(?:activate|find|click\/move) needs\b|⛔ Stopping screen control\b)/.test(text)
67
+ || /^Screenshot saved\b[\s\S]*\bConfigure a vision model\b/.test(text);
68
+ }
69
+ return false;
32
70
  }
33
71
  /** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
34
72
  * failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
35
73
  export function recordCall(name, input, content, isError = false, scope) {
36
74
  const k = keyOf(name, input);
37
- const failed = isError || looksFailed(content);
75
+ const failed = isError || looksFailed(content, name);
38
76
  const seen = scopedSeen(scope);
39
- const s = seen.get(k) ?? { fails: 0 };
40
77
  if (!failed) {
41
- seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
78
+ seen.clear(); // any success is progress; a later failure starts a fresh no-progress streak
42
79
  return "";
43
80
  }
81
+ const s = seen.get(k) ?? { fails: 0 };
44
82
  s.fails++;
45
- seen.delete(k);
46
- seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
47
- if (seen.size > 500)
48
- seen.delete(seen.keys().next().value);
83
+ // "In a row" is literal: a different failed call is a changed attempt and breaks the old streak.
84
+ seen.clear();
85
+ seen.set(k, s);
49
86
  if (s.fails < 2)
50
87
  return "";
51
88
  return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
@@ -10,6 +10,7 @@ import { dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { createHash } from "node:crypto";
12
12
  import { findProjectRoot } from "./context/agents-md.js";
13
+ import { isHomeWorkspace } from "./context/workspace-scope.js";
13
14
  import { toolSubprocessEnv } from "./security/subprocess-env.js";
14
15
  import { sensitiveFileReason } from "./security/sensitive-files.js";
15
16
  import { redactSensitiveText } from "./security/secrets.js";
@@ -104,6 +105,10 @@ function dropSensitiveIndexEntries(root, gitDir) {
104
105
  }
105
106
  /** Snapshot the current working tree into the shadow repo. Returns the short sha, or null on failure. */
106
107
  export function checkpoint(cwd, label) {
108
+ // A shadow `git add -A` at ~/ would traverse the user's entire personal/control-data scope. Home is
109
+ // deliberately not an implicit project, including when reached through a symlink alias.
110
+ if (isHomeWorkspace(cwd))
111
+ return null;
107
112
  const root = findProjectRoot(cwd);
108
113
  const gitDir = shadowGitDir(root);
109
114
  if (!ensureRepo(root, gitDir))
@@ -133,6 +138,8 @@ export function checkpoint(cwd, label) {
133
138
  }
134
139
  /** Recent checkpoints, newest first. */
135
140
  export function listCheckpoints(cwd, n = 15) {
141
+ if (isHomeWorkspace(cwd))
142
+ return [];
136
143
  const root = findProjectRoot(cwd);
137
144
  const gitDir = shadowGitDir(root);
138
145
  if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
@@ -156,6 +163,8 @@ export function listCheckpoints(cwd, n = 15) {
156
163
  export function restoreCheckpoint(cwd, ref) {
157
164
  if (!/^[0-9a-f]{4,64}$/i.test(ref))
158
165
  return null; // checkpoint refs are hashes; reject option/ref injection
166
+ if (isHomeWorkspace(cwd))
167
+ return null;
159
168
  const root = findProjectRoot(cwd);
160
169
  const gitDir = shadowGitDir(root);
161
170
  if (!ensureRepo(root, gitDir) || !existsSync(join(gitDir, "HEAD")))
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Keep the runtime gate dependency-free and ahead of the main CLI import. Older Node releases must see an
3
3
  // actionable upgrade message even if a future dependency starts using syntax or APIs they cannot load.
4
- import { unsupportedNodeMessage } from "./runtime.js";
4
+ import { applyPortableHomeEnv, unsupportedNodeMessage } from "./runtime.js";
5
5
  const runtimeError = unsupportedNodeMessage();
6
6
  if (runtimeError) {
7
7
  process.stderr.write(`${runtimeError}\n`);
8
8
  process.exitCode = 1;
9
9
  }
10
10
  else {
11
+ applyPortableHomeEnv();
11
12
  void import("./index.js").catch((error) => {
12
13
  const message = error instanceof Error ? error.message : String(error);
13
14
  process.stderr.write(`hara: failed to start: ${message}\n`);
package/dist/config.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, dirname, resolve } from "node:path";
3
3
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { agentMaxRounds, agentRunTimeoutMs } from "./agent/limits.js";
4
5
  import { ensurePrivateHaraState } from "./security/private-state.js";
5
6
  import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
6
7
  import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
8
+ import { isHomeWorkspace } from "./context/workspace-scope.js";
7
9
  const PROVIDER_DEFAULTS = {
8
10
  anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
9
11
  qwen: {
@@ -33,7 +35,7 @@ const PROVIDER_DEFAULTS = {
33
35
  },
34
36
  "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
35
37
  };
36
- 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", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
38
+ 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"];
37
39
  export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
38
40
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
39
41
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
@@ -159,6 +161,10 @@ function readProjectConfig(cwd) {
159
161
  dir = resolve(cwd);
160
162
  }
161
163
  for (;;) {
164
+ // ~/.hara/config.json is the global control-plane file already loaded by loadConfig(). Never read it
165
+ // a second time as repository input, and never climb through Home into a parent repository.
166
+ if (isHomeWorkspace(dir))
167
+ break;
162
168
  const hara = join(dir, ".hara");
163
169
  const p = join(hara, "config.json");
164
170
  let haraInfo;
@@ -320,6 +326,8 @@ export function loadConfig(opts = {}) {
320
326
  const guardianRaw = process.env.HARA_GUARDIAN ?? merged.guardian;
321
327
  const guardian = guardianRaw === "0" || guardianRaw === "off" || guardianRaw === "false" ? "off" : "on";
322
328
  const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
329
+ const runTimeoutMs = agentRunTimeoutMs(process.env.HARA_RUN_TIMEOUT_MS ?? merged.runTimeoutMs);
330
+ const maxAgentRounds = agentMaxRounds(process.env.HARA_MAX_AGENT_ROUNDS ?? merged.maxAgentRounds);
323
331
  const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
324
332
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
325
333
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
@@ -332,7 +340,7 @@ export function loadConfig(opts = {}) {
332
340
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
333
341
  ? reasoningRaw
334
342
  : undefined;
335
- return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
343
+ return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
336
344
  }
337
345
  export function providerEnvKey(provider) {
338
346
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -3,6 +3,7 @@
3
3
  import { existsSync } from "node:fs";
4
4
  import { join, dirname, resolve } from "node:path";
5
5
  import { readModelContextBytePrefixSync } from "../fs-read.js";
6
+ import { homeWorkspaceGuidance, isHomeWorkspace } from "./workspace-scope.js";
6
7
  const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
7
8
  const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
8
9
  const MAX_BYTES = 32 * 1024;
@@ -32,13 +33,23 @@ function markBudgetTruncation(value) {
32
33
  return utf8Prefix(value, MAX_BYTES - byteLength(TRUNCATED)) + TRUNCATED;
33
34
  }
34
35
  export function findProjectRoot(cwd) {
35
- let dir = resolve(cwd);
36
+ const start = resolve(cwd);
37
+ // Home is a control/personal-data scope even when it happens to contain a package.json/.git marker.
38
+ // Resolve this before the marker check so `hara` launched at ~/ cannot inherit a parent repository
39
+ // through a symlinked/nested HOME used by managed development environments.
40
+ if (isHomeWorkspace(start))
41
+ return start;
42
+ let dir = start;
36
43
  for (;;) {
44
+ // A marker accidentally placed at ~/ (for example a personal package.json) must not make every
45
+ // unmarked child directory inherit the entire home as its project root. Explicit child scope stays local.
46
+ if (dir !== start && isHomeWorkspace(dir))
47
+ return start;
37
48
  if (ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
38
49
  return dir;
39
50
  const parent = dirname(dir);
40
51
  if (parent === dir)
41
- return resolve(cwd); // no marker found → treat cwd as root
52
+ return start; // no marker found → treat cwd as root
42
53
  dir = parent;
43
54
  }
44
55
  }
@@ -87,6 +98,14 @@ export function loadAgentsMd(cwd) {
87
98
  }
88
99
  return combined;
89
100
  }
101
+ /** Model context is project AGENTS.md plus a built-in scope note when cwd is the user's home. Keeping the
102
+ * built-in note separate from loadAgentsMd() preserves that function's file-loading semantics and lets the
103
+ * UI accurately say whether an AGENTS.md file was actually loaded. */
104
+ export function loadAgentContext(cwd) {
105
+ const guidance = homeWorkspaceGuidance(cwd);
106
+ const agents = loadAgentsMd(cwd);
107
+ return [guidance, agents].filter(Boolean).join("\n\n--- workspace-context ---\n\n");
108
+ }
90
109
  export function hasAgentsMd(cwd) {
91
110
  const root = findProjectRoot(cwd);
92
111
  return FILENAMES.some((n) => existsSync(join(root, n)));
@@ -2,11 +2,12 @@
2
2
  // and provide fuzzy file candidates for REPL tab-completion.
3
3
  import { existsSync, lstatSync } from "node:fs";
4
4
  import { isAbsolute, resolve } from "node:path";
5
- import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
5
+ import { listProjectFiles, dirPrefixes, walkFiles, walkFilesAsync } from "../fs-walk.js";
6
6
  import { fuzzyRank } from "../fuzzy.js";
7
7
  import { mediaTypeFor } from "../images.js";
8
8
  import { readModelContextPrefixSync } from "../fs-read.js";
9
9
  import { sensitiveFileError } from "../security/sensitive-files.js";
10
+ import { recursiveRootContainsHome, recursiveHomeSearchError } from "./workspace-scope.js";
10
11
  const MAX_FILE = 50_000;
11
12
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
12
13
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
@@ -44,6 +45,86 @@ export function expandMentions(input, cwd) {
44
45
  return `${prefix}${block}`;
45
46
  });
46
47
  }
48
+ /**
49
+ * Interruptible production variant. In particular, `@dir` uses the streaming async walker so mention
50
+ * expansion cannot block the event loop before an agent run's own deadline starts.
51
+ */
52
+ export async function expandMentionsAsync(input, cwd, options = {}) {
53
+ const seen = new Set();
54
+ const mentionRe = new RegExp(MENTION_RE.source, MENTION_RE.flags);
55
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 2_000;
56
+ const startedAt = Date.now();
57
+ let output = "";
58
+ let copiedThrough = 0;
59
+ let match;
60
+ while ((match = mentionRe.exec(input)) !== null) {
61
+ if (options.signal?.aborted) {
62
+ throw options.signal.reason instanceof Error ? options.signal.reason : new Error("mention expansion cancelled");
63
+ }
64
+ output += input.slice(copiedThrough, match.index);
65
+ copiedThrough = match.index + match[0].length;
66
+ const whole = match[0];
67
+ const ref = match[1];
68
+ const prefix = whole.slice(0, whole.length - ref.length - 1);
69
+ if (seen.has(ref)) {
70
+ output += whole;
71
+ continue;
72
+ }
73
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
74
+ if (remainingMs <= 0) {
75
+ output += `${prefix}\nReferenced \`${ref}\` was not inserted because the ${timeoutMs}ms attachment budget was exhausted.\n`;
76
+ continue;
77
+ }
78
+ const block = await expandRefAsync(ref, cwd, {
79
+ ...options,
80
+ timeoutMs: remainingMs,
81
+ maxFiles: Math.min(options.maxFiles ?? 300, 300),
82
+ });
83
+ if (block === null)
84
+ output += whole;
85
+ else {
86
+ seen.add(ref);
87
+ output += `${prefix}${block}`;
88
+ }
89
+ }
90
+ return output + input.slice(copiedThrough);
91
+ }
92
+ async function expandRefAsync(ref, cwd, options) {
93
+ const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
94
+ const denied = sensitiveFileError(abs, "attach");
95
+ if (denied)
96
+ return `\nProtected file \`${ref}\` was not inserted into model context. ${denied}\n`;
97
+ try {
98
+ const st = lstatSync(abs);
99
+ if (st.isSymbolicLink())
100
+ return `Referenced \`${ref}\` is a symbolic link — it was not inserted into model context.`;
101
+ if (st.isFile()) {
102
+ if (mediaTypeFor(abs))
103
+ return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
104
+ const prefix = readModelContextPrefixSync(abs, MAX_FILE);
105
+ if (prefix.binary)
106
+ return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
107
+ const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
108
+ return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
109
+ }
110
+ if (st.isDirectory()) {
111
+ if (recursiveRootContainsHome(abs))
112
+ return recursiveHomeSearchError("directory attachment");
113
+ const inventory = await walkFilesAsync(abs, options);
114
+ const bounded = inventory.truncated
115
+ ? `; listing stopped at its ${inventory.reason?.replace("_", " ") ?? "safety limit"}`
116
+ : "";
117
+ return `\nReferenced directory \`${ref}\` (${inventory.files.length} files${bounded}):\n\`\`\`\n${inventory.files.join("\n") || "(empty)"}\n\`\`\`\n`;
118
+ }
119
+ }
120
+ catch (error) {
121
+ if (options.signal?.aborted) {
122
+ throw options.signal.reason instanceof Error ? options.signal.reason : new Error("mention expansion cancelled");
123
+ }
124
+ /* ignore unreadable mention */
125
+ }
126
+ return null;
127
+ }
47
128
  /** Render one mention as an inline block, or null if it isn't a readable file/dir. */
48
129
  function expandRef(ref, cwd) {
49
130
  const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
@@ -67,6 +148,8 @@ function expandRef(ref, cwd) {
67
148
  return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
68
149
  }
69
150
  if (st.isDirectory()) {
151
+ if (recursiveRootContainsHome(abs))
152
+ return recursiveHomeSearchError("directory attachment");
70
153
  // `@dir` loads a listing of the directory's files (the agent can then read specific ones)
71
154
  const files = walkFiles(abs, 300);
72
155
  return `\nReferenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\`\n`;
@@ -0,0 +1,49 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ /** Resolve existing paths through symlinks before comparing security/workspace scopes. Falling back to
5
+ * resolve() keeps messages deterministic for a path that disappears during startup; normal cwd/home paths
6
+ * exist and therefore take the realpath branch. */
7
+ export function canonicalWorkspacePath(path) {
8
+ const absolute = resolve(path);
9
+ try {
10
+ return realpathSync.native(absolute);
11
+ }
12
+ catch {
13
+ return absolute;
14
+ }
15
+ }
16
+ /** The user's home directory is a control/personal-data scope, not an implicit project workspace. */
17
+ export function isHomeWorkspace(cwd, home = homedir()) {
18
+ return canonicalWorkspacePath(cwd) === canonicalWorkspacePath(home);
19
+ }
20
+ /** A recursive root is unsafe when it is Home itself OR an ancestor that would descend into Home. This
21
+ * closes `path: ".."`, filesystem-root, and symlink-alias bypasses while keeping an explicitly selected
22
+ * project child under Home usable. */
23
+ export function recursiveRootContainsHome(root, home = homedir()) {
24
+ const canonicalRoot = canonicalWorkspacePath(root);
25
+ const canonicalHome = canonicalWorkspacePath(home);
26
+ const rel = relative(canonicalRoot, canonicalHome);
27
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
28
+ }
29
+ export function homeWorkspaceActionError(action) {
30
+ return (`Refusing to ${action} from the home directory: it is not an implicit project workspace. ` +
31
+ "Run `cd /path/to/project` first.");
32
+ }
33
+ export function recursiveHomeSearchError(tool) {
34
+ return (`Error: ${tool} will not recursively scan the home directory. ` +
35
+ "Run Hara from a project (`cd /path/to/project`) or set `path` to a specific file/subdirectory. " +
36
+ "Non-recursive `ls` and explicit file reads remain available.");
37
+ }
38
+ /** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
39
+ * policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
40
+ export function homeWorkspaceGuidance(cwd) {
41
+ if (!isHomeWorkspace(cwd))
42
+ return "";
43
+ return ("# Home-directory workspace boundary\n" +
44
+ "The working directory resolves to the user's home directory, which is not an implicit project. " +
45
+ "Do not initialize a project, build a repository index, run shell/external executable tools, or recursively " +
46
+ "grep/glob/search the home root. " +
47
+ "Ask the user to run `cd /path/to/project` for project work. Non-recursive `ls`, explicitly named files, " +
48
+ "and explicitly named child directories remain available.");
49
+ }
@@ -7,21 +7,36 @@
7
7
  // weixin:<peerId> (sends over stored ~/.hara/weixin creds — explicit peer required; guessing the
8
8
  // "owner" from a multi-DM context-token cache can deliver private results to the wrong person)
9
9
  // Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
10
+ import { outboundTransferTimeoutMs, withOutboundDeadline } from "../gateway/telegram.js";
10
11
  /** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
11
12
  export function parseDeliver(spec) {
12
13
  const i = spec.indexOf(":");
13
14
  if (i <= 0)
14
- return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>` };
15
+ return { error: "bad deliver spec — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>" };
15
16
  const platform = spec.slice(0, i).toLowerCase();
16
17
  const to = spec.slice(i + 1).trim();
17
18
  if (!to)
18
- return { error: `deliver spec "${spec}" is missing a target after ":"` };
19
+ return { error: "deliver spec is missing a target after ':'" };
19
20
  if (platform === "weixin" && to.toLowerCase() === "owner") {
20
21
  return { error: "weixin:owner is ambiguous when several people have messaged the bot — use an explicit weixin:<peerId>" };
21
22
  }
22
23
  if (platform === "telegram" || platform === "feishu" || platform === "webhook" || platform === "weixin")
23
24
  return { platform, to };
24
- return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, weixin, webhook` };
25
+ return { error: "unsupported deliver platform — supported: telegram, feishu, weixin, webhook" };
26
+ }
27
+ function safeDeliveryError(error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ const boundedLifecycle = /^(?:Gateway delivery|Webhook delivery|Telegram (?:send|upload)|Feishu (?:send|upload|recall)) (?:cancelled|timed out after \d+ms)$/u.exec(message);
30
+ if (boundedLifecycle)
31
+ return boundedLifecycle[0];
32
+ const telegramStatus = /^(Telegram [A-Za-z]+ failed: HTTP \d+)/u.exec(message);
33
+ if (telegramStatus)
34
+ return telegramStatus[1];
35
+ const controlledTransport = /^(?:Telegram [A-Za-z]+ transport failed|Feishu (?:auth transport failed|transport failed|auth failed: HTTP \d+(?: · code=\d+)?|request failed: HTTP \d+(?: · code=\d+)?))$/u.exec(message);
36
+ if (controlledTransport)
37
+ return controlledTransport[0];
38
+ // Opaque adapters and native fetch errors may contain the full target URL (including query credentials).
39
+ return "transport request failed";
25
40
  }
26
41
  /** Flatten markdown for plain-text chat surfaces (Feishu/WeChat/Telegram text messages render syntax
27
42
  * literally): drop code fences/inline backticks/bold/headers, turn [text](url) into "text (url)". */
@@ -36,49 +51,57 @@ export function plainChat(text) {
36
51
  .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
37
52
  }
38
53
  /** Send `text` to the target. Returns null on success, or an error string (never throws — cron
39
- * delivery is best-effort and must not kill the tick). */
40
- export async function deliverResult(spec, text) {
54
+ * delivery is best-effort and must not kill the tick). `signal` ties one-shot adapters to their caller;
55
+ * the outer hard deadline also bounds adapters/SDKs that fail to cooperate with cancellation. */
56
+ export async function deliverResult(spec, text, signal, idempotencyKey) {
41
57
  const t = parseDeliver(spec);
42
58
  if ("error" in t)
43
59
  return t.error;
44
60
  if (t.platform !== "webhook")
45
61
  text = plainChat(text); // chat surfaces are plain text; webhooks get raw payload
46
62
  try {
47
- if (t.platform === "webhook") {
48
- const r = await fetch(t.to, {
49
- method: "POST",
50
- headers: { "content-type": "application/json" },
51
- body: JSON.stringify({ source: "hara-cron", text }),
52
- signal: AbortSignal.timeout(15_000),
53
- });
54
- return r.ok ? null : `webhook ${r.status}`;
55
- }
56
- if (t.platform === "telegram") {
57
- const token = process.env.HARA_TELEGRAM_TOKEN;
58
- if (!token)
59
- return "HARA_TELEGRAM_TOKEN not set";
60
- const { telegramAdapter } = await import("../gateway/telegram.js");
61
- await telegramAdapter(token).send(t.to, text);
62
- return null;
63
- }
64
- if (t.platform === "weixin") {
65
- const { loadWeixinCreds, weixinAdapter } = await import("../gateway/weixin.js");
66
- const creds = loadWeixinCreds();
67
- if (!creds)
68
- return "hara weixin not logged in (~/.hara/weixin/creds.json missing)";
69
- await weixinAdapter(creds).send(t.to, text);
63
+ return await withOutboundDeadline("Gateway delivery", signal, outboundTransferTimeoutMs("text"), async (deliverySignal) => {
64
+ if (t.platform === "webhook") {
65
+ return withOutboundDeadline("Webhook delivery", deliverySignal, 15_000, async (webhookSignal) => {
66
+ const r = await fetch(t.to, {
67
+ method: "POST",
68
+ headers: {
69
+ "content-type": "application/json",
70
+ ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
71
+ },
72
+ body: JSON.stringify({ source: "hara-cron", text }),
73
+ signal: webhookSignal,
74
+ });
75
+ return r.ok ? null : `webhook ${r.status}`;
76
+ });
77
+ }
78
+ if (t.platform === "telegram") {
79
+ const token = process.env.HARA_TELEGRAM_TOKEN;
80
+ if (!token)
81
+ return "HARA_TELEGRAM_TOKEN not set";
82
+ const { telegramAdapter } = await import("../gateway/telegram.js");
83
+ await telegramAdapter(token).send(t.to, text, deliverySignal, idempotencyKey);
84
+ return null;
85
+ }
86
+ if (t.platform === "weixin") {
87
+ const { loadWeixinCreds, weixinAdapter } = await import("../gateway/weixin.js");
88
+ const creds = loadWeixinCreds();
89
+ if (!creds)
90
+ return "hara weixin not logged in (~/.hara/weixin/creds.json missing)";
91
+ await weixinAdapter(creds).send(t.to, text, deliverySignal);
92
+ return null;
93
+ }
94
+ // feishu
95
+ const appId = process.env.HARA_FEISHU_APP_ID;
96
+ const appSecret = process.env.HARA_FEISHU_APP_SECRET;
97
+ if (!appId || !appSecret)
98
+ return "HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET not set";
99
+ const { feishuAdapter } = await import("../gateway/feishu.js");
100
+ await feishuAdapter(appId, appSecret).send(t.to, text, deliverySignal, idempotencyKey);
70
101
  return null;
71
- }
72
- // feishu
73
- const appId = process.env.HARA_FEISHU_APP_ID;
74
- const appSecret = process.env.HARA_FEISHU_APP_SECRET;
75
- if (!appId || !appSecret)
76
- return "HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET not set";
77
- const { feishuAdapter } = await import("../gateway/feishu.js");
78
- await feishuAdapter(appId, appSecret).send(t.to, text);
79
- return null;
102
+ });
80
103
  }
81
104
  catch (e) {
82
- return `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
105
+ return `delivery failed: ${safeDeliveryError(e)}`;
83
106
  }
84
107
  }