@nanhara/hara 0.127.2 → 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 +41 -0
- package/README.md +9 -0
- package/dist/agent/loop.js +6 -6
- package/dist/agent/repeat-guard.js +33 -3
- package/dist/artifacts/store.js +792 -0
- 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 +113 -8
- package/dist/serve/protocol.js +9 -0
- package/dist/serve/server.js +131 -0
- package/package.json +1 -1
|
@@ -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>");
|
|
@@ -8,9 +8,15 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
9
|
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
10
|
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
11
|
-
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts"]);
|
|
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
|
}
|
|
@@ -300,17 +306,113 @@ function syncPrivateDirectory(path) {
|
|
|
300
306
|
/* Directory fsync is not portable; the staged file itself is always fsynced. */
|
|
301
307
|
}
|
|
302
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Crash-safe immutable binary creation below a bound private-state directory.
|
|
311
|
+
*
|
|
312
|
+
* Artifact revisions use this instead of the text replacement writer: revision content is immutable,
|
|
313
|
+
* may be a binary Office file, and must never replace an entry that appeared concurrently. The staged
|
|
314
|
+
* inode is fsync'd, hard-linked into place with create-if-absent semantics, and checked again after the
|
|
315
|
+
* temporary name is removed.
|
|
316
|
+
*/
|
|
317
|
+
export function writePrivateStateBytesOnceSync(binding, bytes) {
|
|
318
|
+
const { directory, path } = binding;
|
|
319
|
+
verifyPrivateDirectory(directory);
|
|
320
|
+
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
321
|
+
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
lstatSync(path);
|
|
325
|
+
throw new Error(`private Hara state file already exists: '${path}'`);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error?.code !== "ENOENT")
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
332
|
+
let fd;
|
|
333
|
+
let staged;
|
|
334
|
+
try {
|
|
335
|
+
fd = openSync(temp, "wx", 0o600);
|
|
336
|
+
writeFileSync(fd, bytes);
|
|
337
|
+
try {
|
|
338
|
+
fchmodSync(fd, 0o600);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (process.platform !== "win32")
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
fsyncSync(fd);
|
|
345
|
+
const stagedInfo = fstatSync(fd);
|
|
346
|
+
if (!stagedInfo.isFile() || stagedInfo.nlink !== 1) {
|
|
347
|
+
throw new Error("unsafe private Hara binary staging inode");
|
|
348
|
+
}
|
|
349
|
+
staged = {
|
|
350
|
+
dev: stagedInfo.dev,
|
|
351
|
+
ino: stagedInfo.ino,
|
|
352
|
+
mode: stagedInfo.mode & 0o777,
|
|
353
|
+
nlink: stagedInfo.nlink,
|
|
354
|
+
};
|
|
355
|
+
closeSync(fd);
|
|
356
|
+
fd = undefined;
|
|
357
|
+
verifyPrivateDirectory(directory);
|
|
358
|
+
try {
|
|
359
|
+
linkSync(temp, path);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (error?.code === "EEXIST") {
|
|
363
|
+
throw new Error(`private Hara state file changed before create: '${path}'`);
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
|
|
368
|
+
if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
|
|
369
|
+
throw new Error(`private Hara binary staging identity changed during commit: '${path}'`
|
|
370
|
+
+ `; expected=${JSON.stringify(linkedStaged)}`
|
|
371
|
+
+ `; staging=${privateFileIdentitySummary(temp)}`
|
|
372
|
+
+ `; target=${privateFileIdentitySummary(path)}`);
|
|
373
|
+
}
|
|
374
|
+
unlinkSync(temp);
|
|
375
|
+
const committed = lstatSync(path);
|
|
376
|
+
if (!committed.isFile()
|
|
377
|
+
|| committed.isSymbolicLink()
|
|
378
|
+
|| !sameOpenedFileIdentity(committed, staged)
|
|
379
|
+
|| committed.nlink !== 1
|
|
380
|
+
|| (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
|
|
381
|
+
throw new Error(`private Hara state file changed during commit: '${path}'`);
|
|
382
|
+
verifyPrivateDirectory(directory);
|
|
383
|
+
syncPrivateDirectory(directory.path);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
if (fd !== undefined)
|
|
387
|
+
try {
|
|
388
|
+
closeSync(fd);
|
|
389
|
+
}
|
|
390
|
+
catch { /* preserve original error */ }
|
|
391
|
+
if (staged) {
|
|
392
|
+
try {
|
|
393
|
+
if (samePrivateFile(temp, staged))
|
|
394
|
+
unlinkSync(temp);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* A changed entry is retained rather than unlinking an attacker-supplied replacement. */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
303
402
|
/**
|
|
304
403
|
* Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
|
|
305
404
|
* are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
|
|
306
405
|
*/
|
|
307
|
-
export function writePrivateStateFileSync(binding, text) {
|
|
406
|
+
export function writePrivateStateFileSync(binding, text, options = {}) {
|
|
308
407
|
const { directory, path } = binding;
|
|
309
408
|
verifyPrivateDirectory(directory);
|
|
310
409
|
if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
|
|
311
410
|
throw new Error(`private Hara state file is outside '${directory.path}'`);
|
|
312
411
|
}
|
|
313
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
|
+
}
|
|
314
416
|
verifyPrivateDirectory(directory);
|
|
315
417
|
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
316
418
|
let fd;
|
|
@@ -345,8 +447,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
345
447
|
linkSync(temp, path);
|
|
346
448
|
}
|
|
347
449
|
catch (error) {
|
|
348
|
-
if (error?.code === "EEXIST")
|
|
349
|
-
throw new
|
|
450
|
+
if (error?.code === "EEXIST") {
|
|
451
|
+
throw new PrivateStateConflictError(`private Hara state file changed before create: '${path}'`, { cause: error });
|
|
452
|
+
}
|
|
350
453
|
throw error;
|
|
351
454
|
}
|
|
352
455
|
}
|
|
@@ -357,8 +460,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
357
460
|
renameSync(path, claimed);
|
|
358
461
|
}
|
|
359
462
|
catch (error) {
|
|
360
|
-
if (error?.code === "ENOENT")
|
|
361
|
-
throw new
|
|
463
|
+
if (error?.code === "ENOENT") {
|
|
464
|
+
throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`, { cause: error });
|
|
465
|
+
}
|
|
362
466
|
throw error;
|
|
363
467
|
}
|
|
364
468
|
let verifiedClaim = false;
|
|
@@ -369,8 +473,9 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
369
473
|
&& claimedSnapshot.mode === existing.mode
|
|
370
474
|
&& claimedSnapshot.nlink === existing.nlink
|
|
371
475
|
&& claimedSnapshot.text === existing.text);
|
|
372
|
-
if (!verifiedClaim)
|
|
373
|
-
throw new
|
|
476
|
+
if (!verifiedClaim) {
|
|
477
|
+
throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`);
|
|
478
|
+
}
|
|
374
479
|
}
|
|
375
480
|
catch (error) {
|
|
376
481
|
try {
|
package/dist/serve/protocol.js
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
// → {id,name,schedule}
|
|
29
29
|
// automation.toggle {id,enabled} → {id,enabled}
|
|
30
30
|
// automation.delete {id} → {id,deleted}
|
|
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}
|
|
36
|
+
// artifact.list {} → {artifacts,invalid,truncated}
|
|
37
|
+
// artifact.get {artifactId} → {artifact,currentRevision,content}
|
|
38
|
+
// artifact.revisions {artifactId} → {artifactId,revisions}
|
|
31
39
|
// session.rename {sessionId,title} → {sessionId,title}
|
|
32
40
|
// session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
|
|
33
41
|
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
|
@@ -48,6 +56,7 @@ export const ERR = {
|
|
|
48
56
|
BUSY: -32002, // requested operation conflicts with active server/session work
|
|
49
57
|
NO_SESSION: -32003, // unknown/expired sessionId
|
|
50
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
|
|
51
60
|
};
|
|
52
61
|
/** Parse one inbound text frame into a request. Returns {error} (never throws) on malformed input —
|
|
53
62
|
* the transport turns that into a PARSE/INVALID error response. */
|