@nanhara/hara 0.131.1 → 0.132.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 +23 -0
- package/README.md +15 -0
- package/dist/config.js +3 -2
- package/dist/gateway/feishu.js +9 -1
- package/dist/gateway/runtime-state.js +247 -0
- package/dist/gateway/serve.js +176 -4
- package/dist/gateway/weixin.js +26 -4
- package/dist/index.js +185 -43
- package/dist/org-fleet/enroll.js +173 -24
- package/dist/security/secrets.js +2 -2
- package/dist/serve/protocol.js +7 -0
- package/dist/serve/server.js +56 -1
- package/dist/tools/web.js +234 -15
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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.132.0 — 2026-07-22 — observable chat gateways and scoped web proxies
|
|
9
|
+
|
|
10
|
+
- `hara gateway status --platform <name>` now reports redacted credential readability, the live
|
|
11
|
+
credential-scoped lease/PID, transport state, start time, last connection/poll/message, bounded error code,
|
|
12
|
+
and one focused action. The same read-only data is available to Desktop through
|
|
13
|
+
`settings.gateways.list`; no model call, chat content, credential id, token, URL, or raw transport error is
|
|
14
|
+
stored or returned. WeChat status distinguishes missing, malformed, and readable QR login state, while
|
|
15
|
+
Feishu lifecycle comes from the SDK's actual connect/reconnect/error callbacks.
|
|
16
|
+
- `web_fetch` and `web_search` now honor `HTTPS_PROXY`, `HTTP_PROXY`, lowercase equivalents, `NO_PROXY`,
|
|
17
|
+
`HARA_WEB_PROXY`, or global `~/.hara/config.json` key `proxy`. Proxy dispatch is scoped to those two web
|
|
18
|
+
tools: model/provider, Control enrollment, and chat gateway traffic are unchanged. `web_fetch` still
|
|
19
|
+
resolves and rejects internal targets before every redirect and sends the approved public IP in the
|
|
20
|
+
CONNECT tunnel, preserving its DNS-rebinding/SSRF boundary even when a proxy is used.
|
|
21
|
+
- Proxy URLs and every `*ApiKey` config value are masked by `hara config get`; invalid proxy errors do not
|
|
22
|
+
echo the supplied value. The proxy transport uses patched Undici 7.28.0, and Hono/node-server/fast-uri
|
|
23
|
+
transitive dependencies are pinned to their current security fixes.
|
|
24
|
+
- Desktop can now list, add, re-enroll, switch, check, and locally remove user-provided organization
|
|
25
|
+
connections through authenticated `settings.organizations.*` Serve methods. Enrollment rejects redirects,
|
|
26
|
+
embedded URL credentials, and non-HTTPS remote endpoints; one-time codes are never persisted or returned,
|
|
27
|
+
while scoped device tokens remain only in the private `profiles.json` store. No enterprise endpoint is
|
|
28
|
+
preconfigured by Hara.
|
|
29
|
+
- Upgrade with `npm i -g @nanhara/hara@0.132.0`.
|
|
30
|
+
|
|
8
31
|
## 0.131.1 — 2026-07-21 — authenticated macOS standalone assets
|
|
9
32
|
|
|
10
33
|
- Darwin arm64 and Intel standalone binaries are now built on their matching native macOS runners,
|
package/README.md
CHANGED
|
@@ -127,6 +127,17 @@ hara config set apiKey ...
|
|
|
127
127
|
hara config set model ...
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
**Proxy for web tools in mainland/restricted networks** — only `web_fetch` and `web_search` use it:
|
|
131
|
+
```bash
|
|
132
|
+
export HTTPS_PROXY=http://127.0.0.1:7890
|
|
133
|
+
# or persist the same endpoint privately:
|
|
134
|
+
hara config set proxy http://127.0.0.1:7890
|
|
135
|
+
```
|
|
136
|
+
`HTTP_PROXY`, lowercase variants, `NO_PROXY`, and `HARA_WEB_PROXY` are supported. Model/provider calls,
|
|
137
|
+
organization enrollment, and chat gateways are not silently redirected. Even through a proxy, `web_fetch`
|
|
138
|
+
pins each DNS-approved public IP and rechecks redirects; authenticated proxy URLs are masked by
|
|
139
|
+
`hara config get`.
|
|
140
|
+
|
|
130
141
|
**Vision** — hara **auto-detects** whether your main model can see images. A vision model (Claude, gpt-4o,
|
|
131
142
|
qwen-vl, glm-4v…) gets pasted images **inline**. For a **text-only** model (DeepSeek, coding models), set a
|
|
132
143
|
describer — the "eyes" — and hara OCRs/describes each pasted image into text first:
|
|
@@ -183,6 +194,7 @@ hara --sandbox workspace-write # confine shell writes to the project (macOS Se
|
|
|
183
194
|
hara --cwd /path/to/project # explicitly select a workspace without changing your shell directory
|
|
184
195
|
hara -c # resume the most recent session in this directory
|
|
185
196
|
hara --profile work # use a named profile from ~/.hara/config.json
|
|
197
|
+
hara profile add work --gateway https://control.example.com --code <one-time-code> # user-added org route
|
|
186
198
|
hara -m glm-5 # pick a model
|
|
187
199
|
```
|
|
188
200
|
|
|
@@ -331,6 +343,9 @@ start an external tool server.
|
|
|
331
343
|
**Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
|
|
332
344
|
`~/.hara/config.json` for named config overlays. Project `.hara/config.json` files get the safe preference
|
|
333
345
|
allowlist above; project-specific routing requires `HARA_TRUST_PROJECT_CONFIG=1` before launch.
|
|
346
|
+
Organization connections are never prefilled: add one with `hara profile add <id> --gateway <https-url>
|
|
347
|
+
--code <one-time-code>` or in Desktop's AI & models settings. The registration code is exchanged once and
|
|
348
|
+
discarded; only the scoped, revocable device credential is retained in the private `~/.hara/profiles.json`.
|
|
334
349
|
`.hara-profile` identity pins are read no-follow with size, single-inode, and hard-link checks; pin updates use
|
|
335
350
|
an atomic compare-and-swap, and invalid-pin warnings never echo file contents or paths. A Git-tracked pin is
|
|
336
351
|
repository-controlled and ignored by default; local untracked pins created with `hara profile pin` work
|
package/dist/config.js
CHANGED
|
@@ -76,7 +76,7 @@ export function providerCatalog() {
|
|
|
76
76
|
...(PROVIDER_DEFAULTS[id].baseURL ? { defaultBaseURL: PROVIDER_DEFAULTS[id].baseURL } : {}),
|
|
77
77
|
}));
|
|
78
78
|
}
|
|
79
|
-
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
79
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "runTimeoutMs", "maxAgentRounds", "vimMode", "autoCompact", "fileCheckpoints", "updateCheck", "proxy", "fallbackModel", "fallbackProvider", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
80
80
|
export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
|
|
81
81
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
82
82
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
@@ -492,6 +492,7 @@ export function loadConfig(opts = {}) {
|
|
|
492
492
|
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
493
493
|
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
494
494
|
const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
|
|
495
|
+
const proxy = typeof merged.proxy === "string" && merged.proxy.trim() ? merged.proxy.trim() : undefined;
|
|
495
496
|
const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
|
|
496
497
|
const requestedFallbackProvider = nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider;
|
|
497
498
|
const fallbackProvider = isProviderId(requestedFallbackProvider) ? requestedFallbackProvider : undefined;
|
|
@@ -501,7 +502,7 @@ export function loadConfig(opts = {}) {
|
|
|
501
502
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
|
|
502
503
|
? reasoningRaw
|
|
503
504
|
: undefined;
|
|
504
|
-
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
|
|
505
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, runTimeoutMs, maxAgentRounds, vimMode, autoCompact, fileCheckpoints, updateCheck, proxy, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
|
|
505
506
|
}
|
|
506
507
|
export function providerEnvKey(provider) {
|
|
507
508
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
package/dist/gateway/feishu.js
CHANGED
|
@@ -234,6 +234,7 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
234
234
|
// visibility so a reconnect is observable instead of a mystery silence.
|
|
235
235
|
const wsHealth = new FeishuWsHealthMonitor();
|
|
236
236
|
let failActiveStart;
|
|
237
|
+
let runtimeObserver;
|
|
237
238
|
const wsClient = new lark.WSClient({
|
|
238
239
|
appId,
|
|
239
240
|
appSecret,
|
|
@@ -243,10 +244,12 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
243
244
|
handshakeTimeoutMs: 15_000,
|
|
244
245
|
onReady: () => {
|
|
245
246
|
wsHealth.ready();
|
|
247
|
+
runtimeObserver?.connected();
|
|
246
248
|
console.error("hara feishu: ✓ ws connected");
|
|
247
249
|
},
|
|
248
250
|
onReconnecting: () => {
|
|
249
251
|
const health = wsHealth.disconnect();
|
|
252
|
+
runtimeObserver?.error("reconnecting");
|
|
250
253
|
console.error(`hara feishu: ws disconnected #${health.total} · ${health.hourCount}/1h · ${health.dayCount}/24h · ` +
|
|
251
254
|
`previous connection ${wsDuration(health.connectedForMs)} · auto-reconnecting (SDK does not expose close code/reason)`);
|
|
252
255
|
if (health.alert) {
|
|
@@ -255,9 +258,11 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
255
258
|
}
|
|
256
259
|
},
|
|
257
260
|
onReconnected: () => {
|
|
261
|
+
runtimeObserver?.connected();
|
|
258
262
|
console.error(`hara feishu: ✓ ws reconnected after ${wsDuration(wsHealth.reconnected())}`);
|
|
259
263
|
},
|
|
260
264
|
onError: (err) => {
|
|
265
|
+
runtimeObserver?.error("transport-terminal");
|
|
261
266
|
const safe = redactKnownSecrets(String(err?.message ?? err), [appId, appSecret]).text
|
|
262
267
|
.replace(/\s+/gu, " ")
|
|
263
268
|
.slice(0, 300);
|
|
@@ -460,7 +465,8 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
460
465
|
});
|
|
461
466
|
});
|
|
462
467
|
},
|
|
463
|
-
async start(onMessage, signal, shouldDownload) {
|
|
468
|
+
async start(onMessage, signal, shouldDownload, runtime) {
|
|
469
|
+
runtimeObserver = runtime;
|
|
464
470
|
const runAbort = new AbortController();
|
|
465
471
|
const relayAbort = () => {
|
|
466
472
|
runAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("Feishu gateway cancelled"));
|
|
@@ -615,6 +621,8 @@ export function feishuAdapter(appId, appSecret) {
|
|
|
615
621
|
}
|
|
616
622
|
finally {
|
|
617
623
|
failActiveStart = undefined;
|
|
624
|
+
if (runtimeObserver === runtime)
|
|
625
|
+
runtimeObserver = undefined;
|
|
618
626
|
runAbort.abort(new Error("Feishu gateway stopped"));
|
|
619
627
|
signal.removeEventListener("abort", relayAbort);
|
|
620
628
|
try {
|
|
@@ -18,6 +18,8 @@ import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
|
18
18
|
const PLATFORM = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
19
19
|
const LOCK_BYTES = 4 * 1024;
|
|
20
20
|
const LOCK_WAIT_MS = 5_000;
|
|
21
|
+
const RUNTIME_STATUS_BYTES = 32 * 1024;
|
|
22
|
+
const RUNTIME_STATUS_FILE = /^status-([a-z0-9][a-z0-9_-]{0,63})\.json$/;
|
|
21
23
|
const STORE_BYTES = 256 * 1024;
|
|
22
24
|
const DEFAULT_TTL_MS = 60 * 60_000;
|
|
23
25
|
const DEFAULT_CAPACITY = 2_048;
|
|
@@ -341,6 +343,251 @@ export function acquireGatewayInstance(platformValue, options = {}) {
|
|
|
341
343
|
throw new Error(`gateway '${displayPlatform}' stale instance could not be reclaimed`);
|
|
342
344
|
}
|
|
343
345
|
}
|
|
346
|
+
const GATEWAY_RUNTIME_ERROR_CODES = new Set([
|
|
347
|
+
"network",
|
|
348
|
+
"platform-error",
|
|
349
|
+
"reconnecting",
|
|
350
|
+
"session-expired",
|
|
351
|
+
"transport-exited",
|
|
352
|
+
"transport-terminal",
|
|
353
|
+
]);
|
|
354
|
+
function optionalRuntimeTimestamp(value) {
|
|
355
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined;
|
|
356
|
+
}
|
|
357
|
+
function parseGatewayRuntimeStatus(snapshot, path) {
|
|
358
|
+
if (!snapshot)
|
|
359
|
+
return null;
|
|
360
|
+
let value;
|
|
361
|
+
try {
|
|
362
|
+
value = JSON.parse(snapshot.text);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
throw new Error(`invalid gateway runtime status: ${path}`);
|
|
366
|
+
}
|
|
367
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
368
|
+
throw new Error(`invalid gateway runtime status: ${path}`);
|
|
369
|
+
}
|
|
370
|
+
const record = value;
|
|
371
|
+
const state = record.state;
|
|
372
|
+
const error = record.lastErrorCode;
|
|
373
|
+
if (record.version !== 1
|
|
374
|
+
|| typeof record.platform !== "string"
|
|
375
|
+
|| checkedPlatform(record.platform) !== record.platform
|
|
376
|
+
|| !Number.isSafeInteger(record.pid)
|
|
377
|
+
|| record.pid <= 0
|
|
378
|
+
|| !optionalRuntimeTimestamp(record.startedAt)
|
|
379
|
+
|| !optionalRuntimeTimestamp(record.updatedAt)
|
|
380
|
+
|| !["starting", "connected", "degraded", "stopped", "failed"].includes(state)
|
|
381
|
+
|| (error !== undefined && !GATEWAY_RUNTIME_ERROR_CODES.has(error))) {
|
|
382
|
+
throw new Error(`invalid gateway runtime status: ${path}`);
|
|
383
|
+
}
|
|
384
|
+
for (const timestamp of [
|
|
385
|
+
record.lastConnectedAt,
|
|
386
|
+
record.lastPollAt,
|
|
387
|
+
record.lastMessageAt,
|
|
388
|
+
record.lastErrorAt,
|
|
389
|
+
]) {
|
|
390
|
+
if (timestamp !== undefined && !optionalRuntimeTimestamp(timestamp)) {
|
|
391
|
+
throw new Error(`invalid gateway runtime status: ${path}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return record;
|
|
395
|
+
}
|
|
396
|
+
function gatewayRuntimeStatusPath(directory, runtimeScope) {
|
|
397
|
+
return join(directory, `status-${checkedPlatform(runtimeScope)}.json`);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Credential-scoped, redacted health writer for one live gateway. It stores only lifecycle timestamps and
|
|
401
|
+
* bounded error codes: never tokens, app ids, URLs, chat ids, message text, or raw transport errors.
|
|
402
|
+
*/
|
|
403
|
+
export class GatewayRuntimeReporter {
|
|
404
|
+
dir;
|
|
405
|
+
path;
|
|
406
|
+
now;
|
|
407
|
+
record;
|
|
408
|
+
tail = Promise.resolve();
|
|
409
|
+
writeWarningShown = false;
|
|
410
|
+
constructor(dir, path, now, record) {
|
|
411
|
+
this.dir = dir;
|
|
412
|
+
this.path = path;
|
|
413
|
+
this.now = now;
|
|
414
|
+
this.record = record;
|
|
415
|
+
}
|
|
416
|
+
static async open(runtimeScopeValue, platformValue, options = {}) {
|
|
417
|
+
const runtimeScope = checkedPlatform(runtimeScopeValue);
|
|
418
|
+
const platform = checkedPlatform(platformValue);
|
|
419
|
+
const dir = stateDirectory(options.home ?? homedir());
|
|
420
|
+
const path = gatewayRuntimeStatusPath(dir, runtimeScope);
|
|
421
|
+
const previous = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(path, RUNTIME_STATUS_BYTES), path);
|
|
422
|
+
if (previous && previous.platform !== platform) {
|
|
423
|
+
throw new Error("gateway runtime status belongs to a different platform");
|
|
424
|
+
}
|
|
425
|
+
const now = options.now ?? Date.now;
|
|
426
|
+
const startedAt = now();
|
|
427
|
+
const reporter = new GatewayRuntimeReporter(dir, path, now, {
|
|
428
|
+
version: 1,
|
|
429
|
+
platform,
|
|
430
|
+
pid: process.pid,
|
|
431
|
+
startedAt,
|
|
432
|
+
updatedAt: startedAt,
|
|
433
|
+
state: "starting",
|
|
434
|
+
...(previous?.lastConnectedAt ? { lastConnectedAt: previous.lastConnectedAt } : {}),
|
|
435
|
+
...(previous?.lastPollAt ? { lastPollAt: previous.lastPollAt } : {}),
|
|
436
|
+
...(previous?.lastMessageAt ? { lastMessageAt: previous.lastMessageAt } : {}),
|
|
437
|
+
...(previous?.lastErrorAt ? { lastErrorAt: previous.lastErrorAt } : {}),
|
|
438
|
+
...(previous?.lastErrorCode ? { lastErrorCode: previous.lastErrorCode } : {}),
|
|
439
|
+
});
|
|
440
|
+
reporter.scheduleWrite();
|
|
441
|
+
await reporter.flush();
|
|
442
|
+
return reporter;
|
|
443
|
+
}
|
|
444
|
+
scheduleWrite() {
|
|
445
|
+
this.record.updatedAt = this.now();
|
|
446
|
+
const payload = JSON.stringify({ ...this.record }, null, 2) + "\n";
|
|
447
|
+
this.tail = this.tail
|
|
448
|
+
.then(async () => {
|
|
449
|
+
const snapshot = await readPrivateStateFileSnapshot(this.path, RUNTIME_STATUS_BYTES);
|
|
450
|
+
const boundary = bindHaraPrivateStateWritePath(this.path, this.dir, "write gateway runtime status");
|
|
451
|
+
await atomicWriteText(this.path, payload, {
|
|
452
|
+
expected: snapshot?.text ?? null,
|
|
453
|
+
expectedIdentity: snapshot
|
|
454
|
+
? { dev: snapshot.dev, ino: snapshot.ino, mode: snapshot.mode, nlink: snapshot.nlink }
|
|
455
|
+
: undefined,
|
|
456
|
+
mode: 0o600,
|
|
457
|
+
boundary,
|
|
458
|
+
});
|
|
459
|
+
})
|
|
460
|
+
.catch(() => {
|
|
461
|
+
if (!this.writeWarningShown) {
|
|
462
|
+
this.writeWarningShown = true;
|
|
463
|
+
console.error("hara gateway: runtime status could not be persisted; gateway operation continues");
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
connected() {
|
|
468
|
+
const now = this.now();
|
|
469
|
+
this.record.state = "connected";
|
|
470
|
+
this.record.lastConnectedAt = now;
|
|
471
|
+
this.scheduleWrite();
|
|
472
|
+
}
|
|
473
|
+
poll() {
|
|
474
|
+
const now = this.now();
|
|
475
|
+
this.record.state = "connected";
|
|
476
|
+
this.record.lastConnectedAt = now;
|
|
477
|
+
this.record.lastPollAt = now;
|
|
478
|
+
this.scheduleWrite();
|
|
479
|
+
}
|
|
480
|
+
message() {
|
|
481
|
+
const now = this.now();
|
|
482
|
+
this.record.state = "connected";
|
|
483
|
+
this.record.lastConnectedAt = now;
|
|
484
|
+
this.record.lastMessageAt = now;
|
|
485
|
+
this.scheduleWrite();
|
|
486
|
+
}
|
|
487
|
+
error(code) {
|
|
488
|
+
if (!GATEWAY_RUNTIME_ERROR_CODES.has(code))
|
|
489
|
+
return;
|
|
490
|
+
this.record.state = code === "transport-terminal" || code === "transport-exited" ? "failed" : "degraded";
|
|
491
|
+
this.record.lastErrorAt = this.now();
|
|
492
|
+
this.record.lastErrorCode = code;
|
|
493
|
+
this.scheduleWrite();
|
|
494
|
+
}
|
|
495
|
+
stopped(failed = false) {
|
|
496
|
+
this.record.state = failed ? "failed" : "stopped";
|
|
497
|
+
this.scheduleWrite();
|
|
498
|
+
}
|
|
499
|
+
async flush() {
|
|
500
|
+
await this.tail;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Aggregate all credential-scoped instances of one platform without exposing their scope hashes or ids. */
|
|
504
|
+
export async function inspectGatewayRuntime(platformValue, runtimeScopes = [], options = {}) {
|
|
505
|
+
const platform = checkedPlatform(platformValue);
|
|
506
|
+
const dir = stateDirectory(options.home ?? homedir());
|
|
507
|
+
const scopes = new Set(runtimeScopes.map(checkedPlatform));
|
|
508
|
+
let unreadable = false;
|
|
509
|
+
for (const name of readdirSync(dir)) {
|
|
510
|
+
const match = RUNTIME_STATUS_FILE.exec(name);
|
|
511
|
+
if (!match)
|
|
512
|
+
continue;
|
|
513
|
+
const scope = match[1];
|
|
514
|
+
try {
|
|
515
|
+
const record = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(join(dir, name), RUNTIME_STATUS_BYTES), join(dir, name));
|
|
516
|
+
if (record?.platform === platform)
|
|
517
|
+
scopes.add(scope);
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
if (scope === platform || scope.startsWith(`${platform}-`))
|
|
521
|
+
unreadable = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const pidAlive = options.pidAlive ?? defaultPidAlive;
|
|
525
|
+
const processIdentity = options.processIdentity ?? defaultProcessIdentity;
|
|
526
|
+
const candidates = [];
|
|
527
|
+
for (const scope of scopes) {
|
|
528
|
+
let record = null;
|
|
529
|
+
let lease = null;
|
|
530
|
+
try {
|
|
531
|
+
record = parseGatewayRuntimeStatus(await readPrivateStateFileSnapshot(gatewayRuntimeStatusPath(dir, scope), RUNTIME_STATUS_BYTES), gatewayRuntimeStatusPath(dir, scope));
|
|
532
|
+
if (record && record.platform !== platform)
|
|
533
|
+
continue;
|
|
534
|
+
lease = readLease(join(dir, `instance-${scope}.lock`), scope);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
unreadable = true;
|
|
538
|
+
}
|
|
539
|
+
candidates.push({
|
|
540
|
+
scope,
|
|
541
|
+
record,
|
|
542
|
+
lease,
|
|
543
|
+
live: Boolean(lease && leaseOwnerAlive(lease.record, pidAlive, processIdentity)),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const live = candidates
|
|
547
|
+
.filter((candidate) => candidate.live && candidate.lease)
|
|
548
|
+
.sort((left, right) => (right.lease?.record.startedAt ?? 0) - (left.lease?.record.startedAt ?? 0));
|
|
549
|
+
const selected = live[0]
|
|
550
|
+
?? candidates
|
|
551
|
+
.filter((candidate) => candidate.record)
|
|
552
|
+
.sort((left, right) => (right.record?.updatedAt ?? 0) - (left.record?.updatedAt ?? 0))[0];
|
|
553
|
+
const matchingRecord = selected?.record
|
|
554
|
+
&& (!selected.lease || selected.record.pid === selected.lease.record.pid)
|
|
555
|
+
? selected.record
|
|
556
|
+
: null;
|
|
557
|
+
const history = matchingRecord
|
|
558
|
+
? {
|
|
559
|
+
...(matchingRecord.lastConnectedAt ? { lastConnectedAt: matchingRecord.lastConnectedAt } : {}),
|
|
560
|
+
...(matchingRecord.lastPollAt ? { lastPollAt: matchingRecord.lastPollAt } : {}),
|
|
561
|
+
...(matchingRecord.lastMessageAt ? { lastMessageAt: matchingRecord.lastMessageAt } : {}),
|
|
562
|
+
...(matchingRecord.lastErrorAt ? { lastErrorAt: matchingRecord.lastErrorAt } : {}),
|
|
563
|
+
...(matchingRecord.lastErrorCode ? { lastErrorCode: matchingRecord.lastErrorCode } : {}),
|
|
564
|
+
}
|
|
565
|
+
: {};
|
|
566
|
+
if (live.length && selected?.lease) {
|
|
567
|
+
return {
|
|
568
|
+
running: true,
|
|
569
|
+
runningInstances: live.length,
|
|
570
|
+
state: matchingRecord?.state ?? "unknown",
|
|
571
|
+
pid: selected.lease.record.pid,
|
|
572
|
+
startedAt: selected.lease.record.startedAt,
|
|
573
|
+
...history,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (selected?.record) {
|
|
577
|
+
return {
|
|
578
|
+
running: false,
|
|
579
|
+
runningInstances: 0,
|
|
580
|
+
state: selected.record.state === "failed" ? "failed" : "stopped",
|
|
581
|
+
startedAt: selected.record.startedAt,
|
|
582
|
+
...history,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
running: false,
|
|
587
|
+
runningInstances: 0,
|
|
588
|
+
state: unreadable ? "unreadable" : "unknown",
|
|
589
|
+
};
|
|
590
|
+
}
|
|
344
591
|
function parseProcessedStore(snapshot, path) {
|
|
345
592
|
if (!snapshot)
|
|
346
593
|
return [];
|
package/dist/gateway/serve.js
CHANGED
|
@@ -19,7 +19,7 @@ import { join, resolve } from "node:path";
|
|
|
19
19
|
import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
20
20
|
import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
|
|
21
21
|
import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
|
|
22
|
-
import { acquireGatewayInstance, gatewayRuntimeScope, GatewayFlowRunStore, GatewayInboundTracker, GatewayMessageDeduper, GatewayRunOutcomeStore, } from "./runtime-state.js";
|
|
22
|
+
import { acquireGatewayInstance, gatewayRuntimeScope, GatewayFlowRunStore, GatewayInboundTracker, GatewayMessageDeduper, GatewayRuntimeReporter, GatewayRunOutcomeStore, inspectGatewayRuntime, } from "./runtime-state.js";
|
|
23
23
|
/** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
|
|
24
24
|
export function parseCommand(text) {
|
|
25
25
|
const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
|
|
@@ -485,6 +485,154 @@ function runFlowAgent(prompt, _cwd, schema, signal) {
|
|
|
485
485
|
}
|
|
486
486
|
/** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
|
|
487
487
|
export { weixinLogin } from "./weixin.js";
|
|
488
|
+
export const GATEWAY_PLATFORMS = [
|
|
489
|
+
"telegram",
|
|
490
|
+
"weixin",
|
|
491
|
+
"discord",
|
|
492
|
+
"feishu",
|
|
493
|
+
"slack",
|
|
494
|
+
"mattermost",
|
|
495
|
+
"matrix",
|
|
496
|
+
"dingtalk",
|
|
497
|
+
"wecom",
|
|
498
|
+
"signal",
|
|
499
|
+
];
|
|
500
|
+
const GATEWAY_LABELS = {
|
|
501
|
+
telegram: "Telegram",
|
|
502
|
+
weixin: "WeChat",
|
|
503
|
+
discord: "Discord",
|
|
504
|
+
feishu: "Feishu",
|
|
505
|
+
slack: "Slack",
|
|
506
|
+
mattermost: "Mattermost",
|
|
507
|
+
matrix: "Matrix",
|
|
508
|
+
dingtalk: "DingTalk",
|
|
509
|
+
wecom: "WeCom",
|
|
510
|
+
signal: "Signal",
|
|
511
|
+
};
|
|
512
|
+
function gatewayPlatform(value) {
|
|
513
|
+
const platform = canonicalGatewayPlatform(value);
|
|
514
|
+
if (!GATEWAY_PLATFORMS.includes(platform)) {
|
|
515
|
+
throw new Error(`unsupported gateway platform '${value}'`);
|
|
516
|
+
}
|
|
517
|
+
return platform;
|
|
518
|
+
}
|
|
519
|
+
function environmentConfiguration(values, runtimeScope, missingHint) {
|
|
520
|
+
const present = values.filter((value) => Boolean(value?.trim())).length;
|
|
521
|
+
return present === values.length
|
|
522
|
+
? { state: "ready", runtimeScope: runtimeScope(), missingHint }
|
|
523
|
+
: { state: present === 0 ? "missing" : "incomplete", missingHint };
|
|
524
|
+
}
|
|
525
|
+
async function inspectGatewayConfiguration(platform) {
|
|
526
|
+
if (platform === "weixin") {
|
|
527
|
+
const { inspectWeixinCredentials } = await import("./weixin.js");
|
|
528
|
+
const inspected = inspectWeixinCredentials();
|
|
529
|
+
if (inspected.state === "ready") {
|
|
530
|
+
return {
|
|
531
|
+
state: "ready",
|
|
532
|
+
runtimeScope: gatewayRuntimeScope("weixin", inspected.credentials.user_id),
|
|
533
|
+
missingHint: "run `hara gateway --platform weixin --login`",
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
state: inspected.state,
|
|
538
|
+
missingHint: inspected.state === "unreadable"
|
|
539
|
+
? "repair the private WeChat login state, then log in again"
|
|
540
|
+
: "run `hara gateway --platform weixin --login`",
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (platform === "telegram") {
|
|
544
|
+
const token = process.env.HARA_TELEGRAM_TOKEN;
|
|
545
|
+
return environmentConfiguration([token], () => gatewayRuntimeScope("telegram", token), "set HARA_TELEGRAM_TOKEN");
|
|
546
|
+
}
|
|
547
|
+
if (platform === "discord") {
|
|
548
|
+
const token = process.env.HARA_DISCORD_TOKEN;
|
|
549
|
+
return environmentConfiguration([token], () => gatewayRuntimeScope("discord", token), "set HARA_DISCORD_TOKEN");
|
|
550
|
+
}
|
|
551
|
+
if (platform === "feishu") {
|
|
552
|
+
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
553
|
+
const secret = process.env.HARA_FEISHU_APP_SECRET;
|
|
554
|
+
return environmentConfiguration([appId, secret], () => gatewayRuntimeScope("feishu", appId), "set HARA_FEISHU_APP_ID and HARA_FEISHU_APP_SECRET");
|
|
555
|
+
}
|
|
556
|
+
if (platform === "slack") {
|
|
557
|
+
const appToken = process.env.HARA_SLACK_APP_TOKEN;
|
|
558
|
+
const botToken = process.env.HARA_SLACK_BOT_TOKEN;
|
|
559
|
+
return environmentConfiguration([appToken, botToken], () => gatewayRuntimeScope("slack", appToken), "set HARA_SLACK_APP_TOKEN and HARA_SLACK_BOT_TOKEN");
|
|
560
|
+
}
|
|
561
|
+
if (platform === "mattermost") {
|
|
562
|
+
const url = process.env.HARA_MATTERMOST_URL;
|
|
563
|
+
const token = process.env.HARA_MATTERMOST_TOKEN;
|
|
564
|
+
return environmentConfiguration([url, token], () => gatewayRuntimeScope("mattermost", `${url}\0${token}`), "set HARA_MATTERMOST_URL and HARA_MATTERMOST_TOKEN");
|
|
565
|
+
}
|
|
566
|
+
if (platform === "matrix") {
|
|
567
|
+
const homeserver = process.env.HARA_MATRIX_HOMESERVER;
|
|
568
|
+
const token = process.env.HARA_MATRIX_TOKEN;
|
|
569
|
+
const userId = process.env.HARA_MATRIX_USER_ID;
|
|
570
|
+
return environmentConfiguration([homeserver, token, userId], () => gatewayRuntimeScope("matrix", `${homeserver}\0${userId}`), "set HARA_MATRIX_HOMESERVER, HARA_MATRIX_TOKEN, and HARA_MATRIX_USER_ID");
|
|
571
|
+
}
|
|
572
|
+
if (platform === "dingtalk") {
|
|
573
|
+
const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
|
|
574
|
+
const secret = process.env.HARA_DINGTALK_CLIENT_SECRET;
|
|
575
|
+
return environmentConfiguration([clientId, secret], () => gatewayRuntimeScope("dingtalk", clientId), "set HARA_DINGTALK_CLIENT_ID and HARA_DINGTALK_CLIENT_SECRET");
|
|
576
|
+
}
|
|
577
|
+
if (platform === "wecom") {
|
|
578
|
+
const botId = process.env.HARA_WECOM_BOT_ID;
|
|
579
|
+
const secret = process.env.HARA_WECOM_SECRET;
|
|
580
|
+
return environmentConfiguration([botId, secret], () => gatewayRuntimeScope("wecom", botId), "set HARA_WECOM_BOT_ID and HARA_WECOM_SECRET");
|
|
581
|
+
}
|
|
582
|
+
const rpcUrl = process.env.HARA_SIGNAL_RPC_URL;
|
|
583
|
+
const number = process.env.HARA_SIGNAL_NUMBER;
|
|
584
|
+
return environmentConfiguration([rpcUrl, number], () => gatewayRuntimeScope("signal", `${rpcUrl}\0${number}`), "set HARA_SIGNAL_RPC_URL and HARA_SIGNAL_NUMBER");
|
|
585
|
+
}
|
|
586
|
+
function gatewayRecommendation(platform, configuration, runtime) {
|
|
587
|
+
const unresolvedError = runtime.lastErrorAt !== undefined
|
|
588
|
+
&& runtime.state !== "connected"
|
|
589
|
+
&& (runtime.lastConnectedAt === undefined || runtime.lastErrorAt >= runtime.lastConnectedAt);
|
|
590
|
+
if (unresolvedError && runtime.lastErrorCode === "session-expired" && platform === "weixin") {
|
|
591
|
+
return "re-login with `hara gateway --platform weixin --login`, then restart the gateway";
|
|
592
|
+
}
|
|
593
|
+
if (runtime.state === "degraded" || runtime.state === "failed" || runtime.state === "unreadable") {
|
|
594
|
+
return "inspect the redacted gateway log and restart the gateway if the error persists";
|
|
595
|
+
}
|
|
596
|
+
if (runtime.running)
|
|
597
|
+
return "none";
|
|
598
|
+
if (configuration.state !== "ready")
|
|
599
|
+
return configuration.missingHint;
|
|
600
|
+
return `run \`hara gateway --platform ${platform}\``;
|
|
601
|
+
}
|
|
602
|
+
/** Read-only, redacted gateway diagnosis shared by the CLI and Desktop serve protocol. */
|
|
603
|
+
export async function gatewayStatus(platformValue) {
|
|
604
|
+
const platform = gatewayPlatform(platformValue);
|
|
605
|
+
const configuration = await inspectGatewayConfiguration(platform);
|
|
606
|
+
const runtime = await inspectGatewayRuntime(platform, configuration.runtimeScope ? [configuration.runtimeScope] : []);
|
|
607
|
+
// A long-running gateway can own environment-only credentials that are intentionally unavailable to the
|
|
608
|
+
// Desktop/CLI status process. Report that boundary explicitly instead of contradicting a live connection
|
|
609
|
+
// with "missing credentials". WeChat uses a shared private state file, so unreadability there stays visible.
|
|
610
|
+
const exposedConfiguration = platform !== "weixin"
|
|
611
|
+
&& runtime.running
|
|
612
|
+
&& configuration.state !== "ready"
|
|
613
|
+
? "process-only"
|
|
614
|
+
: configuration.state;
|
|
615
|
+
return {
|
|
616
|
+
platform,
|
|
617
|
+
label: GATEWAY_LABELS[platform],
|
|
618
|
+
configuration: exposedConfiguration,
|
|
619
|
+
configured: exposedConfiguration === "ready" || exposedConfiguration === "process-only" || runtime.running,
|
|
620
|
+
running: runtime.running,
|
|
621
|
+
runningInstances: runtime.runningInstances,
|
|
622
|
+
runtimeState: runtime.state,
|
|
623
|
+
...(runtime.pid ? { pid: runtime.pid } : {}),
|
|
624
|
+
...(runtime.startedAt ? { startedAt: runtime.startedAt } : {}),
|
|
625
|
+
...(runtime.lastConnectedAt ? { lastConnectedAt: runtime.lastConnectedAt } : {}),
|
|
626
|
+
...(runtime.lastPollAt ? { lastPollAt: runtime.lastPollAt } : {}),
|
|
627
|
+
...(runtime.lastMessageAt ? { lastMessageAt: runtime.lastMessageAt } : {}),
|
|
628
|
+
...(runtime.lastErrorAt ? { lastErrorAt: runtime.lastErrorAt } : {}),
|
|
629
|
+
...(runtime.lastErrorCode ? { lastErrorCode: runtime.lastErrorCode } : {}),
|
|
630
|
+
recommendation: gatewayRecommendation(platform, configuration, runtime),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
export async function listGatewayStatuses(platforms = GATEWAY_PLATFORMS) {
|
|
634
|
+
return Promise.all(platforms.map((platform) => gatewayStatus(platform)));
|
|
635
|
+
}
|
|
488
636
|
async function buildAdapter(platform) {
|
|
489
637
|
if (platform === "weixin") {
|
|
490
638
|
const { loadWeixinCreds, weixinAdapter } = await import("./weixin.js");
|
|
@@ -694,6 +842,13 @@ export async function runGateway(opts) {
|
|
|
694
842
|
// requested spelling only for startup/config hints; all persisted/routable identities are canonical.
|
|
695
843
|
const platform = adapter.name || canonicalGatewayPlatform(requestedPlatform);
|
|
696
844
|
const releaseInstance = acquireGatewayInstance(runtimeScope, { displayPlatform: platform });
|
|
845
|
+
let runtimeReporter;
|
|
846
|
+
try {
|
|
847
|
+
runtimeReporter = await GatewayRuntimeReporter.open(runtimeScope, platform);
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
console.error("hara gateway: runtime status is unavailable; gateway operation continues");
|
|
851
|
+
}
|
|
697
852
|
let messageDeduper;
|
|
698
853
|
let flowEffectReceipts;
|
|
699
854
|
let flowRuns;
|
|
@@ -709,6 +864,9 @@ export async function runGateway(opts) {
|
|
|
709
864
|
runOutcomes = await GatewayRunOutcomeStore.open(gatewayRuntimeScope("run-cache", runtimeScope));
|
|
710
865
|
}
|
|
711
866
|
catch (error) {
|
|
867
|
+
runtimeReporter?.error("transport-exited");
|
|
868
|
+
runtimeReporter?.stopped(true);
|
|
869
|
+
await runtimeReporter?.flush();
|
|
712
870
|
releaseInstance();
|
|
713
871
|
throw error;
|
|
714
872
|
}
|
|
@@ -745,6 +903,7 @@ export async function runGateway(opts) {
|
|
|
745
903
|
process.once("SIGTERM", stop);
|
|
746
904
|
ac.signal.addEventListener("abort", closeQueue, { once: true });
|
|
747
905
|
console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
|
|
906
|
+
let transportFailed = false;
|
|
748
907
|
try {
|
|
749
908
|
await pruneStaleMedia(platform).catch((error) => {
|
|
750
909
|
console.error(`hara gateway: media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -753,6 +912,7 @@ export async function runGateway(opts) {
|
|
|
753
912
|
try {
|
|
754
913
|
if (ac.signal.aborted)
|
|
755
914
|
return;
|
|
915
|
+
runtimeReporter?.message();
|
|
756
916
|
let existingRunOutcome = null;
|
|
757
917
|
let outcomeLoadError;
|
|
758
918
|
try {
|
|
@@ -1174,7 +1334,12 @@ export async function runGateway(opts) {
|
|
|
1174
1334
|
console.error(`hara gateway: inbound media cleanup failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
1175
1335
|
});
|
|
1176
1336
|
}
|
|
1177
|
-
})()), ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist));
|
|
1337
|
+
})()), ac.signal, (m) => shouldDownloadInboundMedia(m, allowlist), runtimeReporter);
|
|
1338
|
+
}
|
|
1339
|
+
catch (error) {
|
|
1340
|
+
transportFailed = true;
|
|
1341
|
+
runtimeReporter?.error("transport-exited");
|
|
1342
|
+
throw error;
|
|
1178
1343
|
}
|
|
1179
1344
|
finally {
|
|
1180
1345
|
stop();
|
|
@@ -1184,11 +1349,18 @@ export async function runGateway(opts) {
|
|
|
1184
1349
|
ac.signal.removeEventListener("abort", closeQueue);
|
|
1185
1350
|
process.off("SIGINT", stop);
|
|
1186
1351
|
process.off("SIGTERM", stop);
|
|
1187
|
-
if (handlersDrained)
|
|
1352
|
+
if (handlersDrained) {
|
|
1188
1353
|
releaseInstance();
|
|
1354
|
+
runtimeReporter?.stopped(transportFailed);
|
|
1355
|
+
await runtimeReporter?.flush();
|
|
1356
|
+
}
|
|
1189
1357
|
else {
|
|
1190
1358
|
console.error("hara gateway: shutdown timed out with inbound work still active; instance lease retained until callbacks finish.");
|
|
1191
|
-
void inboundHandlers.waitForIdle().then(
|
|
1359
|
+
void inboundHandlers.waitForIdle().then(async () => {
|
|
1360
|
+
releaseInstance();
|
|
1361
|
+
runtimeReporter?.stopped(transportFailed);
|
|
1362
|
+
await runtimeReporter?.flush();
|
|
1363
|
+
}, (error) => console.error(`hara gateway: could not release the deferred instance lease — ${error instanceof Error ? error.message : String(error)}`));
|
|
1192
1364
|
}
|
|
1193
1365
|
}
|
|
1194
1366
|
}
|