@nanhara/hara 0.128.0 → 0.129.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 +26 -0
- package/README.md +7 -5
- package/dist/agent/loop.js +6 -6
- package/dist/agent/repeat-guard.js +33 -3
- package/dist/artifacts/store.js +292 -30
- package/dist/context/workspace-scope.js +23 -0
- package/dist/gateway/feishu.js +131 -30
- package/dist/index.js +61 -1
- package/dist/plugins/plugins.js +71 -2
- package/dist/security/private-state.js +19 -7
- package/dist/serve/protocol.js +5 -0
- package/dist/serve/server.js +70 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.129.0 — 2026-07-20 — durable revisions and workspace recovery
|
|
9
|
+
|
|
10
|
+
- Authenticated Serve clients can commit a user-selected file as a new immutable Artifact revision with
|
|
11
|
+
`artifact.commit`. Every write carries `baseRevisionId`; a stale editor receives the dedicated protocol
|
|
12
|
+
conflict code and cannot replace the current revision.
|
|
13
|
+
- `artifact.revert` restores an earlier ancestor by creating another immutable revision rather than moving
|
|
14
|
+
the pointer backward or mutating history. The imported original and every prior revision remain unchanged.
|
|
15
|
+
- Revision pointer updates now use a private-state compare-and-swap guard that also closes a cross-process
|
|
16
|
+
race. The authenticated host assigns the user actor, so a Desktop client cannot forge an Agent/migration
|
|
17
|
+
audit identity.
|
|
18
|
+
- This slice does not yet export files or claim Office fidelity. ExportReceipt, validation reports, worker
|
|
19
|
+
identity, format-specific editing, and Panel v2 remain separate release gates.
|
|
20
|
+
- Interactive launches from the home directory now offer an explicit, one-step switch to a recent or
|
|
21
|
+
registered project before provider and MCP startup. Hara never enumerates the home directory and never
|
|
22
|
+
changes workspace scope without confirmation.
|
|
23
|
+
- Repeated protected-home failures are now grouped by semantic root cause across `ls`, `grep`, `glob`, and
|
|
24
|
+
other filesystem tools. A run stops after the bounded retry threshold with a focused recovery instruction
|
|
25
|
+
instead of trying different tools against the same boundary indefinitely.
|
|
26
|
+
- Git plugin installation now rejects credential-bearing source URLs, bounds clone time, and safely
|
|
27
|
+
distinguishes missing Git, network, authentication/access, and private/not-found repository failures
|
|
28
|
+
without echoing URLs, tokens, or Git stderr.
|
|
29
|
+
- Feishu WebSocket gateways now report connected, disconnected, and reconnected lifecycle events with
|
|
30
|
+
one-hour and 24-hour counters. Repeated disconnects raise rate-limited alerts, while terminal SDK recovery
|
|
31
|
+
failures stop the adapter after a safe drain so a process supervisor can restart it.
|
|
32
|
+
- Upgrade with `npm i -g @nanhara/hara@0.129.0`.
|
|
33
|
+
|
|
8
34
|
## 0.128.0 — 2026-07-20 — local deliverable foundation
|
|
9
35
|
|
|
10
36
|
- Hara Serve now provides the first `artifact/1` local runtime through authenticated
|
package/README.md
CHANGED
|
@@ -274,11 +274,13 @@ new execution. The `hara resume` launcher preserves terminal input in both npm/N
|
|
|
274
274
|
installs.
|
|
275
275
|
**Local deliverables**: Hara Serve 0.128 adds the first local `artifact/1` foundation for presentations,
|
|
276
276
|
spreadsheets, and documents. An authenticated Desktop client can import, list, integrity-check, and inspect
|
|
277
|
-
version history through `artifact.import|list|get|revisions`.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
version history through `artifact.import|list|get|revisions`. The next revision transaction slice also
|
|
278
|
+
advertises `artifact.commit|revert`: every edit supplies the version it started from, a stale edit fails with
|
|
279
|
+
an explicit conflict, and reverting creates a new immutable revision instead of rewriting history. Import and
|
|
280
|
+
commit make owner-only copies under `~/.hara/artifacts`, never change the selected source file, and never store
|
|
281
|
+
its absolute path. Relative, linked, protected, macro-enabled, type-confused, empty, and oversized inputs fail
|
|
282
|
+
closed. The runtime does **not** yet render, format-edit, export, or execute imported content; those operations
|
|
283
|
+
require matching reviewed Office capabilities and validation/export receipts.
|
|
282
284
|
**MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch). Hara starts with every configured server stopped and exposes only `mcp_connect`; when the current task first needs one server, the agent requests permission to connect that server and its tools appear on the next round as `mcp__<server>__<tool>`. Unrelated servers remain stopped. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive external-tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
|
|
283
285
|
**Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
|
|
284
286
|
**Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/WeChat/webhook) for outcomes. `--deliver-mode always` preserves the existing every-run heartbeat, `on-output` sends only when redacted stdout is non-empty, and `on-error` sends only failed runs; `--alert-after N` remains independent and still raises the consecutive-failure 🚨 threshold alarm (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
|
package/dist/agent/loop.js
CHANGED
|
@@ -9,7 +9,7 @@ import { runHooks } from "../hooks.js";
|
|
|
9
9
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
10
10
|
import { decideCommand, isReadOnlyCommand, loadPermissionRules, splitCompound, } from "../security/permissions.js";
|
|
11
11
|
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
12
|
-
import {
|
|
12
|
+
import { failureIdentity, looksFailed, recordCall } from "./repeat-guard.js";
|
|
13
13
|
import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
|
|
14
14
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
15
15
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
@@ -405,7 +405,7 @@ function hardStop(opts, life, kind, detail) {
|
|
|
405
405
|
? `⏸ agent run paused: active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). Waiting for your answers did not consume this budget. No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
|
|
406
406
|
: kind === "max_rounds"
|
|
407
407
|
? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
|
|
408
|
-
: `⛔ agent run stopped: the same failing ${detail?.
|
|
408
|
+
: `⛔ agent run stopped: the same failing ${detail?.label ?? "tool call"} repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
|
|
409
409
|
const event = { kind, message, elapsedMs, rounds: life.rounds, timeoutMs: life.timeoutMs, maxRounds: life.maxRounds };
|
|
410
410
|
if (!life.limitAnnounced) {
|
|
411
411
|
life.limitAnnounced = true;
|
|
@@ -944,16 +944,16 @@ async function runAgentInner(history, opts, life) {
|
|
|
944
944
|
let repeatHalt = null;
|
|
945
945
|
const noteCall = (name, input, content, isError = false) => {
|
|
946
946
|
const note = recordCall(name, input, content, isError, ctx.todoScope);
|
|
947
|
-
const
|
|
947
|
+
const identity = failureIdentity(name, input, content, isError);
|
|
948
948
|
if (isError || looksFailed(content, name)) {
|
|
949
|
-
const count = (life.failedCalls.get(key) ?? 0) + 1;
|
|
949
|
+
const count = (life.failedCalls.get(identity.key) ?? 0) + 1;
|
|
950
950
|
// This is a *consecutive no-progress* streak, not a lifetime counter for the call. A different
|
|
951
951
|
// failure is a changed attempt; keep only that new streak instead of letting an old failure
|
|
952
952
|
// silently accumulate across intervening work.
|
|
953
953
|
life.failedCalls.clear();
|
|
954
|
-
life.failedCalls.set(key, count);
|
|
954
|
+
life.failedCalls.set(identity.key, count);
|
|
955
955
|
if (count >= REPEATED_FAILURE_LIMIT && !repeatHalt)
|
|
956
|
-
repeatHalt = {
|
|
956
|
+
repeatHalt = { label: identity.label, count };
|
|
957
957
|
}
|
|
958
958
|
else {
|
|
959
959
|
// Any successful action is progress (in particular edit/exec calls that may have fixed the
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
|
|
9
9
|
const DEFAULT_SCOPE = "default";
|
|
10
10
|
const seenByScope = new Map();
|
|
11
|
+
const HOME_WORKSPACE_BOUNDARY_KEY = "root-cause:home-workspace-boundary";
|
|
11
12
|
function scopedSeen(scope) {
|
|
12
13
|
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
13
14
|
const seen = seenByScope.get(key) ?? new Map();
|
|
@@ -68,23 +69,52 @@ export function looksFailed(content, name) {
|
|
|
68
69
|
}
|
|
69
70
|
return false;
|
|
70
71
|
}
|
|
72
|
+
/** Several different filesystem tools can hit the same protected Home-workspace boundary. Treating each
|
|
73
|
+
* tool name/argument tuple as a fresh idea lets a model spin through grep → glob → ls even though the
|
|
74
|
+
* root cause cannot change inside that run. Only Hara's own stable boundary diagnostics are coalesced. */
|
|
75
|
+
function isHomeWorkspaceBoundaryFailure(content) {
|
|
76
|
+
return (/will not recursively scan the home directory\b/i.test(content)
|
|
77
|
+
|| /will not enumerate or recursively scan directories while Hara is rooted at the home directory\b/i.test(content)
|
|
78
|
+
|| /workspace that is the home directory or contains it\b/i.test(content));
|
|
79
|
+
}
|
|
80
|
+
/** Stable identity used by both the warning note and the run-level hard breaker. */
|
|
81
|
+
export function failureIdentity(name, input, content, isError = false) {
|
|
82
|
+
const failed = isError || looksFailed(content, name);
|
|
83
|
+
if (failed && isHomeWorkspaceBoundaryFailure(content)) {
|
|
84
|
+
return {
|
|
85
|
+
key: HOME_WORKSPACE_BOUNDARY_KEY,
|
|
86
|
+
label: "Home workspace boundary",
|
|
87
|
+
semantic: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
key: keyOf(name, input),
|
|
92
|
+
label: `${name} call`,
|
|
93
|
+
semantic: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
71
96
|
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
72
97
|
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
73
98
|
export function recordCall(name, input, content, isError = false, scope) {
|
|
74
|
-
const k = keyOf(name, input);
|
|
75
99
|
const failed = isError || looksFailed(content, name);
|
|
100
|
+
const identity = failureIdentity(name, input, content, isError);
|
|
76
101
|
const seen = scopedSeen(scope);
|
|
77
102
|
if (!failed) {
|
|
78
103
|
seen.clear(); // any success is progress; a later failure starts a fresh no-progress streak
|
|
79
104
|
return "";
|
|
80
105
|
}
|
|
81
|
-
const s = seen.get(
|
|
106
|
+
const s = seen.get(identity.key) ?? { fails: 0 };
|
|
82
107
|
s.fails++;
|
|
83
108
|
// "In a row" is literal: a different failed call is a changed attempt and breaks the old streak.
|
|
84
109
|
seen.clear();
|
|
85
|
-
seen.set(
|
|
110
|
+
seen.set(identity.key, s);
|
|
86
111
|
if (s.fails < 2)
|
|
87
112
|
return "";
|
|
113
|
+
if (identity.semantic) {
|
|
114
|
+
return (`\n\n⟳ hara: the same ${identity.label} has now blocked ${s.fails} consecutive tool calls — ` +
|
|
115
|
+
"another filesystem/search tool cannot bypass it. Ask the user to switch with `/cd <project>` " +
|
|
116
|
+
"or stop this run; do not probe another directory tool from Home.");
|
|
117
|
+
}
|
|
88
118
|
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
|
89
119
|
`repeating it unchanged will fail again. Read the error above, change something (arguments / approach / tool), ` +
|
|
90
120
|
`or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
|
package/dist/artifacts/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
|
4
4
|
import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
5
5
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
6
6
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
7
|
-
import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
7
|
+
import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, PrivateStateConflictError, readPrivateStateFileSnapshotSync, writePrivateStateBytesOnceSync, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
8
8
|
export const ARTIFACT_PROTOCOL_VERSION = "artifact/1";
|
|
9
9
|
export const MAX_ARTIFACT_IMPORT_BYTES = 64 * 1024 * 1024;
|
|
10
10
|
const MAX_ARTIFACTS = 10_000;
|
|
@@ -169,6 +169,51 @@ function checkedRevisionId(value) {
|
|
|
169
169
|
}
|
|
170
170
|
return value;
|
|
171
171
|
}
|
|
172
|
+
function checkedInputRevisionId(value, field) {
|
|
173
|
+
if (typeof value !== "string" || !REVISION_ID.test(value)) {
|
|
174
|
+
throw storeError("ARTIFACT_INVALID_INPUT", `${field} is not a valid opaque Revision id`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function checkedActor(value) {
|
|
179
|
+
if (value === undefined)
|
|
180
|
+
return "user";
|
|
181
|
+
if (value !== "user" && value !== "agent" && value !== "migration") {
|
|
182
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "actor must be user, agent, or migration");
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function checkedTaskRunId(value) {
|
|
187
|
+
if (value === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
if (typeof value !== "string"
|
|
190
|
+
|| value.length < 1
|
|
191
|
+
|| value.length > 256
|
|
192
|
+
|| /[\u0000-\u001f\u007f]/.test(value))
|
|
193
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "taskRunId must be a safe string of at most 256 characters");
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
function normalizeArtifactPath(value) {
|
|
197
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 1_024 || value.includes("\0")) {
|
|
198
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must be non-empty relative paths");
|
|
199
|
+
}
|
|
200
|
+
if (value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
|
|
201
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths entries must use relative forward-slash paths");
|
|
202
|
+
}
|
|
203
|
+
const segments = value.split("/");
|
|
204
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
205
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths contains an unsafe path segment");
|
|
206
|
+
}
|
|
207
|
+
return segments.join("/");
|
|
208
|
+
}
|
|
209
|
+
function checkedChangedPaths(value) {
|
|
210
|
+
if (value === undefined)
|
|
211
|
+
return ["content"];
|
|
212
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 10_000) {
|
|
213
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "changedPaths must contain 1 to 10000 relative paths");
|
|
214
|
+
}
|
|
215
|
+
return [...new Set(value.map(normalizeArtifactPath))];
|
|
216
|
+
}
|
|
172
217
|
function childDirectory(parent, name, notFoundMessage) {
|
|
173
218
|
verifyDirectory(parent);
|
|
174
219
|
const path = join(parent.path, name);
|
|
@@ -191,6 +236,24 @@ function childDirectory(parent, name, notFoundMessage) {
|
|
|
191
236
|
function artifactDirectory(home, artifactId) {
|
|
192
237
|
return childDirectory(artifactRoot(home), checkedArtifactId(artifactId), `no artifact ${artifactId}`);
|
|
193
238
|
}
|
|
239
|
+
function readArtifactMetadata(directory) {
|
|
240
|
+
verifyDirectory(directory);
|
|
241
|
+
const snapshot = readPrivateStateFileSnapshotSync(join(directory.path, "metadata.json"), JSON_LIMIT);
|
|
242
|
+
if (!snapshot)
|
|
243
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata is missing");
|
|
244
|
+
let value;
|
|
245
|
+
try {
|
|
246
|
+
value = JSON.parse(snapshot.text);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata is not valid JSON", error);
|
|
250
|
+
}
|
|
251
|
+
if (!isArtifactRecord(value) || value.artifactId !== basename(directory.path)) {
|
|
252
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact metadata does not match its directory");
|
|
253
|
+
}
|
|
254
|
+
verifyDirectory(directory);
|
|
255
|
+
return { artifact: value, text: snapshot.text };
|
|
256
|
+
}
|
|
194
257
|
function revisionDirectory(artifact, revisionId) {
|
|
195
258
|
const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
|
|
196
259
|
return childDirectory(revisions, checkedRevisionId(revisionId), `no revision ${revisionId}`);
|
|
@@ -240,25 +303,67 @@ function inspectContent(revision, content, verifyDigest) {
|
|
|
240
303
|
}
|
|
241
304
|
verifyDirectory(revision);
|
|
242
305
|
}
|
|
243
|
-
function
|
|
244
|
-
verifyDirectory(
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
306
|
+
function readContentBytes(revision, content) {
|
|
307
|
+
verifyDirectory(revision);
|
|
308
|
+
const path = join(revision.path, content.contentRef);
|
|
309
|
+
const before = lstatSync(path);
|
|
310
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
311
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content is not an immutable regular file");
|
|
248
312
|
}
|
|
249
|
-
const
|
|
250
|
-
|
|
313
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
314
|
+
try {
|
|
315
|
+
const opened = fstatSync(fd);
|
|
316
|
+
if (!opened.isFile()
|
|
317
|
+
|| opened.nlink !== 1
|
|
318
|
+
|| !sameOpenedFileIdentity(before, opened)
|
|
319
|
+
|| opened.size !== content.byteSize
|
|
320
|
+
|| (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600))
|
|
321
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content identity or size does not match its receipt");
|
|
322
|
+
const bytes = Buffer.allocUnsafe(content.byteSize);
|
|
323
|
+
let position = 0;
|
|
324
|
+
while (position < bytes.length) {
|
|
325
|
+
const read = readSync(fd, bytes, position, bytes.length - position, position);
|
|
326
|
+
if (!read)
|
|
327
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content ended before its recorded size");
|
|
328
|
+
position += read;
|
|
329
|
+
}
|
|
330
|
+
if (createHash("sha256").update(bytes).digest("hex") !== content.sha256) {
|
|
331
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content digest does not match its receipt");
|
|
332
|
+
}
|
|
333
|
+
const after = fstatSync(fd);
|
|
334
|
+
const linked = lstatSync(path);
|
|
335
|
+
if (!sameOpenedFileIdentity(opened, after)
|
|
336
|
+
|| opened.size !== after.size
|
|
337
|
+
|| opened.mtimeMs !== after.mtimeMs
|
|
338
|
+
|| opened.ctimeMs !== after.ctimeMs
|
|
339
|
+
|| !sameOpenedFileIdentity(after, linked))
|
|
340
|
+
throw storeError("ARTIFACT_CORRUPT", "artifact content changed while it was read");
|
|
341
|
+
verifyDirectory(revision);
|
|
342
|
+
return bytes;
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
closeSync(fd);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function readRevisionDetails(artifact, revisionId, verifyDigest) {
|
|
349
|
+
const revisionDir = revisionDirectory(artifact, revisionId);
|
|
251
350
|
const revisionValue = parseJsonFile(join(revisionDir.path, "revision.json"));
|
|
252
351
|
const contentValue = parseJsonFile(join(revisionDir.path, "content.json"));
|
|
253
352
|
if (!isRevision(revisionValue)
|
|
254
|
-
|| revisionValue.revisionId !==
|
|
255
|
-
|| revisionValue.artifactId !==
|
|
256
|
-
throw storeError("ARTIFACT_CORRUPT", "
|
|
353
|
+
|| revisionValue.revisionId !== revisionId
|
|
354
|
+
|| revisionValue.artifactId !== basename(artifact.path))
|
|
355
|
+
throw storeError("ARTIFACT_CORRUPT", "revision metadata does not match its Artifact");
|
|
257
356
|
if (!isContentInfo(contentValue)
|
|
258
357
|
|| contentValue.contentRef !== revisionValue.contentRef
|
|
259
358
|
|| contentValue.sha256 !== revisionValue.contentDigest)
|
|
260
|
-
throw storeError("ARTIFACT_CORRUPT", "
|
|
359
|
+
throw storeError("ARTIFACT_CORRUPT", "revision content receipt is inconsistent");
|
|
261
360
|
inspectContent(revisionDir, contentValue, verifyDigest);
|
|
361
|
+
return { revision: revisionValue, content: contentValue };
|
|
362
|
+
}
|
|
363
|
+
function readArtifactDetailsFromDirectory(directory, verifyDigest) {
|
|
364
|
+
const { artifact: artifactValue } = readArtifactMetadata(directory);
|
|
365
|
+
const currentRevisionId = checkedRevisionId(artifactValue.currentRevisionId);
|
|
366
|
+
const { revision: revisionValue, content: contentValue } = readRevisionDetails(directory, currentRevisionId, verifyDigest);
|
|
262
367
|
verifyDirectory(directory);
|
|
263
368
|
return { artifact: artifactValue, currentRevision: revisionValue, content: contentValue };
|
|
264
369
|
}
|
|
@@ -275,6 +380,29 @@ function titleFor(sourcePath, extension, requested, kind) {
|
|
|
275
380
|
}
|
|
276
381
|
return title;
|
|
277
382
|
}
|
|
383
|
+
function sourceFormat(sourcePathInput, claimedKind) {
|
|
384
|
+
if (typeof sourcePathInput !== "string"
|
|
385
|
+
|| !sourcePathInput
|
|
386
|
+
|| sourcePathInput.length > 4_096) {
|
|
387
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
|
|
388
|
+
}
|
|
389
|
+
if (!isAbsolute(sourcePathInput)) {
|
|
390
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
|
|
391
|
+
}
|
|
392
|
+
const sourcePath = resolve(sourcePathInput);
|
|
393
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
394
|
+
if (MACRO_FORMATS.has(extension)) {
|
|
395
|
+
throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before using it as an Artifact revision");
|
|
396
|
+
}
|
|
397
|
+
const format = FORMATS.get(extension);
|
|
398
|
+
if (!format) {
|
|
399
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
|
|
400
|
+
}
|
|
401
|
+
if (claimedKind !== undefined && claimedKind !== format.kind) {
|
|
402
|
+
throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${claimedKind}`);
|
|
403
|
+
}
|
|
404
|
+
return { sourcePath, extension, format };
|
|
405
|
+
}
|
|
278
406
|
async function readImportSource(sourcePath) {
|
|
279
407
|
let verified;
|
|
280
408
|
try {
|
|
@@ -390,24 +518,6 @@ function activateStaging(root, staging, artifactId) {
|
|
|
390
518
|
syncDirectory(root.path);
|
|
391
519
|
}
|
|
392
520
|
export async function importArtifact(home, input) {
|
|
393
|
-
if (typeof input.sourcePath !== "string" || !input.sourcePath || input.sourcePath.length > 4_096) {
|
|
394
|
-
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be a non-empty path of at most 4096 characters");
|
|
395
|
-
}
|
|
396
|
-
if (!isAbsolute(input.sourcePath)) {
|
|
397
|
-
throw storeError("ARTIFACT_INVALID_INPUT", "sourcePath must be an absolute path selected by the user");
|
|
398
|
-
}
|
|
399
|
-
const sourcePath = resolve(input.sourcePath);
|
|
400
|
-
const extension = extname(sourcePath).toLowerCase();
|
|
401
|
-
if (MACRO_FORMATS.has(extension)) {
|
|
402
|
-
throw storeError("ARTIFACT_SOURCE_REJECTED", "macro-enabled Office files are not supported; save a macro-free copy before importing");
|
|
403
|
-
}
|
|
404
|
-
const format = FORMATS.get(extension);
|
|
405
|
-
if (!format) {
|
|
406
|
-
throw storeError("ARTIFACT_INVALID_INPUT", "supported files are PPT/PPTX/ODP, XLS/XLSX/CSV/ODS, DOC/DOCX/ODT/RTF, Markdown, and text");
|
|
407
|
-
}
|
|
408
|
-
if (input.kind !== undefined && input.kind !== format.kind) {
|
|
409
|
-
throw storeError("ARTIFACT_INVALID_INPUT", `the selected file is ${format.kind}, not ${input.kind}`);
|
|
410
|
-
}
|
|
411
521
|
if (input.kind !== undefined
|
|
412
522
|
&& input.kind !== "presentation"
|
|
413
523
|
&& input.kind !== "spreadsheet"
|
|
@@ -416,6 +526,7 @@ export async function importArtifact(home, input) {
|
|
|
416
526
|
if (input.title !== undefined && typeof input.title !== "string") {
|
|
417
527
|
throw storeError("ARTIFACT_INVALID_INPUT", "title must be a string");
|
|
418
528
|
}
|
|
529
|
+
const { sourcePath, extension, format } = sourceFormat(input.sourcePath, input.kind);
|
|
419
530
|
const bytes = await readImportSource(sourcePath);
|
|
420
531
|
verifyClaimedFormat(extension, bytes);
|
|
421
532
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
@@ -461,6 +572,157 @@ export async function importArtifact(home, input) {
|
|
|
461
572
|
activateStaging(root, staging, artifactId);
|
|
462
573
|
return getArtifact(home, artifactId, false);
|
|
463
574
|
}
|
|
575
|
+
function activateRevisionStaging(artifact, staging, revisionId) {
|
|
576
|
+
const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
|
|
577
|
+
verifyDirectory(staging);
|
|
578
|
+
const destination = join(revisions.path, revisionId);
|
|
579
|
+
try {
|
|
580
|
+
lstatSync(destination);
|
|
581
|
+
throw storeError("ARTIFACT_CORRUPT", "a Revision id collision occurred; retry the commit");
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (error?.code !== "ENOENT")
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
renameSync(staging.path, destination);
|
|
588
|
+
const activated = lstatSync(destination);
|
|
589
|
+
if (!activated.isDirectory()
|
|
590
|
+
|| activated.isSymbolicLink()
|
|
591
|
+
|| activated.dev !== staging.dev
|
|
592
|
+
|| activated.ino !== staging.ino
|
|
593
|
+
|| realpathSync.native(destination) !== destination)
|
|
594
|
+
throw storeError("ARTIFACT_CORRUPT", "Revision activation changed the staged directory identity");
|
|
595
|
+
verifyDirectory(revisions);
|
|
596
|
+
syncDirectory(revisions.path);
|
|
597
|
+
}
|
|
598
|
+
function commitPreparedRevision(home, input) {
|
|
599
|
+
const artifact = artifactDirectory(home, input.artifactId);
|
|
600
|
+
const metadata = readArtifactMetadata(artifact);
|
|
601
|
+
if (metadata.artifact.currentRevisionId !== input.baseRevisionId) {
|
|
602
|
+
throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
|
|
603
|
+
}
|
|
604
|
+
readRevisionDetails(artifact, metadata.artifact.currentRevisionId, true);
|
|
605
|
+
const format = FORMATS.get(input.extension);
|
|
606
|
+
if (!format || format.kind !== metadata.artifact.kind || format.mediaType !== input.mediaType) {
|
|
607
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "the committed file kind does not match the Artifact");
|
|
608
|
+
}
|
|
609
|
+
if (input.bytes.byteLength < 1 || input.bytes.byteLength > MAX_ARTIFACT_IMPORT_BYTES) {
|
|
610
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "revision content size is outside the supported range");
|
|
611
|
+
}
|
|
612
|
+
verifyClaimedFormat(input.extension, input.bytes);
|
|
613
|
+
const revisions = childDirectory(artifact, "revisions", "artifact revisions are missing");
|
|
614
|
+
if (readdirSync(revisions.path).length >= MAX_REVISIONS) {
|
|
615
|
+
throw storeError("ARTIFACT_TOO_LARGE", `artifact has reached the ${MAX_REVISIONS} revision limit`);
|
|
616
|
+
}
|
|
617
|
+
const revisionId = newOpaqueId("rev");
|
|
618
|
+
const sha256 = createHash("sha256").update(input.bytes).digest("hex");
|
|
619
|
+
const contentRef = `content${input.extension}`;
|
|
620
|
+
const revision = {
|
|
621
|
+
revisionId,
|
|
622
|
+
artifactId: metadata.artifact.artifactId,
|
|
623
|
+
parentRevisionId: input.baseRevisionId,
|
|
624
|
+
baseRevisionId: input.baseRevisionId,
|
|
625
|
+
actor: input.actor,
|
|
626
|
+
...(input.taskRunId ? { taskRunId: input.taskRunId } : {}),
|
|
627
|
+
contentRef,
|
|
628
|
+
assetRefs: [],
|
|
629
|
+
contentDigest: sha256,
|
|
630
|
+
changedPaths: input.changedPaths,
|
|
631
|
+
createdAt: new Date().toISOString(),
|
|
632
|
+
};
|
|
633
|
+
const content = {
|
|
634
|
+
contentRef,
|
|
635
|
+
extension: input.extension,
|
|
636
|
+
mediaType: input.mediaType,
|
|
637
|
+
byteSize: input.bytes.byteLength,
|
|
638
|
+
sha256,
|
|
639
|
+
};
|
|
640
|
+
const stagingName = `.staging-${revisionId}-${randomUUID().replaceAll("-", "")}`;
|
|
641
|
+
const staging = ensurePrivateStateSubdirectory(home, [".hara", "artifacts", metadata.artifact.artifactId, stagingName]);
|
|
642
|
+
writePrivateStateBytesOnceSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], contentRef), input.bytes);
|
|
643
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "content.json"), `${JSON.stringify(content, null, 2)}\n`);
|
|
644
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId, stagingName], "revision.json"), `${JSON.stringify(revision, null, 2)}\n`);
|
|
645
|
+
activateRevisionStaging(artifact, staging, revisionId);
|
|
646
|
+
const updated = {
|
|
647
|
+
...metadata.artifact,
|
|
648
|
+
currentRevisionId: revisionId,
|
|
649
|
+
};
|
|
650
|
+
try {
|
|
651
|
+
writePrivateStateFileSync(bindPrivateHaraStateFile(home, ["artifacts", metadata.artifact.artifactId], "metadata.json"), `${JSON.stringify(updated, null, 2)}\n`, { expectedText: metadata.text });
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (error instanceof PrivateStateConflictError) {
|
|
655
|
+
throw storeError("ARTIFACT_CONFLICT", "the Artifact changed while this revision was committing; reopen the latest version", error);
|
|
656
|
+
}
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
return getArtifact(home, metadata.artifact.artifactId);
|
|
660
|
+
}
|
|
661
|
+
export async function commitArtifact(home, input) {
|
|
662
|
+
const artifactId = checkedArtifactId(input.artifactId);
|
|
663
|
+
const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
|
|
664
|
+
const actor = checkedActor(input.actor);
|
|
665
|
+
const taskRunId = checkedTaskRunId(input.taskRunId);
|
|
666
|
+
const changedPaths = checkedChangedPaths(input.changedPaths);
|
|
667
|
+
const before = getArtifact(home, artifactId);
|
|
668
|
+
if (before.artifact.currentRevisionId !== baseRevisionId) {
|
|
669
|
+
throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this edit started; reopen the latest version and apply the change again");
|
|
670
|
+
}
|
|
671
|
+
const { sourcePath, extension, format } = sourceFormat(input.sourcePath, before.artifact.kind);
|
|
672
|
+
const bytes = await readImportSource(sourcePath);
|
|
673
|
+
verifyClaimedFormat(extension, bytes);
|
|
674
|
+
return commitPreparedRevision(home, {
|
|
675
|
+
artifactId,
|
|
676
|
+
baseRevisionId,
|
|
677
|
+
extension,
|
|
678
|
+
mediaType: format.mediaType,
|
|
679
|
+
bytes,
|
|
680
|
+
actor,
|
|
681
|
+
...(taskRunId ? { taskRunId } : {}),
|
|
682
|
+
changedPaths,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
export function revertArtifact(home, input) {
|
|
686
|
+
const artifactId = checkedArtifactId(input.artifactId);
|
|
687
|
+
const baseRevisionId = checkedInputRevisionId(input.baseRevisionId, "baseRevisionId");
|
|
688
|
+
const targetRevisionId = checkedInputRevisionId(input.targetRevisionId, "targetRevisionId");
|
|
689
|
+
const actor = checkedActor(input.actor);
|
|
690
|
+
const taskRunId = checkedTaskRunId(input.taskRunId);
|
|
691
|
+
if (baseRevisionId === targetRevisionId) {
|
|
692
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is already the current revision");
|
|
693
|
+
}
|
|
694
|
+
const artifact = artifactDirectory(home, artifactId);
|
|
695
|
+
const current = readArtifactMetadata(artifact).artifact;
|
|
696
|
+
if (current.currentRevisionId !== baseRevisionId) {
|
|
697
|
+
throw storeError("ARTIFACT_CONFLICT", "the Artifact changed after this revert started; reopen the latest version");
|
|
698
|
+
}
|
|
699
|
+
const history = listArtifactRevisions(home, artifactId);
|
|
700
|
+
const byId = new Map(history.map((revision) => [revision.revisionId, revision]));
|
|
701
|
+
let cursor = byId.get(baseRevisionId);
|
|
702
|
+
let targetIsAncestor = false;
|
|
703
|
+
for (let count = 0; cursor && count <= history.length; count++) {
|
|
704
|
+
if (cursor.revisionId === targetRevisionId) {
|
|
705
|
+
targetIsAncestor = true;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
cursor = cursor.parentRevisionId ? byId.get(cursor.parentRevisionId) : undefined;
|
|
709
|
+
}
|
|
710
|
+
if (!targetIsAncestor) {
|
|
711
|
+
throw storeError("ARTIFACT_INVALID_INPUT", "targetRevisionId is not an ancestor of the current revision");
|
|
712
|
+
}
|
|
713
|
+
const target = readRevisionDetails(artifact, targetRevisionId, true);
|
|
714
|
+
const bytes = readContentBytes(revisionDirectory(artifact, targetRevisionId), target.content);
|
|
715
|
+
return commitPreparedRevision(home, {
|
|
716
|
+
artifactId,
|
|
717
|
+
baseRevisionId,
|
|
718
|
+
extension: target.content.extension,
|
|
719
|
+
mediaType: target.content.mediaType,
|
|
720
|
+
bytes,
|
|
721
|
+
actor,
|
|
722
|
+
...(taskRunId ? { taskRunId } : {}),
|
|
723
|
+
changedPaths: ["content"],
|
|
724
|
+
});
|
|
725
|
+
}
|
|
464
726
|
export function getArtifact(home, artifactId, verifyDigest = true) {
|
|
465
727
|
return readArtifactDetailsFromDirectory(artifactDirectory(home, artifactId), verifyDigest);
|
|
466
728
|
}
|
|
@@ -56,6 +56,29 @@ export function homeWorkspaceGuidance(cwd) {
|
|
|
56
56
|
"Ask the user to switch with `/cd /path/to/project`, run `cd /path/to/project`, or launch with " +
|
|
57
57
|
"`hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
|
|
58
58
|
}
|
|
59
|
+
/** Pick the first existing project directory from an already-authorized candidate list. This deliberately
|
|
60
|
+
* does not enumerate Home: callers supply recent-session or registered-project paths, and the user still
|
|
61
|
+
* confirms the handoff before Hara changes process.cwd(). */
|
|
62
|
+
export function suggestedProjectWorkspace(candidates, home = effectiveHomeDir()) {
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
if (typeof candidate !== "string" || !candidate.trim())
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const target = realpathSync.native(resolve(candidate));
|
|
69
|
+
if (seen.has(target))
|
|
70
|
+
continue;
|
|
71
|
+
seen.add(target);
|
|
72
|
+
if (!statSync(target).isDirectory() || isUnsafeProjectWorkspace(target, home))
|
|
73
|
+
continue;
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Stale session/project registrations are ordinary; skip them without weakening the boundary.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
59
82
|
/** Resolve an explicit interactive workspace handoff without accepting Home/ancestor scopes. */
|
|
60
83
|
export function resolveWorkspaceSwitch(input, currentCwd, home = effectiveHomeDir()) {
|
|
61
84
|
let requested = input.trim();
|
package/dist/gateway/feishu.js
CHANGED
|
@@ -12,6 +12,60 @@ const lark = (larkNs.default ?? larkNs);
|
|
|
12
12
|
import { chunkText, outboundTransferTimeoutMs, PerChatOutboundLane, withOutboundDeadline, } from "./telegram.js";
|
|
13
13
|
import { InboundMediaBudget, cleanupTransientMedia, savePrivateResponse } from "./media.js";
|
|
14
14
|
import { GatewayEventSpool, gatewayRuntimeScope } from "./runtime-state.js";
|
|
15
|
+
import { redactKnownSecrets } from "../security/secrets.js";
|
|
16
|
+
const FEISHU_WS_HOUR_MS = 60 * 60_000;
|
|
17
|
+
const FEISHU_WS_DAY_MS = 24 * FEISHU_WS_HOUR_MS;
|
|
18
|
+
const FEISHU_WS_HOURLY_ALERT = 5;
|
|
19
|
+
const FEISHU_WS_DAILY_ALERT = 25;
|
|
20
|
+
/** Process-local WS lifecycle accounting. PM2/systemd retains the structured log across restarts; keeping
|
|
21
|
+
* raw connection history out of Hara state avoids another privacy-sensitive durable file. */
|
|
22
|
+
export class FeishuWsHealthMonitor {
|
|
23
|
+
now;
|
|
24
|
+
connectedAt;
|
|
25
|
+
disconnectedAt;
|
|
26
|
+
disconnects = [];
|
|
27
|
+
total = 0;
|
|
28
|
+
lastAlertAt = Number.NEGATIVE_INFINITY;
|
|
29
|
+
constructor(now = Date.now) {
|
|
30
|
+
this.now = now;
|
|
31
|
+
}
|
|
32
|
+
ready() {
|
|
33
|
+
this.connectedAt = this.now();
|
|
34
|
+
this.disconnectedAt = undefined;
|
|
35
|
+
}
|
|
36
|
+
disconnect() {
|
|
37
|
+
const at = this.now();
|
|
38
|
+
this.disconnects = this.disconnects.filter((value) => at - value <= FEISHU_WS_DAY_MS);
|
|
39
|
+
this.disconnects.push(at);
|
|
40
|
+
this.total += 1;
|
|
41
|
+
const hourCount = this.disconnects.filter((value) => at - value <= FEISHU_WS_HOUR_MS).length;
|
|
42
|
+
const dayCount = this.disconnects.length;
|
|
43
|
+
const connectedForMs = this.connectedAt === undefined ? undefined : Math.max(0, at - this.connectedAt);
|
|
44
|
+
this.connectedAt = undefined;
|
|
45
|
+
this.disconnectedAt = at;
|
|
46
|
+
const unhealthy = hourCount >= FEISHU_WS_HOURLY_ALERT || dayCount >= FEISHU_WS_DAILY_ALERT;
|
|
47
|
+
const alert = unhealthy && at - this.lastAlertAt >= FEISHU_WS_HOUR_MS;
|
|
48
|
+
if (alert)
|
|
49
|
+
this.lastAlertAt = at;
|
|
50
|
+
return { total: this.total, hourCount, dayCount, connectedForMs, alert };
|
|
51
|
+
}
|
|
52
|
+
reconnected() {
|
|
53
|
+
const at = this.now();
|
|
54
|
+
const disconnectedForMs = this.disconnectedAt === undefined ? undefined : Math.max(0, at - this.disconnectedAt);
|
|
55
|
+
this.connectedAt = at;
|
|
56
|
+
this.disconnectedAt = undefined;
|
|
57
|
+
return disconnectedForMs;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function wsDuration(ms) {
|
|
61
|
+
if (ms === undefined)
|
|
62
|
+
return "unknown";
|
|
63
|
+
if (ms < 60_000)
|
|
64
|
+
return `${Math.max(0, Math.round(ms / 1_000))}s`;
|
|
65
|
+
if (ms < 60 * 60_000)
|
|
66
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
67
|
+
return `${Math.round(ms / (60 * 60_000))}h`;
|
|
68
|
+
}
|
|
15
69
|
/** Normalize a Feishu message's parsed content by type → text + any media keys (pure; download done by caller). */
|
|
16
70
|
export function parseFeishuContent(messageType, content) {
|
|
17
71
|
if (messageType === "text")
|
|
@@ -178,6 +232,8 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
178
232
|
// gateway go deaf. Turning it on means: no inbound frame within pingTimeout seconds of a ping → presumed dead
|
|
179
233
|
// → reconnect. handshakeTimeoutMs stops a stuck DNS/proxy handshake from hanging. Lifecycle logs give
|
|
180
234
|
// visibility so a reconnect is observable instead of a mystery silence.
|
|
235
|
+
const wsHealth = new FeishuWsHealthMonitor();
|
|
236
|
+
let failActiveStart;
|
|
181
237
|
const wsClient = new lark.WSClient({
|
|
182
238
|
appId,
|
|
183
239
|
appSecret,
|
|
@@ -185,9 +241,29 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
185
241
|
autoReconnect: true,
|
|
186
242
|
wsConfig: { pingTimeout: 10 },
|
|
187
243
|
handshakeTimeoutMs: 15_000,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
244
|
+
onReady: () => {
|
|
245
|
+
wsHealth.ready();
|
|
246
|
+
console.error("hara feishu: ✓ ws connected");
|
|
247
|
+
},
|
|
248
|
+
onReconnecting: () => {
|
|
249
|
+
const health = wsHealth.disconnect();
|
|
250
|
+
console.error(`hara feishu: ws disconnected #${health.total} · ${health.hourCount}/1h · ${health.dayCount}/24h · ` +
|
|
251
|
+
`previous connection ${wsDuration(health.connectedForMs)} · auto-reconnecting (SDK does not expose close code/reason)`);
|
|
252
|
+
if (health.alert) {
|
|
253
|
+
console.error("hara feishu: ALERT WebSocket disconnect frequency is unhealthy; check duplicate bot instances, host network/proxy, " +
|
|
254
|
+
"and adjacent SDK error/watchdog logs. Automatic reconnect remains active.");
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onReconnected: () => {
|
|
258
|
+
console.error(`hara feishu: ✓ ws reconnected after ${wsDuration(wsHealth.reconnected())}`);
|
|
259
|
+
},
|
|
260
|
+
onError: (err) => {
|
|
261
|
+
const safe = redactKnownSecrets(String(err?.message ?? err), [appId, appSecret]).text
|
|
262
|
+
.replace(/\s+/gu, " ")
|
|
263
|
+
.slice(0, 300);
|
|
264
|
+
console.error(`hara feishu: ALERT ws reconnect entered terminal failure — ${safe || "unknown SDK error"}`);
|
|
265
|
+
failActiveStart?.(new Error("Feishu WebSocket entered terminal failure; inspect the redacted gateway log"));
|
|
266
|
+
},
|
|
191
267
|
});
|
|
192
268
|
// Generated SDK methods discard unknown request options before they reach axios, so passing `signal` there
|
|
193
269
|
// is not cooperative cancellation. REST and media use native fetch instead; the SDK remains responsible for
|
|
@@ -385,22 +461,31 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
385
461
|
});
|
|
386
462
|
},
|
|
387
463
|
async start(onMessage, signal, shouldDownload) {
|
|
464
|
+
const runAbort = new AbortController();
|
|
465
|
+
const relayAbort = () => {
|
|
466
|
+
runAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("Feishu gateway cancelled"));
|
|
467
|
+
};
|
|
468
|
+
if (signal.aborted)
|
|
469
|
+
relayAbort();
|
|
470
|
+
else
|
|
471
|
+
signal.addEventListener("abort", relayAbort, { once: true });
|
|
472
|
+
const runSignal = runAbort.signal;
|
|
388
473
|
const spool = await GatewayEventSpool.open(gatewayRuntimeScope("feishu-inbound", appId));
|
|
389
474
|
const locallyCompleted = new Set();
|
|
390
475
|
const cleanupFailures = new Map();
|
|
391
476
|
const retryStateFailures = new Map();
|
|
392
477
|
const postAckCleanups = new Map();
|
|
393
478
|
const runWorker = async () => {
|
|
394
|
-
while (!
|
|
479
|
+
while (!runSignal.aborted) {
|
|
395
480
|
const item = await spool.nextReady();
|
|
396
481
|
if (!item) {
|
|
397
|
-
await waitForFeishuWork(250,
|
|
482
|
+
await waitForFeishuWork(250, runSignal);
|
|
398
483
|
continue;
|
|
399
484
|
}
|
|
400
485
|
try {
|
|
401
486
|
if (!locallyCompleted.has(item.id)) {
|
|
402
|
-
const m = await toInbound(downloadResource, item.payload, await ensureBotOpenId(
|
|
403
|
-
if (
|
|
487
|
+
const m = await toInbound(downloadResource, item.payload, await ensureBotOpenId(runSignal), runSignal, shouldDownload);
|
|
488
|
+
if (runSignal.aborted) {
|
|
404
489
|
await spool.release(item.id);
|
|
405
490
|
return;
|
|
406
491
|
}
|
|
@@ -409,7 +494,7 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
409
494
|
if (cleanup)
|
|
410
495
|
postAckCleanups.set(item.id, cleanup);
|
|
411
496
|
}
|
|
412
|
-
if (
|
|
497
|
+
if (runSignal.aborted) {
|
|
413
498
|
await spool.release(item.id);
|
|
414
499
|
return;
|
|
415
500
|
}
|
|
@@ -433,7 +518,7 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
433
518
|
}
|
|
434
519
|
}
|
|
435
520
|
catch {
|
|
436
|
-
if (
|
|
521
|
+
if (runSignal.aborted) {
|
|
437
522
|
await spool.release(item.id);
|
|
438
523
|
return;
|
|
439
524
|
}
|
|
@@ -449,7 +534,7 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
449
534
|
console.error("hara feishu: ALERT durable spool cleanup suspended after 5 failures; the completed item is retained for startup recovery");
|
|
450
535
|
return; // keep the in-memory lease: no busy loop and no agent replay in this process
|
|
451
536
|
}
|
|
452
|
-
await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))),
|
|
537
|
+
await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), runSignal);
|
|
453
538
|
await spool.release(item.id);
|
|
454
539
|
continue;
|
|
455
540
|
}
|
|
@@ -476,7 +561,7 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
476
561
|
console.error("hara feishu: ALERT durable spool retry-state persistence suspended after 5 failures; event retained for startup recovery");
|
|
477
562
|
return;
|
|
478
563
|
}
|
|
479
|
-
await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))),
|
|
564
|
+
await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), runSignal);
|
|
480
565
|
await spool.release(item.id);
|
|
481
566
|
}
|
|
482
567
|
}
|
|
@@ -492,43 +577,59 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
492
577
|
"im.message.receive_v1": async (data) => {
|
|
493
578
|
// Feishu requires a long-connection callback within three seconds. Durably enqueue first, then ACK;
|
|
494
579
|
// workers run the potentially minutes-long agent task independently and survive process restarts.
|
|
495
|
-
if (
|
|
580
|
+
if (runSignal.aborted)
|
|
496
581
|
throw new Error("Feishu gateway cancelled");
|
|
497
582
|
const write = spool.enqueue(feishuEventSpoolId(data), data);
|
|
498
583
|
persistenceWrites.add(write);
|
|
499
584
|
void write.then(() => persistenceWrites.delete(write), () => persistenceWrites.delete(write));
|
|
500
585
|
try {
|
|
501
|
-
await withOutboundDeadline("Feishu event persistence",
|
|
586
|
+
await withOutboundDeadline("Feishu event persistence", runSignal, 2_500, async () => write);
|
|
502
587
|
}
|
|
503
588
|
catch (error) {
|
|
504
|
-
if (!
|
|
589
|
+
if (!runSignal.aborted) {
|
|
505
590
|
console.error(`hara feishu: ALERT event could not be durably queued before ACK — ${error instanceof Error ? error.message : String(error)}`);
|
|
506
591
|
}
|
|
507
592
|
throw error;
|
|
508
593
|
}
|
|
509
594
|
},
|
|
510
595
|
});
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
596
|
+
const terminalFailure = new Promise((_resolve, reject) => {
|
|
597
|
+
failActiveStart = (error) => {
|
|
598
|
+
reject(error);
|
|
599
|
+
runAbort.abort(error);
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
const stopped = new Promise((resolve) => {
|
|
603
|
+
if (runSignal.aborted)
|
|
517
604
|
return resolve();
|
|
518
|
-
|
|
605
|
+
runSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
519
606
|
});
|
|
520
607
|
try {
|
|
521
|
-
|
|
608
|
+
// The SDK owns ordinary reconnect/backoff. A terminal callback is different: keeping this process alive
|
|
609
|
+
// but deaf defeats PM2/systemd recovery, so reject start after draining the durable inbound workers.
|
|
610
|
+
void Promise.resolve(wsClient.start({ eventDispatcher })).catch((error) => {
|
|
611
|
+
const safe = redactKnownSecrets(String(error instanceof Error ? error.message : error), [appId, appSecret]).text;
|
|
612
|
+
failActiveStart?.(new Error(`Feishu WebSocket start failed: ${safe.slice(0, 300)}`));
|
|
613
|
+
});
|
|
614
|
+
await Promise.race([stopped, terminalFailure]);
|
|
522
615
|
}
|
|
523
|
-
|
|
524
|
-
|
|
616
|
+
finally {
|
|
617
|
+
failActiveStart = undefined;
|
|
618
|
+
runAbort.abort(new Error("Feishu gateway stopped"));
|
|
619
|
+
signal.removeEventListener("abort", relayAbort);
|
|
620
|
+
try {
|
|
621
|
+
wsClient.close();
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
/* best-effort clean shutdown */
|
|
625
|
+
}
|
|
626
|
+
// Do not release the gateway instance lease while an old worker or an ACK-persistence write can still
|
|
627
|
+
// mutate the spool. A replacement process must never race this process's final CAS write or run the same
|
|
628
|
+
// durable item concurrently.
|
|
629
|
+
while (persistenceWrites.size)
|
|
630
|
+
await Promise.allSettled([...persistenceWrites]);
|
|
631
|
+
await workersSettled;
|
|
525
632
|
}
|
|
526
|
-
// Do not release the gateway instance lease while an old worker or an ACK-persistence write can still
|
|
527
|
-
// mutate the spool. A replacement process must never race this process's final CAS write or run the same
|
|
528
|
-
// durable item concurrently.
|
|
529
|
-
while (persistenceWrites.size)
|
|
530
|
-
await Promise.allSettled([...persistenceWrites]);
|
|
531
|
-
await workersSettled;
|
|
532
633
|
},
|
|
533
634
|
};
|
|
534
635
|
}
|
package/dist/index.js
CHANGED
|
@@ -71,7 +71,7 @@ function renderBgJobs() {
|
|
|
71
71
|
}
|
|
72
72
|
import { qwenDeviceLogin, loadQwenToken } from "./providers/qwen-oauth.js";
|
|
73
73
|
import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
74
|
-
import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, } from "./context/workspace-scope.js";
|
|
74
|
+
import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, suggestedProjectWorkspace, } from "./context/workspace-scope.js";
|
|
75
75
|
import { getEmbedder } from "./search/embed.js";
|
|
76
76
|
import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
77
77
|
import { searchHybrid } from "./search/hybrid.js";
|
|
@@ -3001,6 +3001,66 @@ program.action(async (opts) => {
|
|
|
3001
3001
|
// hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
|
|
3002
3002
|
// pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
|
|
3003
3003
|
// in the priority chain than --profile).
|
|
3004
|
+
// An interactive launch from Home gets an explicit, pre-provider handoff. Candidate discovery is limited
|
|
3005
|
+
// to paths the user already established through interactive sessions/project registration; Hara never
|
|
3006
|
+
// enumerates Home and never silently promotes a readable child into the workspace.
|
|
3007
|
+
if (!opts.print
|
|
3008
|
+
&& !opts.cwd
|
|
3009
|
+
&& !opts.resume
|
|
3010
|
+
&& stdin.isTTY
|
|
3011
|
+
&& stdout.isTTY
|
|
3012
|
+
&& isUnsafeProjectWorkspace(process.cwd())) {
|
|
3013
|
+
let candidate;
|
|
3014
|
+
try {
|
|
3015
|
+
const recent = listSessions()
|
|
3016
|
+
.filter((session) => session.source === undefined || session.source === "interactive")
|
|
3017
|
+
.map((session) => session.cwd);
|
|
3018
|
+
candidate = suggestedProjectWorkspace([
|
|
3019
|
+
...recent,
|
|
3020
|
+
...loadProjects().map((project) => project.path),
|
|
3021
|
+
]);
|
|
3022
|
+
}
|
|
3023
|
+
catch {
|
|
3024
|
+
// A damaged optional history/registry must not weaken the Home boundary or block startup.
|
|
3025
|
+
}
|
|
3026
|
+
const startupUsesTui = process.env.HARA_TUI !== "0";
|
|
3027
|
+
if (candidate && startupUsesTui) {
|
|
3028
|
+
// A readline question before Ink mounts leaves stdin unreadable on some terminals. The small Ink
|
|
3029
|
+
// confirmation unmounts cleanly and is the same handoff used by the first-run AGENTS.md prompt.
|
|
3030
|
+
if (await askConfirm(`Home is protected as personal-data scope. Switch to recent project ${displaySessionCwd(candidate)}?`)) {
|
|
3031
|
+
process.chdir(candidate);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
else if (!candidate && startupUsesTui) {
|
|
3035
|
+
out(c.yellow("Home is protected as personal-data scope; no recent project is available to offer safely. ")
|
|
3036
|
+
+ c.dim("Start with `hara --cwd /path/to/project`, or use `/cd /path/to/project` after startup.\n"));
|
|
3037
|
+
}
|
|
3038
|
+
else {
|
|
3039
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
3040
|
+
try {
|
|
3041
|
+
if (candidate) {
|
|
3042
|
+
const answer = (await rl.question(c.yellow(`Home is protected as personal-data scope. Switch to recent project ${displaySessionCwd(candidate)}? `)
|
|
3043
|
+
+ c.dim("[Y/n] "))).trim().toLowerCase();
|
|
3044
|
+
if (answer === "" || answer === "y" || answer === "yes")
|
|
3045
|
+
process.chdir(candidate);
|
|
3046
|
+
}
|
|
3047
|
+
else {
|
|
3048
|
+
const answer = (await rl.question(c.yellow("Home is protected as personal-data scope. Enter a project directory to switch now")
|
|
3049
|
+
+ c.dim(" (leave empty to stay at Home): "))).trim();
|
|
3050
|
+
if (answer) {
|
|
3051
|
+
const switched = resolveWorkspaceSwitch(answer, process.cwd());
|
|
3052
|
+
if (switched.ok)
|
|
3053
|
+
process.chdir(switched.cwd);
|
|
3054
|
+
else
|
|
3055
|
+
out(c.red(`(${switched.error})\n`));
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
finally {
|
|
3060
|
+
rl.close();
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3004
3064
|
// Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
|
|
3005
3065
|
// an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
|
|
3006
3066
|
// constructed from that home rather than from the shell directory that happened to launch hara.
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -281,8 +281,77 @@ function validGitSource(source) {
|
|
|
281
281
|
if (!/^(?:https:\/\/|ssh:\/\/|git@)[^\s\0]+$/u.test(url)) {
|
|
282
282
|
throw new Error("git source must use an https://, ssh://, or git@ URL");
|
|
283
283
|
}
|
|
284
|
+
if (url.startsWith("https://") || url.startsWith("ssh://")) {
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = new URL(url);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
throw new Error("git source contains an invalid URL");
|
|
291
|
+
}
|
|
292
|
+
const secretAuthority = url.startsWith("https://")
|
|
293
|
+
? Boolean(parsed.username || parsed.password)
|
|
294
|
+
: Boolean(parsed.password);
|
|
295
|
+
if (secretAuthority || parsed.search || parsed.hash) {
|
|
296
|
+
throw new Error("git source must not embed credentials or query/fragment secrets; configure a Git credential helper or SSH key instead");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
284
299
|
return url;
|
|
285
300
|
}
|
|
301
|
+
/** Turn git's often credential-bearing stderr into a bounded, actionable diagnosis. Never echo the URL,
|
|
302
|
+
* stderr, usernames, tokens, credential-helper output, or the original command line. */
|
|
303
|
+
export function pluginGitCloneFailure(kind, error) {
|
|
304
|
+
const value = error;
|
|
305
|
+
const stderr = Buffer.isBuffer(value?.stderr)
|
|
306
|
+
? value.stderr.toString("utf8")
|
|
307
|
+
: typeof value?.stderr === "string"
|
|
308
|
+
? value.stderr
|
|
309
|
+
: "";
|
|
310
|
+
const diagnostic = stderr.toLowerCase();
|
|
311
|
+
const label = kind === "github" ? "GitHub plugin repository" : "plugin Git repository";
|
|
312
|
+
if (value?.code === "ENOENT") {
|
|
313
|
+
return `Could not clone the ${label}: Git is not installed or is not available on PATH. Install Git, then retry.`;
|
|
314
|
+
}
|
|
315
|
+
if (value?.code === "ETIMEDOUT"
|
|
316
|
+
|| value?.signal === "SIGTERM"
|
|
317
|
+
|| value?.killed === true
|
|
318
|
+
|| /timed out|connection timed out/u.test(diagnostic)) {
|
|
319
|
+
return `Could not clone the ${label}: the network operation exceeded Hara's 2-minute limit. Check connectivity/proxy settings, then retry.`;
|
|
320
|
+
}
|
|
321
|
+
if (/could not resolve host|failed to connect|connection refused|network is unreachable|connection reset/u.test(diagnostic)) {
|
|
322
|
+
return `Could not clone the ${label}: Git could not reach the remote host. Check DNS, network, VPN, and Git proxy settings, then retry.`;
|
|
323
|
+
}
|
|
324
|
+
if (/authentication failed|permission denied|access denied|could not read username|terminal prompts disabled|publickey|http basic/u.test(diagnostic)) {
|
|
325
|
+
return (`Could not clone the ${label}: authentication or repository access was denied. ` +
|
|
326
|
+
(kind === "github"
|
|
327
|
+
? "For a private repository, authenticate GitHub (`gh auth login` then `gh auth setup-git`) or use `git:git@github.com:owner/repository.git` with a working SSH key. "
|
|
328
|
+
: "Configure credentials for that Git host or use an SSH URL backed by a working key. ") +
|
|
329
|
+
"Do not put a token in the plugin URL.");
|
|
330
|
+
}
|
|
331
|
+
if (/repository not found|not found|could not read from remote repository/u.test(diagnostic)) {
|
|
332
|
+
return (`Could not clone the ${label}: it does not exist or the current Git identity cannot access it. ` +
|
|
333
|
+
(kind === "github"
|
|
334
|
+
? "Check owner/repository spelling; for a private repository run `gh auth login` and `gh auth setup-git`, or use a working SSH URL. "
|
|
335
|
+
: "Check the repository URL and credentials. ") +
|
|
336
|
+
"Do not put a token in the plugin URL.");
|
|
337
|
+
}
|
|
338
|
+
return (`Could not clone the ${label}. Run an equivalent \`git clone\` yourself to diagnose the remote, ` +
|
|
339
|
+
"then retry Hara after Git credentials/network access work. No remote stderr was shown because it may contain credentials.");
|
|
340
|
+
}
|
|
341
|
+
function clonePluginRepository(url, staging, kind) {
|
|
342
|
+
try {
|
|
343
|
+
execFileSync("git", ["clone", "--depth", "1", url, staging], {
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
346
|
+
maxBuffer: 256 * 1024,
|
|
347
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
348
|
+
timeout: 2 * 60_000,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw new Error(pluginGitCloneFailure(kind, error));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
286
355
|
function populateStaging(source, staging) {
|
|
287
356
|
if (source.startsWith("file:")) {
|
|
288
357
|
const requested = resolve(source.slice("file:".length));
|
|
@@ -301,10 +370,10 @@ function populateStaging(source, staging) {
|
|
|
301
370
|
const repo = source.slice("github:".length);
|
|
302
371
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo))
|
|
303
372
|
throw new Error("github source must be owner/repository");
|
|
304
|
-
|
|
373
|
+
clonePluginRepository(`https://github.com/${repo}.git`, staging, "github");
|
|
305
374
|
}
|
|
306
375
|
else if (source.startsWith("git:")) {
|
|
307
|
-
|
|
376
|
+
clonePluginRepository(validGitSource(source), staging, "git");
|
|
308
377
|
}
|
|
309
378
|
else {
|
|
310
379
|
throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
|
|
@@ -11,6 +11,12 @@ import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
|
11
11
|
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
|
|
12
12
|
const tightenedHomes = new Set();
|
|
13
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
14
|
+
export class PrivateStateConflictError extends Error {
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = "PrivateStateConflictError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
14
20
|
function isMissing(error) {
|
|
15
21
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
16
22
|
}
|
|
@@ -397,13 +403,16 @@ export function writePrivateStateBytesOnceSync(binding, bytes) {
|
|
|
397
403
|
* Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
|
|
398
404
|
* are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
|
|
399
405
|
*/
|
|
400
|
-
export function writePrivateStateFileSync(binding, text) {
|
|
406
|
+
export function writePrivateStateFileSync(binding, text, options = {}) {
|
|
401
407
|
const { directory, path } = binding;
|
|
402
408
|
verifyPrivateDirectory(directory);
|
|
403
409
|
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
404
410
|
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
405
411
|
}
|
|
406
412
|
const existing = readPrivateStateFileSnapshotSync(path);
|
|
413
|
+
if (options.expectedText !== undefined && existing?.text !== options.expectedText) {
|
|
414
|
+
throw new PrivateStateConflictError(`private Hara state file no longer matches the expected version: '${path}'`);
|
|
415
|
+
}
|
|
407
416
|
verifyPrivateDirectory(directory);
|
|
408
417
|
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
409
418
|
let fd;
|
|
@@ -438,8 +447,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
438
447
|
linkSync(temp, path);
|
|
439
448
|
}
|
|
440
449
|
catch (error) {
|
|
441
|
-
if (error?.code === "EEXIST")
|
|
442
|
-
throw new
|
|
450
|
+
if (error?.code === "EEXIST") {
|
|
451
|
+
throw new PrivateStateConflictError(`private Hara state file changed before create: '${path}'`, { cause: error });
|
|
452
|
+
}
|
|
443
453
|
throw error;
|
|
444
454
|
}
|
|
445
455
|
}
|
|
@@ -450,8 +460,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
450
460
|
renameSync(path, claimed);
|
|
451
461
|
}
|
|
452
462
|
catch (error) {
|
|
453
|
-
if (error?.code === "ENOENT")
|
|
454
|
-
throw new
|
|
463
|
+
if (error?.code === "ENOENT") {
|
|
464
|
+
throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`, { cause: error });
|
|
465
|
+
}
|
|
455
466
|
throw error;
|
|
456
467
|
}
|
|
457
468
|
let verifiedClaim = false;
|
|
@@ -462,8 +473,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
462
473
|
&& claimedSnapshot.mode === existing.mode
|
|
463
474
|
&& claimedSnapshot.nlink === existing.nlink
|
|
464
475
|
&& claimedSnapshot.text === existing.text);
|
|
465
|
-
if (!verifiedClaim)
|
|
466
|
-
throw new
|
|
476
|
+
if (!verifiedClaim) {
|
|
477
|
+
throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`);
|
|
478
|
+
}
|
|
467
479
|
}
|
|
468
480
|
catch (error) {
|
|
469
481
|
try {
|
package/dist/serve/protocol.js
CHANGED
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
// automation.toggle {id,enabled} → {id,enabled}
|
|
30
30
|
// automation.delete {id} → {id,deleted}
|
|
31
31
|
// artifact.import {sourcePath,title?,kind?} → {artifact,currentRevision,content}
|
|
32
|
+
// artifact.commit {artifactId,baseRevisionId,sourcePath,taskRunId?,changedPaths?}
|
|
33
|
+
// → {artifact,currentRevision,content}
|
|
34
|
+
// artifact.revert {artifactId,baseRevisionId,targetRevisionId,taskRunId?}
|
|
35
|
+
// → {artifact,currentRevision,content}
|
|
32
36
|
// artifact.list {} → {artifacts,invalid,truncated}
|
|
33
37
|
// artifact.get {artifactId} → {artifact,currentRevision,content}
|
|
34
38
|
// artifact.revisions {artifactId} → {artifactId,revisions}
|
|
@@ -52,6 +56,7 @@ export const ERR = {
|
|
|
52
56
|
BUSY: -32002, // requested operation conflicts with active server/session work
|
|
53
57
|
NO_SESSION: -32003, // unknown/expired sessionId
|
|
54
58
|
LOCKED: -32004, // session held by another live hara process (single-writer lock)
|
|
59
|
+
CONFLICT: -32005, // optimistic version/base no longer matches the current object
|
|
55
60
|
};
|
|
56
61
|
/** Parse one inbound text frame into a request. Returns {error} (never throws) on malformed input —
|
|
57
62
|
* the transport turns that into a PARSE/INVALID error response. */
|
package/dist/serve/server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { readModelContextFileSync } from "../fs-read.js";
|
|
|
37
37
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
38
38
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
39
39
|
import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
|
|
40
|
-
import { ArtifactStoreError, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, } from "../artifacts/store.js";
|
|
40
|
+
import { ArtifactStoreError, commitArtifact, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, revertArtifact, } from "../artifacts/store.js";
|
|
41
41
|
import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
|
|
42
42
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
43
43
|
const COMPACT_TIMEOUT_MS = 60_000;
|
|
@@ -46,12 +46,18 @@ const SOCKET_CLOSE_GRACE_MS = 250;
|
|
|
46
46
|
const DISCOVERY_LOCK_WAIT_MS = 2_000;
|
|
47
47
|
const artifactRpcError = (id, error, action) => {
|
|
48
48
|
if (error instanceof ArtifactStoreError) {
|
|
49
|
-
const code = error.code === "ARTIFACT_CORRUPT"
|
|
49
|
+
const code = error.code === "ARTIFACT_CORRUPT"
|
|
50
|
+
? ERR.INTERNAL
|
|
51
|
+
: error.code === "ARTIFACT_CONFLICT"
|
|
52
|
+
? ERR.CONFLICT
|
|
53
|
+
: ERR.PARAMS;
|
|
50
54
|
return rpcError(id, code, error.message);
|
|
51
55
|
}
|
|
52
|
-
return rpcError(id, ERR.INTERNAL, action === "import"
|
|
53
|
-
?
|
|
54
|
-
:
|
|
56
|
+
return rpcError(id, ERR.INTERNAL, action === "import" || action === "commit"
|
|
57
|
+
? `Artifact ${action} failed safely; the source file was not modified`
|
|
58
|
+
: action === "revert"
|
|
59
|
+
? "Artifact revert failed safely; no current revision was replaced"
|
|
60
|
+
: `Artifact ${action} failed safely; local Artifact data was not changed`);
|
|
55
61
|
};
|
|
56
62
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
63
|
const isPidAlive = (pid) => {
|
|
@@ -742,7 +748,8 @@ export async function startServe(opts, deps) {
|
|
|
742
748
|
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
743
749
|
"settings.providers.list", "settings.providers.test", "settings.providers.save",
|
|
744
750
|
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
745
|
-
"artifact.import", "artifact.
|
|
751
|
+
"artifact.import", "artifact.commit", "artifact.revert",
|
|
752
|
+
"artifact.list", "artifact.get", "artifact.revisions",
|
|
746
753
|
"tasks.list", "approvals.list", "approvals.resolve",
|
|
747
754
|
];
|
|
748
755
|
const runtime = runtimeInfo();
|
|
@@ -1162,6 +1169,63 @@ export async function startServe(opts, deps) {
|
|
|
1162
1169
|
return reply(artifactRpcError(id, error, "import"));
|
|
1163
1170
|
}
|
|
1164
1171
|
}
|
|
1172
|
+
case "artifact.commit": {
|
|
1173
|
+
if (typeof p.artifactId !== "string"
|
|
1174
|
+
|| typeof p.baseRevisionId !== "string"
|
|
1175
|
+
|| typeof p.sourcePath !== "string"
|
|
1176
|
+
|| !p.sourcePath) {
|
|
1177
|
+
return reply(rpcError(id, ERR.PARAMS, "artifactId, baseRevisionId, and sourcePath required"));
|
|
1178
|
+
}
|
|
1179
|
+
if (p.actor !== undefined) {
|
|
1180
|
+
return reply(rpcError(id, ERR.PARAMS, "actor is assigned by the authenticated host"));
|
|
1181
|
+
}
|
|
1182
|
+
if (p.taskRunId !== undefined && typeof p.taskRunId !== "string") {
|
|
1183
|
+
return reply(rpcError(id, ERR.PARAMS, "taskRunId must be a string"));
|
|
1184
|
+
}
|
|
1185
|
+
if (p.changedPaths !== undefined && !Array.isArray(p.changedPaths)) {
|
|
1186
|
+
return reply(rpcError(id, ERR.PARAMS, "changedPaths must be an array"));
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
const details = await commitArtifact(artifactHome, {
|
|
1190
|
+
artifactId: p.artifactId,
|
|
1191
|
+
baseRevisionId: p.baseRevisionId,
|
|
1192
|
+
sourcePath: p.sourcePath,
|
|
1193
|
+
actor: "user",
|
|
1194
|
+
...(p.taskRunId !== undefined ? { taskRunId: p.taskRunId } : {}),
|
|
1195
|
+
...(p.changedPaths !== undefined ? { changedPaths: p.changedPaths } : {}),
|
|
1196
|
+
});
|
|
1197
|
+
return reply(rpcResult(id, details));
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
return reply(artifactRpcError(id, error, "commit"));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
case "artifact.revert": {
|
|
1204
|
+
if (typeof p.artifactId !== "string"
|
|
1205
|
+
|| typeof p.baseRevisionId !== "string"
|
|
1206
|
+
|| typeof p.targetRevisionId !== "string") {
|
|
1207
|
+
return reply(rpcError(id, ERR.PARAMS, "artifactId, baseRevisionId, and targetRevisionId required"));
|
|
1208
|
+
}
|
|
1209
|
+
if (p.actor !== undefined) {
|
|
1210
|
+
return reply(rpcError(id, ERR.PARAMS, "actor is assigned by the authenticated host"));
|
|
1211
|
+
}
|
|
1212
|
+
if (p.taskRunId !== undefined && typeof p.taskRunId !== "string") {
|
|
1213
|
+
return reply(rpcError(id, ERR.PARAMS, "taskRunId must be a string"));
|
|
1214
|
+
}
|
|
1215
|
+
try {
|
|
1216
|
+
const details = revertArtifact(artifactHome, {
|
|
1217
|
+
artifactId: p.artifactId,
|
|
1218
|
+
baseRevisionId: p.baseRevisionId,
|
|
1219
|
+
targetRevisionId: p.targetRevisionId,
|
|
1220
|
+
actor: "user",
|
|
1221
|
+
...(p.taskRunId !== undefined ? { taskRunId: p.taskRunId } : {}),
|
|
1222
|
+
});
|
|
1223
|
+
return reply(rpcResult(id, details));
|
|
1224
|
+
}
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
return reply(artifactRpcError(id, error, "revert"));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1165
1229
|
case "artifact.list": {
|
|
1166
1230
|
try {
|
|
1167
1231
|
return reply(rpcResult(id, listArtifacts(artifactHome)));
|