@nanhara/hara 0.121.1 → 0.122.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 (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Supported validation keywords are deliberately small and explicit:
3
+ * - `type`: object, array, string, number, integer, boolean, null (or a union array)
4
+ * - `required`, `properties`, and boolean `additionalProperties` for objects
5
+ * - one schema in `items` for arrays (tuple schemas are not supported)
6
+ * - `enum` containing JSON primitive values
7
+ * - annotation-only `title` and `description`
8
+ *
9
+ * Every other JSON-Schema keyword is rejected. In particular, combinators and references such as
10
+ * `$ref`, `allOf`, `anyOf`, `oneOf`, and `not` must never be silently accepted: doing so would make
11
+ * the CLI promise a constraint that this dependency-free validator did not enforce.
12
+ */
13
+ const SUPPORTED_SCHEMA_KEYWORDS = new Set([
14
+ "type",
15
+ "required",
16
+ "properties",
17
+ "additionalProperties",
18
+ "items",
19
+ "enum",
20
+ "title",
21
+ "description",
22
+ ]);
23
+ const SUPPORTED_SCHEMA_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
24
+ function isSchemaObject(value) {
25
+ if (typeof value !== "object" || value === null || Array.isArray(value))
26
+ return false;
27
+ const prototype = Object.getPrototypeOf(value);
28
+ return prototype === Object.prototype || prototype === null;
29
+ }
30
+ function own(schema, key) {
31
+ return Object.hasOwn(schema, key) ? schema[key] : undefined;
32
+ }
33
+ function schemaTypes(schema) {
34
+ const type = own(schema, "type");
35
+ return type === undefined ? [] : Array.isArray(type) ? type : [type];
36
+ }
37
+ function describeSchemaValue(value) {
38
+ try {
39
+ return JSON.stringify(value) ?? String(value);
40
+ }
41
+ catch {
42
+ return String(value);
43
+ }
44
+ }
45
+ function validateSchemaDefinition(schema, path = "$schema") {
46
+ if (!isSchemaObject(schema))
47
+ return `${path}: schema must be a plain JSON object`;
48
+ for (const keyword of Object.keys(schema)) {
49
+ if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword))
50
+ return `${path}.${keyword}: unsupported JSON-Schema keyword`;
51
+ }
52
+ const type = own(schema, "type");
53
+ if (type !== undefined) {
54
+ const types = Array.isArray(type) ? type : [type];
55
+ if (types.length === 0)
56
+ return `${path}.type: type array must not be empty`;
57
+ const seen = new Set();
58
+ for (const candidate of types) {
59
+ if (typeof candidate !== "string" || !SUPPORTED_SCHEMA_TYPES.has(candidate)) {
60
+ return `${path}.type: unsupported JSON-Schema type ${describeSchemaValue(candidate)}`;
61
+ }
62
+ if (seen.has(candidate))
63
+ return `${path}.type: duplicate type ${describeSchemaValue(candidate)}`;
64
+ seen.add(candidate);
65
+ }
66
+ }
67
+ for (const annotation of ["title", "description"]) {
68
+ const value = own(schema, annotation);
69
+ if (value !== undefined && typeof value !== "string")
70
+ return `${path}.${annotation}: expected string`;
71
+ }
72
+ const enumValues = own(schema, "enum");
73
+ if (enumValues !== undefined) {
74
+ if (!Array.isArray(enumValues) || enumValues.length === 0)
75
+ return `${path}.enum: expected a non-empty array`;
76
+ for (const value of enumValues) {
77
+ if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
78
+ return `${path}.enum: only JSON primitive values are supported`;
79
+ }
80
+ if (typeof value === "number" && !Number.isFinite(value))
81
+ return `${path}.enum: numbers must be finite`;
82
+ }
83
+ }
84
+ const required = own(schema, "required");
85
+ if (required !== undefined) {
86
+ if (!Array.isArray(required) || required.some((key) => typeof key !== "string")) {
87
+ return `${path}.required: expected an array of property names`;
88
+ }
89
+ if (new Set(required).size !== required.length)
90
+ return `${path}.required: property names must be unique`;
91
+ }
92
+ const properties = own(schema, "properties");
93
+ if (properties !== undefined) {
94
+ if (!isSchemaObject(properties))
95
+ return `${path}.properties: expected an object of property schemas`;
96
+ for (const [key, child] of Object.entries(properties)) {
97
+ const error = validateSchemaDefinition(child, `${path}.properties.${key}`);
98
+ if (error)
99
+ return error;
100
+ }
101
+ }
102
+ const additionalProperties = own(schema, "additionalProperties");
103
+ if (additionalProperties !== undefined && typeof additionalProperties !== "boolean") {
104
+ return `${path}.additionalProperties: only boolean values are supported`;
105
+ }
106
+ const items = own(schema, "items");
107
+ if (items !== undefined) {
108
+ const error = validateSchemaDefinition(items, `${path}.items`);
109
+ if (error)
110
+ return error;
111
+ }
112
+ const types = schemaTypes(schema);
113
+ const hasObjectKeyword = required !== undefined || properties !== undefined || additionalProperties !== undefined;
114
+ if (hasObjectKeyword && !types.includes("object")) {
115
+ return `${path}: object keywords require type "object"`;
116
+ }
117
+ if (items !== undefined && !types.includes("array"))
118
+ return `${path}: items requires type "array"`;
119
+ return null;
120
+ }
121
+ function actualType(value) {
122
+ return Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
123
+ }
124
+ function matchesType(value, type) {
125
+ const actual = actualType(value);
126
+ if (type === "integer")
127
+ return actual === "number" && Number.isFinite(value) && Number.isInteger(value);
128
+ if (type === "number")
129
+ return actual === "number" && Number.isFinite(value);
130
+ return type === actual;
131
+ }
132
+ function validateValue(value, schema, path) {
133
+ const types = schemaTypes(schema);
134
+ if (types.length && !types.some((type) => matchesType(value, type))) {
135
+ return `${path}: expected ${types.join("|")}, got ${actualType(value)}`;
136
+ }
137
+ const enumValues = own(schema, "enum");
138
+ if (enumValues && !enumValues.some((candidate) => candidate === value)) {
139
+ return `${path}: value not in enum ${JSON.stringify(enumValues)}`;
140
+ }
141
+ if (typeof value === "object" && value !== null && !Array.isArray(value) && types.includes("object")) {
142
+ const object = value;
143
+ const required = own(schema, "required");
144
+ for (const key of required ?? []) {
145
+ if (!Object.hasOwn(object, key))
146
+ return `${path}.${key}: required property missing`;
147
+ }
148
+ const properties = own(schema, "properties");
149
+ for (const [key, child] of Object.entries(properties ?? {})) {
150
+ if (Object.hasOwn(object, key)) {
151
+ const error = validateValue(object[key], child, `${path}.${key}`);
152
+ if (error)
153
+ return error;
154
+ }
155
+ }
156
+ if (own(schema, "additionalProperties") === false) {
157
+ for (const key of Object.keys(object)) {
158
+ if (!properties || !Object.hasOwn(properties, key))
159
+ return `${path}.${key}: unexpected property`;
160
+ }
161
+ }
162
+ }
163
+ if (Array.isArray(value) && types.includes("array")) {
164
+ const items = own(schema, "items");
165
+ if (items) {
166
+ for (let index = 0; index < value.length; index++) {
167
+ const error = validateValue(value[index], items, `${path}[${index}]`);
168
+ if (error)
169
+ return error;
170
+ }
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+ /** Validate `value` against the supported JSON-Schema subset. Returns null when valid, otherwise a path-rich
176
+ * error. Invalid or unsupported schemas are errors too; they are never treated as permissive schemas. */
177
+ export function validateAgainstSchema(value, schema, path = "$") {
178
+ const schemaError = validateSchemaDefinition(schema);
179
+ if (schemaError)
180
+ return `invalid schema — ${schemaError}`;
181
+ return validateValue(value, schema, path);
182
+ }
183
+ /** Parse a `--schema` CLI value: inline JSON, or (by the caller) file contents. Returns the schema object or
184
+ * an error string. Top level must be an object schema — tool arguments are always a JSON object. */
185
+ export function parseSchemaArg(raw) {
186
+ let schema;
187
+ try {
188
+ schema = JSON.parse(raw);
189
+ }
190
+ catch (e) {
191
+ return { error: `--schema is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
192
+ }
193
+ if (!isSchemaObject(schema))
194
+ return { error: "--schema must be a JSON object (a JSON-Schema)" };
195
+ let normalized = schema;
196
+ if (!Object.hasOwn(normalized, "type"))
197
+ normalized = { ...normalized, type: "object" };
198
+ if (own(normalized, "type") !== "object") {
199
+ return { error: "--schema top level must have type:'object' (tool args are an object)" };
200
+ }
201
+ if (!Object.hasOwn(normalized, "properties"))
202
+ normalized = { ...normalized, properties: {} };
203
+ const schemaError = validateSchemaDefinition(normalized);
204
+ if (schemaError)
205
+ return { error: `--schema is unsupported or invalid: ${schemaError}` };
206
+ return normalized;
207
+ }
208
+ /** The instruction appended to the prompt so the model knows the contract. */
209
+ export const STRUCTURED_INSTRUCTION = "\n\nWhen you have the final answer, you MUST call the `structured_output` tool exactly once with the result " +
210
+ "matching its schema. Do not print the result as text — the tool call IS the answer.";
211
+ /** The retry nudge when a turn ends without the tool having been called. */
212
+ export const STRUCTURED_NUDGE = "You finished without calling `structured_output`. Call it NOW with your final result matching the schema — the tool call is the only accepted answer.";
213
+ /** Build the run-scoped structured_output tool. `sink` receives the validated payload (last call wins). */
214
+ export function structuredOutputTool(schema, sink) {
215
+ const schemaError = validateSchemaDefinition(schema);
216
+ if (schemaError)
217
+ throw new Error(`Invalid structured-output schema: ${schemaError}`);
218
+ return {
219
+ name: "structured_output",
220
+ description: "Record the final structured result of this task. Call exactly once, when done, with the complete answer.",
221
+ input_schema: schema,
222
+ kind: "read", // never prompts; the payload is data, not an action
223
+ async run(input) {
224
+ const err = validateAgainstSchema(input, schema);
225
+ if (err)
226
+ return `schema validation failed — ${err}. Fix the payload and call structured_output again.`;
227
+ sink(input);
228
+ return "structured result recorded.";
229
+ },
230
+ };
231
+ }
@@ -2,17 +2,31 @@
2
2
  // The loop records every file the MAIN conversation reads/edits; when the history is compacted to a
3
3
  // summary, the top-N most-recent files get their CURRENT on-disk content re-attached — so the model
4
4
  // doesn't lose the very files it was working on and re-read them all next turn.
5
- const touched = new Map(); // absolute path → last-touch timestamp
6
- export function recordTouch(path) {
5
+ const DEFAULT_SCOPE = "default";
6
+ const touchedByScope = new Map(); // scope → absolute path → last-touch timestamp
7
+ function scopedTouched(scope) {
8
+ const key = scope?.trim() || DEFAULT_SCOPE;
9
+ const touched = touchedByScope.get(key) ?? new Map();
10
+ touchedByScope.set(key, touched);
11
+ return touched;
12
+ }
13
+ export function recordTouch(path, scope) {
14
+ const touched = scopedTouched(scope);
15
+ touched.delete(path);
7
16
  touched.set(path, Date.now());
17
+ if (touched.size > 200)
18
+ touched.delete(touched.keys().next().value);
8
19
  }
9
20
  /** Most-recently-touched first. */
10
- export function recentTouched(n = 5) {
11
- return [...touched.entries()]
21
+ export function recentTouched(n = 5, scope) {
22
+ return [...scopedTouched(scope).entries()]
12
23
  .sort((a, b) => b[1] - a[1])
13
24
  .slice(0, n)
14
25
  .map(([p]) => p);
15
26
  }
16
- export function clearTouched() {
17
- touched.clear();
27
+ export function clearTouched(scope) {
28
+ if (scope)
29
+ touchedByScope.delete(scope.trim() || DEFAULT_SCOPE);
30
+ else
31
+ touchedByScope.clear();
18
32
  }
package/dist/config.js CHANGED
@@ -38,18 +38,27 @@ const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "p
38
38
  export function configPath() {
39
39
  return join(homedir(), ".hara", "config.json");
40
40
  }
41
+ function configRecord(value) {
42
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
43
+ }
41
44
  export function readRawConfig() {
42
45
  const p = configPath();
43
46
  if (!existsSync(p))
44
47
  return {};
45
48
  try {
46
- return JSON.parse(readFileSync(p, "utf8"));
49
+ return configRecord(JSON.parse(readFileSync(p, "utf8")));
47
50
  }
48
51
  catch {
49
52
  return {};
50
53
  }
51
54
  }
52
- const ROUTING_CONFIG_KEYS = new Set(["provider", "apiKey", "model", "baseURL"]);
55
+ const ROUTING_CONFIG_KEYS = new Set([
56
+ "provider", "apiKey", "model", "baseURL",
57
+ "fallbackProvider", "fallbackApiKey", "fallbackModel", "fallbackBaseURL",
58
+ "visionApiKey", "visionModel", "visionBaseURL",
59
+ "embedProvider", "embedApiKey", "embedModel", "embedBaseURL",
60
+ "routeApiKey", "routeModel", "routeBaseURL",
61
+ ]);
53
62
  /** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
54
63
  * an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
55
64
  function withoutBlankRoutingValues(input) {
@@ -78,7 +87,7 @@ function readProjectConfig(cwd) {
78
87
  const p = join(dir, ".hara", "config.json");
79
88
  if (existsSync(p)) {
80
89
  try {
81
- return JSON.parse(readFileSync(p, "utf8"));
90
+ return configRecord(JSON.parse(readFileSync(p, "utf8")));
82
91
  }
83
92
  catch {
84
93
  return {};
@@ -139,14 +148,15 @@ export function loadConfig(opts = {}) {
139
148
  // Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
140
149
  // The legacy `profiles` key is kept readable for back-compat with users who already have it.
141
150
  const { overlays, profiles, ...globalBase } = global;
142
- const project = readProjectConfig(process.cwd());
143
- const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
151
+ const effectiveCwd = resolve(opts.cwd ?? process.cwd());
152
+ const project = readProjectConfig(effectiveCwd);
153
+ const overlayName = nonBlankEnv(process.env.HARA_OVERLAY) ?? nonBlankEnv(opts.overlay);
144
154
  const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
145
- const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
155
+ const overlay = configRecord(overlayName && overlayMap ? overlayMap[overlayName] : undefined);
146
156
  const merged = {
147
157
  ...withoutBlankRoutingValues(globalBase),
148
- ...withoutBlankRoutingValues(project),
149
158
  ...withoutBlankRoutingValues(overlay),
159
+ ...withoutBlankRoutingValues(project),
150
160
  };
151
161
  const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
152
162
  const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
@@ -160,21 +170,21 @@ export function loadConfig(opts = {}) {
160
170
  const assetCapture = (process.env.HARA_ASSET_CAPTURE ?? merged.assetCapture ?? "ask");
161
171
  const computerUse = (process.env.HARA_COMPUTER_USE ?? merged.computerUse ?? "off");
162
172
  const computerApps = String(process.env.HARA_COMPUTER_APPS ?? merged.computerApps ?? "").split(",").map((s) => s.trim()).filter(Boolean);
163
- const visionModel = process.env.HARA_VISION_MODEL ?? merged.visionModel;
164
- const visionBaseURL = process.env.HARA_VISION_BASE_URL ?? merged.visionBaseURL;
165
- const visionApiKey = process.env.HARA_VISION_API_KEY ?? merged.visionApiKey;
173
+ const visionModel = nonBlankEnv(process.env.HARA_VISION_MODEL) ?? merged.visionModel;
174
+ const visionBaseURL = nonBlankEnv(process.env.HARA_VISION_BASE_URL) ?? merged.visionBaseURL;
175
+ const visionApiKey = nonBlankEnv(process.env.HARA_VISION_API_KEY) ?? merged.visionApiKey;
166
176
  const modelVision = merged.modelVision && typeof merged.modelVision === "object" ? merged.modelVision : {};
167
- const embedProvider = (process.env.HARA_EMBED_PROVIDER ?? merged.embedProvider ?? "off");
168
- const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
169
- const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
170
- const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
171
- const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
172
- const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
173
- const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
177
+ const embedProvider = (nonBlankEnv(process.env.HARA_EMBED_PROVIDER) ?? merged.embedProvider ?? "off");
178
+ const embedModel = nonBlankEnv(process.env.HARA_EMBED_MODEL) ?? merged.embedModel;
179
+ const embedBaseURL = nonBlankEnv(process.env.HARA_EMBED_BASE_URL) ?? merged.embedBaseURL;
180
+ const embedApiKey = nonBlankEnv(process.env.HARA_EMBED_API_KEY) ?? merged.embedApiKey;
181
+ const routeModel = nonBlankEnv(process.env.HARA_ROUTE_MODEL) ?? merged.routeModel;
182
+ const routeBaseURL = nonBlankEnv(process.env.HARA_ROUTE_BASE_URL) ?? merged.routeBaseURL;
183
+ const routeApiKey = nonBlankEnv(process.env.HARA_ROUTE_API_KEY) ?? merged.routeApiKey;
174
184
  const mcpServers = {
175
185
  ...(globalBase.mcpServers ?? {}),
176
- ...(project.mcpServers ?? {}),
177
186
  ...(overlay.mcpServers ?? {}),
187
+ ...(project.mcpServers ?? {}),
178
188
  };
179
189
  const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
180
190
  // Guardian: default ON; env HARA_GUARDIAN=0/off/false or config guardian:"off" disables it.
@@ -185,15 +195,15 @@ export function loadConfig(opts = {}) {
185
195
  const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
186
196
  const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
187
197
  const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
188
- const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
189
- const fallbackProvider = (process.env.HARA_FALLBACK_PROVIDER ?? merged.fallbackProvider);
190
- const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
191
- const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
198
+ const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
199
+ const fallbackProvider = (nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider);
200
+ const fallbackBaseURL = nonBlankEnv(process.env.HARA_FALLBACK_BASE_URL) ?? merged.fallbackBaseURL;
201
+ const fallbackApiKey = nonBlankEnv(process.env.HARA_FALLBACK_API_KEY) ?? merged.fallbackApiKey;
192
202
  const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
193
203
  const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
194
204
  ? reasoningRaw
195
205
  : undefined;
196
- 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: process.cwd() };
206
+ 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 };
197
207
  }
198
208
  export function providerEnvKey(provider) {
199
209
  return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
@@ -1,15 +1,39 @@
1
+ // Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
2
+ // WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
3
+ // gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
4
+ // telegram:<chatId> (HARA_TELEGRAM_TOKEN)
5
+ // feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
6
+ // webhook:<url> (plain POST {name,status,text} JSON — for anything else)
7
+ // weixin:<peerId> (sends over stored ~/.hara/weixin creds — explicit peer required; guessing the
8
+ // "owner" from a multi-DM context-token cache can deliver private results to the wrong person)
9
+ // Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
1
10
  /** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
2
11
  export function parseDeliver(spec) {
3
12
  const i = spec.indexOf(":");
4
13
  if (i <= 0)
5
- return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, or webhook:<url>` };
14
+ return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>` };
6
15
  const platform = spec.slice(0, i).toLowerCase();
7
16
  const to = spec.slice(i + 1).trim();
8
17
  if (!to)
9
18
  return { error: `deliver spec "${spec}" is missing a target after ":"` };
10
- if (platform === "telegram" || platform === "feishu" || platform === "webhook")
19
+ if (platform === "weixin" && to.toLowerCase() === "owner") {
20
+ return { error: "weixin:owner is ambiguous when several people have messaged the bot — use an explicit weixin:<peerId>" };
21
+ }
22
+ if (platform === "telegram" || platform === "feishu" || platform === "webhook" || platform === "weixin")
11
23
  return { platform, to };
12
- return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, webhook (WeChat needs the live gateway)` };
24
+ return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, weixin, webhook` };
25
+ }
26
+ /** Flatten markdown for plain-text chat surfaces (Feishu/WeChat/Telegram text messages render syntax
27
+ * literally): drop code fences/inline backticks/bold/headers, turn [text](url) into "text (url)". */
28
+ export function plainChat(text) {
29
+ return text
30
+ .replace(/```[a-zA-Z0-9_-]*\n?/g, "")
31
+ .replace(/```/g, "")
32
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
33
+ .replace(/__([^_]+)__/g, "$1")
34
+ .replace(/`([^`]+)`/g, "$1")
35
+ .replace(/^#{1,6}\s+/gm, "")
36
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
13
37
  }
14
38
  /** Send `text` to the target. Returns null on success, or an error string (never throws — cron
15
39
  * delivery is best-effort and must not kill the tick). */
@@ -17,6 +41,8 @@ export async function deliverResult(spec, text) {
17
41
  const t = parseDeliver(spec);
18
42
  if ("error" in t)
19
43
  return t.error;
44
+ if (t.platform !== "webhook")
45
+ text = plainChat(text); // chat surfaces are plain text; webhooks get raw payload
20
46
  try {
21
47
  if (t.platform === "webhook") {
22
48
  const r = await fetch(t.to, {
@@ -35,6 +61,14 @@ export async function deliverResult(spec, text) {
35
61
  await telegramAdapter(token).send(t.to, text);
36
62
  return null;
37
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);
70
+ return null;
71
+ }
38
72
  // feishu
39
73
  const appId = process.env.HARA_FEISHU_APP_ID;
40
74
  const appSecret = process.env.HARA_FEISHU_APP_SECRET;
package/dist/feedback.js CHANGED
@@ -41,9 +41,10 @@ export function buildIssueBody(description, env, sessionTail) {
41
41
  parts.push("---", "_filed via `hara feedback`_");
42
42
  return parts.join("\n");
43
43
  }
44
- /** Issue title from the description: first line, trimmed, capped. */
44
+ /** Issue title from the description: first line, redacted BEFORE it crosses the public issue boundary,
45
+ * trimmed and capped. Body-only redaction is insufficient because GitHub titles are public too. */
45
46
  export function issueTitle(description) {
46
- const first = (description.trim().split("\n")[0] || "feedback").trim();
47
+ const first = redact((description.trim().split("\n")[0] || "feedback").trim());
47
48
  return first.length > 70 ? first.slice(0, 67) + "…" : first;
48
49
  }
49
50
  export const FEEDBACK_REPO = "hara-cli/hara";
package/dist/fs-read.js CHANGED
@@ -1,20 +1,92 @@
1
1
  // Bounded streaming line reader for files too large to load as one string. It stores only the requested
2
2
  // window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
3
- import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
3
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
4
+ import { open } from "node:fs/promises";
4
5
  export class BinaryFileError extends Error {
5
6
  constructor(path) {
6
7
  super(`${path} appears to be binary (NUL byte detected)`);
7
8
  this.name = "BinaryFileError";
8
9
  }
9
10
  }
11
+ export class NonRegularFileError extends Error {
12
+ code = "HARA_NOT_REGULAR_FILE";
13
+ constructor(path) {
14
+ super(`${path} is not a regular file`);
15
+ this.name = "NonRegularFileError";
16
+ }
17
+ }
18
+ export class FileReadLimitError extends Error {
19
+ limit;
20
+ code = "HARA_FILE_TOO_LARGE";
21
+ constructor(path, limit) {
22
+ super(`${path} exceeds the ${limit}-byte safe edit/read limit`);
23
+ this.limit = limit;
24
+ this.name = "FileReadLimitError";
25
+ }
26
+ }
10
27
  const DEFAULT_LINE_CAP = 2000;
11
28
  const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
29
+ const MAX_SLICE_LINES = 2_000;
30
+ /** Editing tools materialize the old text for diff/CAS. Keep that allocation explicitly bounded. */
31
+ export const MAX_EDIT_READ_BYTES = 64 * 1024 * 1024;
32
+ const READ_CHUNK_BYTES = 64 * 1024;
33
+ const MAX_PREFIX_CHARS = 1_000_000;
34
+ /** Open without blocking on a FIFO, validate the SAME descriptor, then read at most maxBytes from it.
35
+ * Path-level stat→readFile is unsafe because the path can be exchanged for a pipe/device between calls. */
36
+ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
37
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
38
+ const limit = Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
39
+ const handle = await open(path, flags);
40
+ try {
41
+ const info = await handle.stat();
42
+ if (!info.isFile())
43
+ throw new NonRegularFileError(path);
44
+ if (info.size > limit)
45
+ throw new FileReadLimitError(path, limit);
46
+ const chunks = [];
47
+ let total = 0;
48
+ let position = 0;
49
+ // Read one byte past the limit so concurrent growth cannot bypass the pre-read size check.
50
+ while (total <= limit) {
51
+ const want = Math.min(READ_CHUNK_BYTES, limit + 1 - total);
52
+ const buffer = Buffer.allocUnsafe(want);
53
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
54
+ if (bytesRead === 0)
55
+ break;
56
+ chunks.push(buffer.subarray(0, bytesRead));
57
+ total += bytesRead;
58
+ position += bytesRead;
59
+ }
60
+ if (total > limit)
61
+ throw new FileReadLimitError(path, limit);
62
+ return { text: Buffer.concat(chunks, total).toString("utf8"), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
63
+ }
64
+ finally {
65
+ await handle.close().catch(() => { });
66
+ }
67
+ }
68
+ export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
69
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK);
70
+ }
71
+ /** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
72
+ export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
73
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
74
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
75
+ }
76
+ export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
77
+ return (await readRegularFileSnapshot(path, maxBytes)).text;
78
+ }
12
79
  /** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
13
80
  export function readTextPrefixSync(path, maxChars) {
14
- const chars = Math.max(0, Math.floor(maxChars));
15
- const fd = openSync(path, "r");
81
+ const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
82
+ const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
83
+ // O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
84
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
16
85
  try {
17
- const size = fstatSync(fd).size;
86
+ const info = fstatSync(fd);
87
+ if (!info.isFile())
88
+ throw new NonRegularFileError(path);
89
+ const size = info.size;
18
90
  // Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
19
91
  // `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
20
92
  const byteLimit = Math.min(size, chars * 4 + 4);
@@ -41,11 +113,19 @@ export function readTextPrefixSync(path, maxChars) {
41
113
  }
42
114
  /** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
43
115
  export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
44
- const start = Math.max(1, Math.floor(offset));
45
- const want = Math.max(1, Math.floor(limit));
116
+ const requestedStart = Number.isFinite(offset) ? Math.floor(offset) : 1;
117
+ const requestedLines = Number.isFinite(limit) ? Math.floor(limit) : 300;
118
+ const start = Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, requestedStart));
119
+ const want = Math.min(MAX_SLICE_LINES, Math.max(1, requestedLines));
46
120
  const requestedEnd = start + want - 1;
47
- const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
48
- const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
121
+ const requestedLineCap = Number.isFinite(options.lineCap)
122
+ ? Math.floor(options.lineCap)
123
+ : DEFAULT_LINE_CAP;
124
+ const lineCap = Math.min(DEFAULT_LINE_CAP, Math.max(1, requestedLineCap));
125
+ const requestedScan = Number.isFinite(options.maxScanChars)
126
+ ? Math.floor(options.maxScanChars)
127
+ : DEFAULT_MAX_SCAN;
128
+ const maxScan = Math.min(DEFAULT_MAX_SCAN, Math.max(lineCap, requestedScan));
49
129
  const rendered = [];
50
130
  let lineNo = 1;
51
131
  let linePrefix = "";
@@ -98,8 +178,17 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
98
178
  if (current > requestedEnd)
99
179
  hasMore = true;
100
180
  };
101
- const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
181
+ // Keep validation and streaming on the same non-blocking descriptor. This closes the stat→open race
182
+ // where an attacker/local generator exchanges a regular path for a FIFO after validation.
183
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
184
+ let stream;
102
185
  try {
186
+ const info = await handle.stat();
187
+ if (!info.isFile())
188
+ throw new NonRegularFileError(path);
189
+ // `end` is an inclusive byte offset. It makes the scan ceiling a true fd-level byte bound (not just
190
+ // a post-read character counter, which could overshoot badly on multi-byte text or a giant chunk).
191
+ stream = handle.createReadStream({ encoding: "utf8", highWaterMark: 64 * 1024, autoClose: false, end: maxScan - 1 });
103
192
  for await (const raw of stream) {
104
193
  const chunk = String(raw);
105
194
  if (chunk.length)
@@ -125,17 +214,22 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
125
214
  stoppedEarly = true;
126
215
  break;
127
216
  }
128
- if (scanned >= maxScan) {
217
+ }
218
+ if (!stoppedEarly && stream.bytesRead >= maxScan) {
219
+ // Re-fstat the same open file description so concurrent growth is also detected. An exact-size file
220
+ // is genuine EOF and should still receive the normal total-line rendering.
221
+ const latest = await handle.stat();
222
+ if (latest.size > stream.bytesRead) {
129
223
  scanLimited = true;
130
224
  stoppedEarly = true;
131
225
  if (lineNo >= start && lineNo <= requestedEnd)
132
226
  finishLine(true);
133
- break;
134
227
  }
135
228
  }
136
229
  }
137
230
  finally {
138
- stream.destroy();
231
+ stream?.destroy();
232
+ await handle.close().catch(() => { });
139
233
  }
140
234
  if (!stoppedEarly) {
141
235
  // A trailing newline creates no phantom line, matching String#split + trailing-empty removal.