@nanhara/hara 0.53.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +224 -0
- package/README.md +30 -10
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +17 -10
- package/dist/completions.js +49 -0
- package/dist/concurrency.js +22 -0
- package/dist/config.js +18 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/export.js +43 -0
- package/dist/index.js +485 -13
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/org-fleet/enroll.js +94 -0
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/recall.js +20 -4
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/web.js +81 -8
- package/dist/tui/App.js +2 -2
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// MCP server — expose hara's safe read/search tools over stdio so other MCP clients (Claude Desktop,
|
|
2
|
+
// Cursor, another hara, …) can use them. The high-value one is `codebase_search` over the current repo
|
|
3
|
+
// (semantic if you've built an index, lexical otherwise). `hara mcp` runs this.
|
|
4
|
+
//
|
|
5
|
+
// Read-only by DEFAULT: no edit/write/bash/computer — an external client must not be able to mutate your
|
|
6
|
+
// machine through hara. Override the exposed set with HARA_MCP_TOOLS (comma list) at your own risk.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT: stdio is the JSON-RPC transport — this module must never write to stdout. Diagnostics go to
|
|
9
|
+
// stderr (the `hara mcp` command handles that); tool output flows back through the protocol.
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { getTool, getTools } from "../tools/registry.js";
|
|
14
|
+
/** Safe default: navigate + search the repo + the web. No state, no mutation, no privacy-sensitive memory. */
|
|
15
|
+
const SAFE_TOOLS = ["read_file", "grep", "glob", "ls", "codebase_search", "web_fetch", "web_search"];
|
|
16
|
+
/** Names to expose: HARA_MCP_TOOLS (comma list) if set, else the safe default — intersected with what's
|
|
17
|
+
* actually registered. An unknown name in the override is silently dropped. */
|
|
18
|
+
export function mcpServeToolNames() {
|
|
19
|
+
const env = process.env.HARA_MCP_TOOLS;
|
|
20
|
+
const want = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : SAFE_TOOLS;
|
|
21
|
+
const have = new Set(getTools().map((t) => t.name));
|
|
22
|
+
return want.filter((n) => have.has(n));
|
|
23
|
+
}
|
|
24
|
+
/** The exposed tools in MCP `tools/list` shape. */
|
|
25
|
+
export function mcpToolList() {
|
|
26
|
+
return mcpServeToolNames().map((n) => {
|
|
27
|
+
const t = getTool(n);
|
|
28
|
+
return { name: t.name, description: t.description, inputSchema: t.input_schema };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Run one exposed tool. Refuses anything outside the exposed set (defense in depth, even if a client
|
|
32
|
+
* asks for a name it shouldn't know). Never throws — errors come back as `isError` results. */
|
|
33
|
+
export async function mcpCallTool(name, args, ctx) {
|
|
34
|
+
if (!mcpServeToolNames().includes(name)) {
|
|
35
|
+
return { content: [{ type: "text", text: `Tool not exposed by \`hara mcp\`: ${name}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
const tool = getTool(name);
|
|
38
|
+
if (!tool)
|
|
39
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
40
|
+
try {
|
|
41
|
+
const out = await tool.run(args ?? {}, ctx);
|
|
42
|
+
return { content: [{ type: "text", text: String(out) }] };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Start the stdio MCP server and block (the transport keeps the process alive). */
|
|
49
|
+
export async function startMcpServer(version, ctx) {
|
|
50
|
+
const server = new Server({ name: "hara", version }, { capabilities: { tools: {} } });
|
|
51
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpToolList() }));
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK's result union has a task-augmented
|
|
53
|
+
// member TS can't narrow to; our {content,isError} shape is a valid CallToolResult (validated at runtime).
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => (await mcpCallTool(req.params.name, (req.params.arguments ?? {}), ctx)));
|
|
55
|
+
await server.connect(new StdioServerTransport());
|
|
56
|
+
}
|
package/dist/memory/store.js
CHANGED
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
// project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
8
|
-
|
|
8
|
+
// Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
|
|
9
|
+
// project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
|
|
10
|
+
// boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
|
|
11
|
+
// per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
|
|
12
|
+
const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
|
|
13
|
+
/** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
|
|
14
|
+
function capAtLine(text, cap) {
|
|
15
|
+
if (text.length <= cap)
|
|
16
|
+
return text;
|
|
17
|
+
const cut = text.slice(0, cap);
|
|
18
|
+
const nl = cut.lastIndexOf("\n");
|
|
19
|
+
return (nl > cap * 0.5 ? cut.slice(0, nl) : cut).trimEnd() + "\n…[truncated — memory_search for the rest]";
|
|
20
|
+
}
|
|
9
21
|
export function memoryDir(scope, cwd) {
|
|
10
22
|
if (scope === "global")
|
|
11
23
|
return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
|
|
@@ -49,7 +61,9 @@ export function forgetMemory(scope, target, match, cwd) {
|
|
|
49
61
|
writeFileSync(f, kept.join("\n"), "utf8");
|
|
50
62
|
return lines.length - kept.length;
|
|
51
63
|
}
|
|
52
|
-
/**
|
|
64
|
+
/** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
|
|
65
|
+
* capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
|
|
66
|
+
* never starves USER prefs) and no entry is cut mid-line. Daily logs are reached via memory_search. */
|
|
53
67
|
export function memoryDigest(cwd) {
|
|
54
68
|
const sources = [
|
|
55
69
|
["project", "memory", "project MEMORY"],
|
|
@@ -64,14 +78,38 @@ export function memoryDigest(cwd) {
|
|
|
64
78
|
try {
|
|
65
79
|
const t = readFileSync(f, "utf8").trim();
|
|
66
80
|
if (t)
|
|
67
|
-
parts.push(`## ${label}\n${t}`);
|
|
81
|
+
parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* skip unreadable */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return parts.join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
/** Concatenate the daily logs (`log/YYYY-MM-DD.md`) from the last `days` for one scope — the short-term
|
|
90
|
+
* tier `hara memory distill` consolidates into evergreen MEMORY. Empty if there's no log dir. */
|
|
91
|
+
export function readRecentLogs(scope, cwd, days) {
|
|
92
|
+
const dir = join(memoryDir(scope, cwd), "log");
|
|
93
|
+
if (!existsSync(dir))
|
|
94
|
+
return "";
|
|
95
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const f of readdirSync(dir).sort()) {
|
|
98
|
+
if (!f.endsWith(".md"))
|
|
99
|
+
continue;
|
|
100
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(f);
|
|
101
|
+
if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
|
|
102
|
+
continue;
|
|
103
|
+
try {
|
|
104
|
+
const t = readFileSync(join(dir, f), "utf8").trim();
|
|
105
|
+
if (t)
|
|
106
|
+
out.push(`### ${f}\n${t}`);
|
|
68
107
|
}
|
|
69
108
|
catch {
|
|
70
109
|
/* skip unreadable */
|
|
71
110
|
}
|
|
72
111
|
}
|
|
73
|
-
|
|
74
|
-
return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
|
|
112
|
+
return out.join("\n\n");
|
|
75
113
|
}
|
|
76
114
|
/** Create memory dirs + seed files (global + project). Returns files written. */
|
|
77
115
|
export function scaffoldMemory(cwd) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Multi-role review chain — hara's "runs like an engineering org" move. After an implementer makes
|
|
2
|
+
// changes, a reviewer role inspects the diff and either APPROVES or requests changes; requested changes
|
|
3
|
+
// feed back to the implementer, looping until approved or a round cap. The orchestration lives in runOrg
|
|
4
|
+
// (it needs runAgent + providers); these are the pure, testable pieces: verdict parsing, change capture,
|
|
5
|
+
// and the prompts. Used by `hara org --review`.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
/** Fallback reviewer persona when the project has no `reviewer` role. Read-only by intent. */
|
|
8
|
+
export const REVIEWER_SYSTEM = `You are a senior code reviewer reviewing changes made to accomplish a task.
|
|
9
|
+
Inspect them for: correctness and bugs, security, missing edge cases, and whether they actually accomplish
|
|
10
|
+
the task. Use read_file / grep / glob / ls to inspect context and any new files. Be concrete and specific —
|
|
11
|
+
cite files. Block only on real problems (bugs, breakage, security), not style preferences.
|
|
12
|
+
|
|
13
|
+
A script parses your final line, so it MUST be EXACTLY one of these two, verbatim, as the LAST line —
|
|
14
|
+
the literal word APPROVED or CHANGES_REQUESTED. Do NOT paraphrase it (not "No issues found", not "LGTM"),
|
|
15
|
+
do NOT bold it, do NOT add words after the token:
|
|
16
|
+
VERDICT: APPROVED
|
|
17
|
+
VERDICT: CHANGES_REQUESTED
|
|
18
|
+
|
|
19
|
+
Use APPROVED only if the changes correctly and safely accomplish the task. If anything must be fixed, use
|
|
20
|
+
CHANGES_REQUESTED and list the required fixes as a short numbered list ABOVE the verdict line — each one
|
|
21
|
+
naming the file and exactly what to change.`;
|
|
22
|
+
// Real models won't reliably emit the literal token — across live runs glm-5 wrote `VERDICT: APPROVED`,
|
|
23
|
+
// `**VERDICT**: No issues found`, and `**VERDICT**: PASS`. So we anchor on the (markdown-tolerant) VERDICT
|
|
24
|
+
// marker, then CLASSIFY the phrase after it: a "changes" signal vetoes (safer), an "approve" signal passes,
|
|
25
|
+
// and anything ambiguous stays NOT approved — worst case is one extra review round, never a bad auto-commit.
|
|
26
|
+
const CHANGES_RE = /\b(changes?[ _-]?request\w*|request\w*[ _-]?changes?|fail(ed|ure)?|reject\w*|block\w*|rework|needs?[ _-]?(work|fix\w*|change\w*)|must[ _-]?(fix|change)|not[ _-]?approv\w*)\b/i;
|
|
27
|
+
const APPROVE_RE = /\b(approv\w*|passe?d?|lgtm|accept\w*|ship[ _-]?it|no[ _-]?(issues?|problems?|changes?|concerns?)|looks?[ _-]?good)\b/i;
|
|
28
|
+
/** Parse a reviewer's reply into a verdict — see the note above for why it's lenient. Takes the LAST
|
|
29
|
+
* VERDICT marker (the final call) and classifies the phrase after it; `issues` is the body before it. */
|
|
30
|
+
export function parseVerdict(text) {
|
|
31
|
+
const markers = [...text.matchAll(/VERDICT\b[*_:\s]*/gi)];
|
|
32
|
+
const last = markers[markers.length - 1];
|
|
33
|
+
if (!last)
|
|
34
|
+
return { approved: false, issues: text.trim() };
|
|
35
|
+
const idx = last.index ?? 0;
|
|
36
|
+
const after = text.slice(idx + last[0].length, idx + last[0].length + 80); // the verdict phrase itself
|
|
37
|
+
const approved = !CHANGES_RE.test(after) && APPROVE_RE.test(after); // changes-signal vetoes; ambiguous = not approved
|
|
38
|
+
return { approved, issues: text.slice(0, idx).trim() };
|
|
39
|
+
}
|
|
40
|
+
/** Capture the working-tree changes vs HEAD (what to review). Non-destructive; empty for a non-git dir
|
|
41
|
+
* or a repo with no commits. New (untracked) files are listed by name — the reviewer can read_file them. */
|
|
42
|
+
export function captureChanges(cwd, cap = 100_000) {
|
|
43
|
+
const git = (args) => {
|
|
44
|
+
try {
|
|
45
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50_000_000 });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
let diff = git(["diff", "HEAD"]).trim();
|
|
52
|
+
if (diff.length > cap)
|
|
53
|
+
diff = diff.slice(0, cap) + "\n…[diff truncated]";
|
|
54
|
+
const newFiles = git(["ls-files", "--others", "--exclude-standard"])
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.slice(0, 50);
|
|
59
|
+
return { diff, newFiles };
|
|
60
|
+
}
|
|
61
|
+
/** True only if the working tree is fully clean — no uncommitted changes. The `--commit` capstone uses
|
|
62
|
+
* this as a guard: `git add -A` + commit is only safe to run when the tree was clean before the org ran,
|
|
63
|
+
* so it captures THIS run's work and never sweeps up pre-existing WIP. False for a non-git dir. */
|
|
64
|
+
export function isTreeClean(cwd) {
|
|
65
|
+
try {
|
|
66
|
+
return execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", maxBuffer: 50_000_000 }).trim() === "";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false; // not a git repo / git error → treat as "not clean" so we never auto-commit blindly
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Strip a leading/trailing markdown code fence a model sometimes wraps a commit message in. */
|
|
73
|
+
export function stripCommitFence(text) {
|
|
74
|
+
return text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
75
|
+
}
|
|
76
|
+
/** The reviewer's input: the task + the changes to review. */
|
|
77
|
+
export function reviewPrompt(task, changes) {
|
|
78
|
+
const parts = [`Task that was implemented:\n${task}`, ""];
|
|
79
|
+
if (changes.diff)
|
|
80
|
+
parts.push("Changes (working tree vs HEAD):\n```diff\n" + changes.diff + "\n```");
|
|
81
|
+
if (changes.newFiles.length)
|
|
82
|
+
parts.push(`New files (use read_file to inspect): ${changes.newFiles.join(", ")}`);
|
|
83
|
+
if (!changes.diff && !changes.newFiles.length)
|
|
84
|
+
parts.push("(no diff captured — inspect the working tree with git / read_file)");
|
|
85
|
+
parts.push("\nReview these changes against the task. Finish with your VERDICT line.");
|
|
86
|
+
return parts.join("\n");
|
|
87
|
+
}
|
|
88
|
+
/** Feed the reviewer's requested changes back to the implementer. */
|
|
89
|
+
export function fixPrompt(issues) {
|
|
90
|
+
return `A code reviewer reviewed your changes and requires these fixes before this can ship:\n\n${issues}\n\nMake these fixes now — edit the files directly; don't just explain.`;
|
|
91
|
+
}
|
package/dist/org/roles.js
CHANGED
|
@@ -49,6 +49,17 @@ function parseFrontmatter(text) {
|
|
|
49
49
|
}
|
|
50
50
|
return { fm, body: m[2].trim() };
|
|
51
51
|
}
|
|
52
|
+
/** Tool filter for a fan-out sub-agent: ALWAYS read-only (sub-agents run full-auto + unconfirmed +
|
|
53
|
+
* parallel), with a role allowed to narrow further but never to grant write/exec. `isReadonly` is the
|
|
54
|
+
* read-kind predicate. This is the guard that keeps the `agent` tool from bypassing the approval gate. */
|
|
55
|
+
export function subagentToolFilter(role, isReadonly) {
|
|
56
|
+
const roleFilter = role?.allowTools
|
|
57
|
+
? (n) => role.allowTools.includes(n)
|
|
58
|
+
: role?.denyTools
|
|
59
|
+
? (n) => !role.denyTools.includes(n)
|
|
60
|
+
: null;
|
|
61
|
+
return (n) => isReadonly(n) && (roleFilter ? roleFilter(n) : true);
|
|
62
|
+
}
|
|
52
63
|
export function loadRoles(cwd) {
|
|
53
64
|
const byId = new Map();
|
|
54
65
|
// lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins, same as memory/config)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// B-end device enrollment (the OSS client side of the fleet/control-plane story). A hara device joins a
|
|
2
|
+
// fleet by exchanging a one-time enrollment code for a scoped, revocable DEVICE TOKEN — it never holds the
|
|
3
|
+
// real provider key (that stays at the gateway). hara then points its OpenAI-compatible calls at the
|
|
4
|
+
// gateway, which validates the token, maps it to an upstream key, and proxies. Heartbeats give the control
|
|
5
|
+
// plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
|
|
6
|
+
//
|
|
7
|
+
// Protocol (what `hara-control` implements on the other end):
|
|
8
|
+
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
|
|
9
|
+
// POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
|
|
10
|
+
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
11
|
+
import { homedir, hostname, platform } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
14
|
+
const orgPath = () => join(homedir(), ".hara", "org.json");
|
|
15
|
+
const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
|
|
16
|
+
/** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
|
|
17
|
+
export function gatewayBaseURL(e) {
|
|
18
|
+
return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
19
|
+
}
|
|
20
|
+
export function loadEnrollment() {
|
|
21
|
+
const p = orgPath();
|
|
22
|
+
if (!existsSync(p))
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const e = JSON.parse(readFileSync(p, "utf8"));
|
|
26
|
+
return e && typeof e === "object" && e.gatewayUrl && e.deviceToken ? e : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveEnrollment(e) {
|
|
33
|
+
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
34
|
+
writeFileSync(orgPath(), JSON.stringify(e, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); // holds a device token → 0600
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(orgPath(), 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* best-effort */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function clearEnrollment() {
|
|
43
|
+
if (!existsSync(orgPath()))
|
|
44
|
+
return false;
|
|
45
|
+
rmSync(orgPath());
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
|
|
49
|
+
export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
50
|
+
const deviceToken = (j.device_token ?? j.deviceToken);
|
|
51
|
+
if (!deviceToken)
|
|
52
|
+
throw new Error("enroll response missing device_token");
|
|
53
|
+
return {
|
|
54
|
+
gatewayUrl: gatewayUrl.replace(/\/$/, ""),
|
|
55
|
+
deviceToken,
|
|
56
|
+
deviceId: String(j.device_id ?? j.deviceId ?? ""),
|
|
57
|
+
model: String(j.model ?? ""),
|
|
58
|
+
baseURL: (j.base_url ?? j.baseURL),
|
|
59
|
+
enrolledAt: now,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
|
|
63
|
+
export async function enrollDevice(gatewayUrl, code, signal) {
|
|
64
|
+
const base = gatewayUrl.replace(/\/$/, "");
|
|
65
|
+
const res = await fetch(`${base}/v1/enroll`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
signal,
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ code, device: deviceInfo() }),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}: ${(await res.text()).slice(0, 200)}`);
|
|
73
|
+
const e = parseEnrollResponse(base, (await res.json()), new Date().toISOString());
|
|
74
|
+
saveEnrollment(e);
|
|
75
|
+
return e;
|
|
76
|
+
}
|
|
77
|
+
/** Best-effort heartbeat so the control plane shows this device online. Never throws. */
|
|
78
|
+
export async function heartbeat(signal) {
|
|
79
|
+
const e = loadEnrollment();
|
|
80
|
+
if (!e)
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(`${e.gatewayUrl}/v1/heartbeat`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
signal,
|
|
86
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${e.deviceToken}` },
|
|
87
|
+
body: JSON.stringify({ device_id: e.deviceId, ...deviceInfo() }),
|
|
88
|
+
});
|
|
89
|
+
return res.ok;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
|
|
7
7
|
const BASE = "https://chat.qwen.ai";
|
|
8
8
|
const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
|
|
9
9
|
const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
|
|
@@ -28,7 +28,14 @@ export function loadQwenToken() {
|
|
|
28
28
|
function saveQwenToken(t) {
|
|
29
29
|
const p = tokenPath();
|
|
30
30
|
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
31
|
-
|
|
31
|
+
// 0600 — the file holds long-lived access + refresh tokens; don't leave it world-readable on shared boxes.
|
|
32
|
+
writeFileSync(p, JSON.stringify(t, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
33
|
+
try {
|
|
34
|
+
chmodSync(p, 0o600); // tighten an existing file that predated the mode
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* best-effort */
|
|
38
|
+
}
|
|
32
39
|
}
|
|
33
40
|
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
41
|
function pkce() {
|
package/dist/recall.js
CHANGED
|
@@ -24,6 +24,20 @@ export function titleOf(text, path) {
|
|
|
24
24
|
const h = /^#\s+(.+)$/m.exec(text);
|
|
25
25
|
return h ? h[1].trim() : (path.split("/").pop() ?? path);
|
|
26
26
|
}
|
|
27
|
+
/** A ranking boost from the asset's declared dimensions: a query word in the title or the frontmatter
|
|
28
|
+
* tags/lang matters more than one buried in the body. Used to order results, NOT the base relevance
|
|
29
|
+
* score (which the dedup threshold relies on). */
|
|
30
|
+
export function metaBoost(text, title, words) {
|
|
31
|
+
const titleL = title.toLowerCase();
|
|
32
|
+
let b = words.filter((w) => titleL.includes(w)).length * 3;
|
|
33
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
34
|
+
if (fm) {
|
|
35
|
+
const tags = (/(?:^|\n)tags:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
|
|
36
|
+
const lang = (/(?:^|\n)lang:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
|
|
37
|
+
b += words.filter((w) => `${tags} ${lang}`.includes(w)).length * 2;
|
|
38
|
+
}
|
|
39
|
+
return b;
|
|
40
|
+
}
|
|
27
41
|
/**
|
|
28
42
|
* Lexical search: rank .md files by how many query words appear in path+content.
|
|
29
43
|
* Default searches the code-asset library (relative paths). Pass `roots` to search other dirs
|
|
@@ -48,14 +62,16 @@ export function searchAssets(query, limit = 5, roots) {
|
|
|
48
62
|
continue;
|
|
49
63
|
}
|
|
50
64
|
const hay = (rel + "\n" + text).toLowerCase();
|
|
51
|
-
const score = words.filter((w) => hay.includes(w)).length;
|
|
65
|
+
const score = words.filter((w) => hay.includes(w)).length; // distinct query words present (dedup threshold uses this)
|
|
52
66
|
if (!score)
|
|
53
67
|
continue;
|
|
54
|
-
|
|
68
|
+
const title = titleOf(text, rel);
|
|
69
|
+
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text.slice(0, 800), score, boost: metaBoost(text, title, words) });
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
// rank by relevance, then by the declared-dimension boost (title/tags/lang), then prefer the shorter path
|
|
73
|
+
hits.sort((a, b) => b.score - a.score || b.boost - a.boost || a.path.length - b.path.length);
|
|
74
|
+
return hits.slice(0, limit).map(({ boost, ...r }) => r);
|
|
59
75
|
}
|
|
60
76
|
/** Create the assets dir with an example snippet + README. Returns files written. */
|
|
61
77
|
export function scaffoldAssets() {
|
package/dist/sandbox.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// OS sandboxing for the bash tool
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
1
|
+
// OS sandboxing for the bash tool — WRITE CONFINEMENT, not a security jail. macOS = Seatbelt
|
|
2
|
+
// (sandbox-exec) restricting `file-write*` to the workspace (workspace-write) or to nothing outside
|
|
3
|
+
// temp (read-only). Reads, network, and process exec are NOT restricted, and /private/tmp +
|
|
4
|
+
// /private/var/folders stay writable in every mode — so this stops a stray `rm`/overwrite escaping the
|
|
5
|
+
// project, NOT a determined exfiltration. Other platforms run UNSANDBOXED (a one-time warning is
|
|
6
|
+
// emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
|
|
7
|
+
// shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
|
|
4
8
|
import { spawn } from "node:child_process";
|
|
5
9
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
6
10
|
import { tmpdir, platform } from "node:os";
|
|
@@ -23,6 +27,7 @@ function seatbeltProfile(cwd, mode) {
|
|
|
23
27
|
export function sandboxSupported() {
|
|
24
28
|
return platform() === "darwin";
|
|
25
29
|
}
|
|
30
|
+
let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per process
|
|
26
31
|
/**
|
|
27
32
|
* Run a shell command, sandboxed when mode != off and the platform supports it.
|
|
28
33
|
* Streams output via `opts.onData` while capturing it for the resolved value.
|
|
@@ -39,6 +44,10 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
39
44
|
args = ["-f", profileFile, "/bin/bash", "-lc", command];
|
|
40
45
|
}
|
|
41
46
|
else {
|
|
47
|
+
if (mode !== "off" && !warnedUnsandboxed) {
|
|
48
|
+
warnedUnsandboxed = true;
|
|
49
|
+
process.stderr.write(`hara: --sandbox ${mode} is macOS-only — the shell runs UNSANDBOXED on ${platform()}.\n`);
|
|
50
|
+
}
|
|
42
51
|
cmd = "/bin/sh";
|
|
43
52
|
args = ["-c", command];
|
|
44
53
|
}
|
|
@@ -47,20 +56,31 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
47
56
|
let stdout = "";
|
|
48
57
|
let stderr = "";
|
|
49
58
|
let timedOut = false;
|
|
59
|
+
let killedForSize = false;
|
|
50
60
|
const grow = (cur, add) => (cur.length < opts.maxBuffer ? cur + add : cur);
|
|
51
61
|
const timer = setTimeout(() => {
|
|
52
62
|
timedOut = true;
|
|
53
63
|
child.kill("SIGKILL");
|
|
54
64
|
}, opts.timeout);
|
|
65
|
+
// Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
|
|
66
|
+
// until the timeout just because we stopped retaining the bytes.
|
|
67
|
+
const checkOverflow = () => {
|
|
68
|
+
if (!killedForSize && stdout.length + stderr.length >= opts.maxBuffer) {
|
|
69
|
+
killedForSize = true;
|
|
70
|
+
child.kill("SIGKILL");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
55
73
|
child.stdout.on("data", (d) => {
|
|
56
74
|
const s = d.toString();
|
|
57
75
|
stdout = grow(stdout, s);
|
|
58
76
|
opts.onData?.(s);
|
|
77
|
+
checkOverflow();
|
|
59
78
|
});
|
|
60
79
|
child.stderr.on("data", (d) => {
|
|
61
80
|
const s = d.toString();
|
|
62
81
|
stderr = grow(stderr, s);
|
|
63
82
|
opts.onData?.(s);
|
|
83
|
+
checkOverflow();
|
|
64
84
|
});
|
|
65
85
|
child.on("error", (e) => {
|
|
66
86
|
clearTimeout(timer);
|
|
@@ -68,6 +88,8 @@ export function runShell(command, cwd, mode, opts) {
|
|
|
68
88
|
});
|
|
69
89
|
child.on("close", (code) => {
|
|
70
90
|
clearTimeout(timer);
|
|
91
|
+
if (killedForSize)
|
|
92
|
+
return resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process killed]` });
|
|
71
93
|
if (timedOut)
|
|
72
94
|
return reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
|
|
73
95
|
if (code !== 0)
|
package/dist/search/semindex.js
CHANGED
|
@@ -121,6 +121,13 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
|
121
121
|
export function indexExists(name, cwd) {
|
|
122
122
|
return existsSync(indexPath(name, cwd));
|
|
123
123
|
}
|
|
124
|
+
// Never embed (and POST to an embedding provider, then persist in the index) a secret-bearing file —
|
|
125
|
+
// the asset/skill/memory dirs aren't .gitignore-filtered, so a stray credentials.json/secrets.yaml/.env
|
|
126
|
+
// there would otherwise leak. Defense-in-depth (the repo walk already respects .gitignore).
|
|
127
|
+
const SECRET_FILE = /(^\.?env(\.|$)|secret|credential|password|apikey|api[_-]?key|\btoken|\.(pem|key|p12|pfx|keystore|crt)$|^\.netrc$|^\.npmrc$|id_(rsa|ed25519|ecdsa))/i;
|
|
128
|
+
function looksSecret(rel) {
|
|
129
|
+
return SECRET_FILE.test(rel.split("/").pop() ?? rel);
|
|
130
|
+
}
|
|
124
131
|
/** Walk one knowledge directory (code-assets / skills / memory) and chunk its files. Files come back as
|
|
125
132
|
* absolute paths so recall/memory_search can read or open them directly. */
|
|
126
133
|
export function collectDirChunks(dir, source) {
|
|
@@ -128,7 +135,7 @@ export function collectDirChunks(dir, source) {
|
|
|
128
135
|
return [];
|
|
129
136
|
const chunks = [];
|
|
130
137
|
for (const rel of walkFiles(dir)) {
|
|
131
|
-
if (!CODE_RE.test(rel))
|
|
138
|
+
if (!CODE_RE.test(rel) || looksSecret(rel))
|
|
132
139
|
continue;
|
|
133
140
|
const abs = join(dir, rel);
|
|
134
141
|
if (fileSize(abs) > 200_000)
|
|
@@ -150,7 +157,7 @@ export function collectDirChunks(dir, source) {
|
|
|
150
157
|
export function collectRepoChunks(root) {
|
|
151
158
|
const chunks = [];
|
|
152
159
|
for (const rel of listProjectFiles(root)) {
|
|
153
|
-
if (!CODE_RE.test(rel))
|
|
160
|
+
if (!CODE_RE.test(rel) || looksSecret(rel))
|
|
154
161
|
continue;
|
|
155
162
|
const abs = join(root, rel);
|
|
156
163
|
if (fileSize(abs) > 200_000)
|
package/dist/session/store.js
CHANGED
|
@@ -42,6 +42,8 @@ export function cleanSessionName(raw) {
|
|
|
42
42
|
* (unlike the ASCII-slug `cleanSessionName`), trims code/whitespace, caps length. Empty for blank input
|
|
43
43
|
* (callers fall back to the short id, never "new session"). */
|
|
44
44
|
export function deriveTitle(text) {
|
|
45
|
+
if (typeof text !== "string")
|
|
46
|
+
return ""; // a malformed/hand-edited session may have a non-string content
|
|
45
47
|
const t = text
|
|
46
48
|
.replace(/^\/\S+\s*/, "") // drop a leading slash-command
|
|
47
49
|
.replace(/```[\s\S]*?```/g, " ") // drop fenced code blocks
|
|
@@ -75,12 +77,18 @@ export function saveSession(meta, history) {
|
|
|
75
77
|
const data = { meta, history };
|
|
76
78
|
writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
|
|
77
79
|
}
|
|
80
|
+
/** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
|
|
81
|
+
function isSessionData(d) {
|
|
82
|
+
const o = d;
|
|
83
|
+
return !!o && typeof o === "object" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
|
|
84
|
+
}
|
|
78
85
|
export function loadSession(id) {
|
|
79
86
|
const p = sessionFile(id);
|
|
80
87
|
if (!existsSync(p))
|
|
81
88
|
return null;
|
|
82
89
|
try {
|
|
83
|
-
|
|
90
|
+
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
91
|
+
return isSessionData(d) ? d : null; // a corrupt / hand-edited file resumes as "no session" instead of crashing
|
|
84
92
|
}
|
|
85
93
|
catch {
|
|
86
94
|
return null;
|
|
@@ -93,7 +101,9 @@ export function listSessions(cwd) {
|
|
|
93
101
|
if (!f.endsWith(".json"))
|
|
94
102
|
continue;
|
|
95
103
|
try {
|
|
96
|
-
|
|
104
|
+
const d = JSON.parse(readFileSync(join(sessionsDir(), f), "utf8"));
|
|
105
|
+
if (d?.meta && typeof d.meta === "object" && d.meta.id && d.meta.updatedAt)
|
|
106
|
+
metas.push(d.meta); // skip metaless/corrupt
|
|
97
107
|
}
|
|
98
108
|
catch {
|
|
99
109
|
/* skip corrupt */
|
package/dist/tools/computer.js
CHANGED
|
@@ -12,15 +12,20 @@ import { registerTool } from "./registry.js";
|
|
|
12
12
|
import { loadConfig } from "../config.js";
|
|
13
13
|
const RANK = { off: 0, read: 1, click: 2, full: 3 };
|
|
14
14
|
const ACTION_MIN = { screenshot: "read", find: "read", activate: "click", move: "click", click: "click", type: "full", key: "full" };
|
|
15
|
-
// dangerous combos refused even at full tier (quit / close / delete / task-switch-kill)
|
|
15
|
+
// dangerous combos refused even at full tier (quit / close / delete / task-switch-kill).
|
|
16
16
|
const KEY_BLOCK = /(?:\b(cmd|command|ctrl|control|alt|option|win|super|meta)\b.*\+.*\b(q|w|delete|del|f4|escape|esc)\b)|ctrl\+alt\+(?:delete|del|backspace)/i;
|
|
17
|
+
// Windows SendKeys spells modifiers as % (Alt) / ^ (Ctrl) with no modifier WORD, so the combo regex
|
|
18
|
+
// above misses them: block Alt+F4 / Ctrl+F4 (close window) and Ctrl+W (close tab) in that syntax.
|
|
19
|
+
const KEY_BLOCK_SENDKEYS = /[%^]\s*\{\s*f4\s*\}|\^\s*w\b/i;
|
|
20
|
+
// Linux/X keysyms for logout / power-off (xdotool key XF86LogOff …) — not modifier combos.
|
|
21
|
+
const KEY_BLOCK_KEYSYM = /\bxf86(logoff|poweroff|reboot|sleep)\b/i;
|
|
17
22
|
/** Whether the configured tier permits the action. Exported for tests. */
|
|
18
23
|
export function actionAllowed(tier, action) {
|
|
19
24
|
return RANK[tier] >= RANK[ACTION_MIN[action] ?? "full"];
|
|
20
25
|
}
|
|
21
26
|
/** Whether a key combo is on the dangerous blocklist. Exported for tests. */
|
|
22
27
|
export function keyIsBlocked(keys) {
|
|
23
|
-
return KEY_BLOCK.test(keys);
|
|
28
|
+
return KEY_BLOCK.test(keys) || KEY_BLOCK_SENDKEYS.test(keys) || KEY_BLOCK_KEYSYM.test(keys);
|
|
24
29
|
}
|
|
25
30
|
// Circuit breaker (learned from codex): bound consecutive screen-control failures so the agent can't loop
|
|
26
31
|
// forever on a broken setup. Reset on any success; after FAIL_LIMIT in a row, return a clear stop + how to fix.
|
|
@@ -315,7 +320,7 @@ registerTool({
|
|
|
315
320
|
const app = String(input.app ?? input.target ?? "");
|
|
316
321
|
if (!app)
|
|
317
322
|
return "activate needs an `app` name (e.g. 'WeChat').";
|
|
318
|
-
if (!cfg.computerApps.some((a) =>
|
|
323
|
+
if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
|
|
319
324
|
return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
|
|
320
325
|
const r = activateApp(app);
|
|
321
326
|
return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
|
|
@@ -325,7 +330,7 @@ registerTool({
|
|
|
325
330
|
if (!cfg.computerApps.length)
|
|
326
331
|
return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
|
|
327
332
|
const app = frontmostApp();
|
|
328
|
-
const allowed = cfg.computerApps.some((a) =>
|
|
333
|
+
const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
|
|
329
334
|
if (!allowed)
|
|
330
335
|
return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
|
|
331
336
|
}
|