@nanhara/hara 0.131.1 → 0.132.1

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.
@@ -239,16 +239,34 @@ export function weixinKnownPeers(accountId) {
239
239
  return [];
240
240
  }
241
241
  }
242
- export function loadWeixinCreds() {
242
+ function validWeixinCredentials(value) {
243
+ if (!value || typeof value !== "object" || Array.isArray(value))
244
+ return false;
245
+ const credentials = value;
246
+ return [credentials.account_id, credentials.token, credentials.base_url, credentials.user_id]
247
+ .every((part) => typeof part === "string" && part.trim().length > 0);
248
+ }
249
+ /** Read-only credential probe. The public status mapper exposes only `state`; credentials stay inside the
250
+ * gateway module and are used solely to derive the already-redacted runtime scope or start the adapter. */
251
+ export function inspectWeixinCredentials() {
243
252
  try {
244
253
  const binding = weixinStateFile("creds.json");
245
254
  const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
246
- return snapshot ? JSON.parse(snapshot.text) : null;
255
+ if (!snapshot)
256
+ return { state: "missing" };
257
+ const credentials = JSON.parse(snapshot.text);
258
+ return validWeixinCredentials(credentials)
259
+ ? { state: "ready", credentials }
260
+ : { state: "unreadable" };
247
261
  }
248
262
  catch {
249
- return null;
263
+ return { state: "unreadable" };
250
264
  }
251
265
  }
266
+ export function loadWeixinCreds() {
267
+ const inspected = inspectWeixinCredentials();
268
+ return inspected.state === "ready" ? inspected.credentials : null;
269
+ }
252
270
  function saveWeixinCreds(c) {
253
271
  writePrivateStateFileSync(weixinStateFile("creds.json"), JSON.stringify(c, null, 2) + "\n");
254
272
  }
@@ -664,7 +682,7 @@ export function weixinAdapter(creds) {
664
682
  if (!(await sendMediaFile(creds, tokenStore, String(chatId), file)))
665
683
  throw new Error(`weixin file delivery failed: ${file.safeName}`);
666
684
  },
667
- async start(onMessage, signal, shouldDownload) {
685
+ async start(onMessage, signal, shouldDownload, runtime) {
668
686
  let buf = loadCursor(creds.account_id);
669
687
  let pollMs = LONG_POLL_TIMEOUT_MS;
670
688
  while (!signal.aborted) {
@@ -676,20 +694,24 @@ export function weixinAdapter(creds) {
676
694
  catch {
677
695
  if (signal.aborted)
678
696
  break;
697
+ runtime?.error("network");
679
698
  await sleep(2000, signal); // timeout (normal) or network blip → re-poll
680
699
  continue;
681
700
  }
682
701
  const ret = num(resp.ret);
683
702
  const errcode = num(resp.errcode);
684
703
  if (isSessionExpired(ret, errcode, str(resp.errmsg))) {
704
+ runtime?.error("session-expired");
685
705
  console.error("weixin: session expired — re-login with `hara gateway --platform weixin --login`. backing off 600s.");
686
706
  await sleep(600_000, signal);
687
707
  continue;
688
708
  }
689
709
  if (ret !== 0 || errcode !== 0) {
710
+ runtime?.error("platform-error");
690
711
  await sleep(2000, signal);
691
712
  continue;
692
713
  }
714
+ runtime?.poll();
693
715
  buf = str(resp.get_updates_buf) || buf;
694
716
  saveCursor(creds.account_id, buf);
695
717
  for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { notifyDone } from "./notify.js";
22
22
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
23
23
  import { completionScript } from "./completions.js";
24
24
  import { renderSessionMarkdown } from "./export.js";
25
- import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
25
+ import { clearEnrollment, enrollDevice, enrollGatewayProfile, gatewayProfileFromEnrollment, enrollmentFromProfile, heartbeatEnrollment, heartbeat, syncOrgRoles, deviceTokenExpired, deviceTokenExpiryWarning, } from "./org-fleet/enroll.js";
26
26
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
27
27
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
28
28
  import { routingProvider } from "./agent/route.js";
@@ -130,6 +130,28 @@ const pkg = {
130
130
  })()),
131
131
  };
132
132
  const maskKey = (v) => (v ? `••••${v.slice(-4)}` : "(unset)");
133
+ const SECRET_CONFIG_KEY = /(?:apiKey|secret|token|password)$/i;
134
+ const maskProxy = (value) => {
135
+ if (typeof value !== "string" || !value.trim())
136
+ return "(unset)";
137
+ try {
138
+ const url = new URL(value);
139
+ const authenticated = Boolean(url.username || url.password);
140
+ url.username = "";
141
+ url.password = "";
142
+ return `${url.protocol}//${url.host}${authenticated ? " (credentials redacted)" : ""}`;
143
+ }
144
+ catch {
145
+ return "(configured, invalid URL hidden)";
146
+ }
147
+ };
148
+ const displayConfigValue = (key, value) => {
149
+ if (key === "proxy")
150
+ return maskProxy(value);
151
+ if (SECRET_CONFIG_KEY.test(key))
152
+ return maskKey(typeof value === "string" ? value : undefined);
153
+ return value === undefined ? "(unset)" : String(value);
154
+ };
133
155
  async function buildProvider(cfg, targetOverride) {
134
156
  // Identity-profile is the source of truth for routing. `cfg` is the *merged* HaraConfig (env +
135
157
  // project + global) and still drives non-routing concerns (model overrides, baseURL fallbacks
@@ -268,6 +290,58 @@ function providerSettingsSnapshot(targetCwd) {
268
290
  providers: catalog,
269
291
  };
270
292
  }
293
+ function organizationAccessState(profile, now = Date.now()) {
294
+ if (!profile.deviceToken || !profile.gatewayUrl)
295
+ return "invalid";
296
+ if (!profile.tokenExpiresAt)
297
+ return "legacy";
298
+ const expiry = Date.parse(profile.tokenExpiresAt);
299
+ if (!Number.isFinite(expiry))
300
+ return "invalid";
301
+ if (expiry <= now)
302
+ return "expired";
303
+ return expiry - now <= 24 * 60 * 60_000 ? "expiring" : "valid";
304
+ }
305
+ function publicGatewayIdentity(value) {
306
+ try {
307
+ const url = new URL(value || "");
308
+ return { gatewayUrl: url.origin, gatewayHost: url.host };
309
+ }
310
+ catch {
311
+ return { gatewayUrl: "", gatewayHost: "invalid endpoint" };
312
+ }
313
+ }
314
+ function organizationConnectionsSnapshot(targetCwd) {
315
+ const resolution = resolveActive(targetCwd);
316
+ const connections = listProfiles()
317
+ .filter((profile) => profile.kind === "gateway")
318
+ .map((profile) => {
319
+ const endpoint = publicGatewayIdentity(profile.gatewayUrl);
320
+ return {
321
+ id: profile.id,
322
+ label: profile.label || profile.id,
323
+ active: profile.id === resolution.id,
324
+ ...endpoint,
325
+ model: effectiveModel(profile) || "",
326
+ ...(profile.enrolledAt ? { enrolledAt: profile.enrolledAt } : {}),
327
+ ...(profile.tokenExpiresAt ? { expiresAt: profile.tokenExpiresAt } : {}),
328
+ accessState: organizationAccessState(profile),
329
+ };
330
+ });
331
+ return {
332
+ activeId: resolution.id,
333
+ activeSource: resolution.source,
334
+ switchLocked: resolution.source === "flag" || resolution.source === "env" || resolution.source === "pin",
335
+ connections,
336
+ };
337
+ }
338
+ function assertOrganizationId(value) {
339
+ const id = value.trim();
340
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id) || id === PERSONAL_ID) {
341
+ throw new Error("invalid organization connection id");
342
+ }
343
+ return id;
344
+ }
271
345
  async function testProviderSettingsCandidate(input) {
272
346
  if (!isProviderId(input.provider) || input.provider === "hara-gateway") {
273
347
  throw new Error("provider is not a configurable personal provider");
@@ -1872,28 +1946,16 @@ profileCmd
1872
1946
  if (!opts.code)
1873
1947
  return void out(c.red("gateway profile add needs --code <code> from your hara-control admin\n"));
1874
1948
  try {
1875
- const e = await enrollDevice(opts.gateway, opts.code);
1876
- const p = {
1949
+ const { enrollment: e } = await enrollGatewayProfile({
1877
1950
  id,
1878
- kind: "gateway",
1879
1951
  label: opts.label || id,
1880
- gatewayUrl: e.gatewayUrl,
1881
- deviceId: e.deviceId,
1882
- deviceToken: e.deviceToken,
1883
- baseURL: e.baseURL,
1884
- defaultModel: e.model || "",
1885
- availableModels: e.model ? [e.model] : [],
1886
- enrolledAt: e.enrolledAt,
1887
- tokenExpiresAt: e.expiresAt,
1888
- };
1889
- upsertProfile(p); // upsert: re-enrolling the same id rotates the token
1890
- const r = useProfile(id);
1891
- if (r.ok) {
1892
- out(c.green(`✓ enrolled and switched to '${id}' (${e.gatewayUrl})`) + c.dim(` · model ${p.defaultModel || "(gateway default)"}\n`));
1893
- const nRoles = await syncOrgRoles();
1894
- if (nRoles > 0)
1895
- out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
1896
- }
1952
+ gatewayUrl: opts.gateway,
1953
+ code: opts.code,
1954
+ });
1955
+ out(c.green(`✓ enrolled and switched to '${id}' (${e.gatewayUrl})`) + c.dim(` · model ${e.model || "(gateway default)"}\n`));
1956
+ const nRoles = await syncOrgRoles();
1957
+ if (nRoles > 0)
1958
+ out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
1897
1959
  }
1898
1960
  catch (err) {
1899
1961
  out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
@@ -2089,19 +2151,7 @@ program
2089
2151
  return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
2090
2152
  try {
2091
2153
  const e = await enrollDevice(gatewayUrl, opts.code);
2092
- const p = {
2093
- id: DEFAULT_ORG_ID,
2094
- kind: "gateway",
2095
- label: "Default Org",
2096
- gatewayUrl: e.gatewayUrl,
2097
- deviceId: e.deviceId,
2098
- deviceToken: e.deviceToken,
2099
- baseURL: e.baseURL,
2100
- defaultModel: e.model || "",
2101
- availableModels: e.model ? [e.model] : [],
2102
- enrolledAt: e.enrolledAt,
2103
- tokenExpiresAt: e.expiresAt,
2104
- };
2154
+ const p = gatewayProfileFromEnrollment(DEFAULT_ORG_ID, "Default Org", e);
2105
2155
  upsertProfile(p);
2106
2156
  useProfile(DEFAULT_ORG_ID);
2107
2157
  out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · profile ${DEFAULT_ORG_ID}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
@@ -2134,22 +2184,52 @@ program
2134
2184
  ` ${c.red("deny")} : ${r.deny.length ? r.deny.join(", ") : c.dim("(none)")}\n` +
2135
2185
  c.dim(" edit the JSON to customize, or `hara permissions --init` for a starter.\n"));
2136
2186
  });
2137
- program
2187
+ const gatewayCommand = program
2138
2188
  .command("gateway")
2139
2189
  .description("run a supported chat gateway so you can drive your local hara from your phone — opt-in daemon")
2140
- .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
2190
+ .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal")
2141
2191
  .option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
2142
2192
  .option("--recover-outcome <message-id>", "recover exactly one interrupted/terminal run marker by its original platform message id")
2143
2193
  .option("--confirm-recovery <action:message-id>", "required exact confirmation: terminalize:<id> or delete-terminal:<id>")
2144
- .option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
2145
- .action(async (opts) => {
2194
+ .option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)");
2195
+ gatewayCommand
2196
+ .command("status")
2197
+ .description("show redacted configuration, live process, connection, poll, and error state")
2198
+ .option("--json", "print stable machine-readable JSON")
2199
+ .action(async (opts, command) => {
2146
2200
  const mod = await import("./gateway/serve.js");
2201
+ const platform = command.parent?.opts().platform;
2202
+ const statuses = platform
2203
+ ? [await mod.gatewayStatus(platform)]
2204
+ : await mod.listGatewayStatuses();
2205
+ if (opts.json) {
2206
+ out(`${JSON.stringify(platform ? statuses[0] : { gateways: statuses }, null, 2)}\n`);
2207
+ return;
2208
+ }
2209
+ const timestamp = (value) => value ? new Date(value).toISOString() : "never";
2210
+ for (const [index, status] of statuses.entries()) {
2211
+ if (index)
2212
+ out("\n");
2213
+ out(`${c.bold(`${status.label} (${status.platform})`)}\n` +
2214
+ ` configuration: ${status.configuration}\n` +
2215
+ ` process: ${status.running ? `running${status.pid ? ` (pid ${status.pid})` : ""}` : "stopped"}` +
2216
+ `${status.runningInstances > 1 ? ` · ${status.runningInstances} credential-scoped instances` : ""}\n` +
2217
+ ` transport: ${status.runtimeState}\n` +
2218
+ ` started: ${timestamp(status.startedAt)}\n` +
2219
+ ` last connected/poll/message: ${timestamp(status.lastConnectedAt)} / ${timestamp(status.lastPollAt)} / ${timestamp(status.lastMessageAt)}\n` +
2220
+ ` last error: ${status.lastErrorCode ?? "none"}${status.lastErrorAt ? ` at ${timestamp(status.lastErrorAt)}` : ""}\n` +
2221
+ ` action: ${status.recommendation}\n`);
2222
+ }
2223
+ });
2224
+ gatewayCommand.action(async (opts) => {
2225
+ const mod = await import("./gateway/serve.js");
2226
+ const platform = opts.platform || "telegram";
2147
2227
  if (opts.recoverOutcome !== undefined || opts.confirmRecovery !== undefined) {
2148
2228
  if (typeof opts.recoverOutcome !== "string" || typeof opts.confirmRecovery !== "string") {
2149
2229
  throw new Error("gateway outcome recovery requires both --recover-outcome <message-id> and --confirm-recovery <action:message-id>");
2150
2230
  }
2151
2231
  const result = await mod.recoverGatewayRunOutcome({
2152
- platform: opts.platform,
2232
+ platform,
2153
2233
  messageId: opts.recoverOutcome,
2154
2234
  confirmation: opts.confirmRecovery,
2155
2235
  });
@@ -2166,12 +2246,12 @@ program
2166
2246
  }
2167
2247
  return;
2168
2248
  }
2169
- if (opts.platform === "weixin" && opts.login) {
2249
+ if (platform === "weixin" && opts.login) {
2170
2250
  await mod.weixinLogin();
2171
2251
  return;
2172
2252
  }
2173
2253
  const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
2174
- await mod.runGateway({ cwd, platform: opts.platform });
2254
+ await mod.runGateway({ cwd, platform });
2175
2255
  });
2176
2256
  program
2177
2257
  .command("serve")
@@ -2218,6 +2298,55 @@ program
2218
2298
  return listModels(target.baseURL, target.apiKey ?? "");
2219
2299
  },
2220
2300
  providerSettings: (targetCwd) => providerSettingsSnapshot(targetCwd ?? cwd),
2301
+ gatewayStatuses: async () => {
2302
+ const gateway = await import("./gateway/serve.js");
2303
+ return gateway.listGatewayStatuses(["weixin", "feishu"]);
2304
+ },
2305
+ organizationConnections: (targetCwd) => organizationConnectionsSnapshot(targetCwd ?? cwd),
2306
+ enrollOrganizationConnection: async (input, targetCwd) => {
2307
+ const settingsCwd = targetCwd ?? cwd;
2308
+ const resolution = resolveActive(settingsCwd);
2309
+ if (input.activate !== false && (resolution.source === "flag" || resolution.source === "env" || resolution.source === "pin")) {
2310
+ throw new Error("the active profile is locked by a flag, environment variable, or project pin; remove that override before activating an organization connection");
2311
+ }
2312
+ await enrollGatewayProfile(input, AbortSignal.timeout(30_000));
2313
+ return organizationConnectionsSnapshot(settingsCwd);
2314
+ },
2315
+ useOrganizationConnection: (inputId, targetCwd) => {
2316
+ const settingsCwd = targetCwd ?? cwd;
2317
+ const id = assertOrganizationId(inputId);
2318
+ const target = getProfile(id);
2319
+ if (!target || target.kind !== "gateway")
2320
+ throw new Error("organization connection was not found");
2321
+ const resolution = resolveActive(settingsCwd);
2322
+ if (resolution.source === "flag" || resolution.source === "env" || resolution.source === "pin") {
2323
+ throw new Error("the active profile is locked by a flag, environment variable, or project pin; remove that override before switching");
2324
+ }
2325
+ const switched = useProfile(id);
2326
+ if (!switched.ok)
2327
+ throw new Error("organization connection could not be activated");
2328
+ return organizationConnectionsSnapshot(settingsCwd);
2329
+ },
2330
+ removeOrganizationConnection: (inputId, targetCwd) => {
2331
+ const settingsCwd = targetCwd ?? cwd;
2332
+ const id = assertOrganizationId(inputId);
2333
+ const target = getProfile(id);
2334
+ if (!target || target.kind !== "gateway")
2335
+ throw new Error("organization connection was not found");
2336
+ const removed = removeProfile(id);
2337
+ if (!removed.ok)
2338
+ throw new Error("organization connection could not be removed");
2339
+ return organizationConnectionsSnapshot(settingsCwd);
2340
+ },
2341
+ checkOrganizationConnection: async (inputId) => {
2342
+ const id = assertOrganizationId(inputId);
2343
+ const profile = getProfile(id);
2344
+ if (!profile || profile.kind !== "gateway")
2345
+ throw new Error("organization connection was not found");
2346
+ const enrollment = enrollmentFromProfile(profile);
2347
+ const ok = !!enrollment && !deviceTokenExpired(enrollment.expiresAt) && await heartbeatEnrollment(enrollment, AbortSignal.timeout(15_000));
2348
+ return { id, ok, checkedAt: Date.now() };
2349
+ },
2221
2350
  testProviderSettings: (input) => testProviderSettingsCandidate(input),
2222
2351
  saveProviderSettings: async (input, targetCwd) => {
2223
2352
  const settingsCwd = targetCwd ?? cwd;
@@ -3032,6 +3161,18 @@ config
3032
3161
  out(c.red(`Invalid reasoning effort. One of: ${REASONING_EFFORTS.join(", ")}.\n`));
3033
3162
  process.exit(1);
3034
3163
  }
3164
+ if (key === "proxy") {
3165
+ try {
3166
+ const parsed = new URL(value);
3167
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.pathname !== "/" || parsed.search || parsed.hash) {
3168
+ throw new Error("invalid proxy URL");
3169
+ }
3170
+ }
3171
+ catch {
3172
+ out(c.red("Invalid proxy. Use an HTTP(S) proxy URL such as http://127.0.0.1:7890; paths, queries, and fragments are not allowed.\n"));
3173
+ process.exit(1);
3174
+ }
3175
+ }
3035
3176
  if (key === "runTimeoutMs") {
3036
3177
  const parsed = parseAgentRunTimeoutMs(value);
3037
3178
  if (parsed === undefined || parsed < MIN_AGENT_RUN_TIMEOUT_MS || parsed > MAX_AGENT_RUN_TIMEOUT_MS) {
@@ -3051,11 +3192,11 @@ config
3051
3192
  });
3052
3193
  config
3053
3194
  .command("get [key]")
3054
- .description("show config (apiKey masked)")
3195
+ .description("show config (credentials masked)")
3055
3196
  .action((key) => {
3056
3197
  const raw = readRawConfig();
3057
3198
  if (key) {
3058
- out((key === "apiKey" ? maskKey(raw.apiKey) : raw[key] ?? "(unset)") + "\n");
3199
+ out(displayConfigValue(key, raw[key]) + "\n");
3059
3200
  }
3060
3201
  else {
3061
3202
  out(`path: ${configPath()}\n` +
@@ -3066,6 +3207,7 @@ config
3066
3207
  `sandbox: ${raw.sandbox ?? "(default off)"}\n` +
3067
3208
  `timeout: ${raw.runTimeoutMs ?? "(default 30m)"}\n` +
3068
3209
  `rounds: ${raw.maxAgentRounds ?? "(default 64)"}\n` +
3210
+ `proxy: ${maskProxy(raw.proxy)}\n` +
3069
3211
  `apiKey: ${maskKey(raw.apiKey)}\n`);
3070
3212
  }
3071
3213
  });
@@ -13,9 +13,67 @@ import { homedir, hostname, platform } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { writeFileSync, mkdirSync, rmSync } from "node:fs";
15
15
  import { orgRolesDir } from "../org/roles.js";
16
- import { loadActiveProfile } from "../profile/profile.js";
16
+ import { loadActiveProfile, upsertProfile, useProfile, getProfile, } from "../profile/profile.js";
17
17
  import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
18
+ const MAX_ENROLL_RESPONSE_BYTES = 1024 * 1024;
19
+ const PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
20
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
21
+ const loopbackHostname = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
18
22
  const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
23
+ /** Enrollment codes are sent to a security-sensitive endpoint. Only HTTPS is accepted outside a
24
+ * loopback development server, and userinfo/path/query/fragment are rejected so a code cannot be
25
+ * redirected or accidentally embedded in a URL. */
26
+ export function normalizeGatewayUrl(value) {
27
+ let url;
28
+ try {
29
+ url = new URL(value.trim());
30
+ }
31
+ catch {
32
+ throw new Error("organization URL must be a valid absolute URL");
33
+ }
34
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopbackHostname(url.hostname))) {
35
+ throw new Error("organization URL must use HTTPS (HTTP is allowed only for localhost)");
36
+ }
37
+ if (url.username || url.password)
38
+ throw new Error("organization URL must not contain credentials");
39
+ if ((url.pathname && url.pathname !== "/") || url.search || url.hash) {
40
+ throw new Error("organization URL must contain only scheme, host, and optional port");
41
+ }
42
+ return url.origin;
43
+ }
44
+ function normalizeGatewayBaseUrl(value) {
45
+ if (value === undefined || value === null || value === "")
46
+ return undefined;
47
+ if (typeof value !== "string")
48
+ throw new Error("enroll response contains an invalid base_url");
49
+ let url;
50
+ try {
51
+ url = new URL(value);
52
+ }
53
+ catch {
54
+ throw new Error("enroll response contains an invalid base_url");
55
+ }
56
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopbackHostname(url.hostname))) {
57
+ throw new Error("enroll response contains an insecure base_url");
58
+ }
59
+ if (url.username || url.password || url.search || url.hash)
60
+ throw new Error("enroll response contains an invalid base_url");
61
+ return `${url.origin}${url.pathname.replace(/\/$/, "")}`;
62
+ }
63
+ function validateGatewayProfileInput(input) {
64
+ const id = input.id.trim();
65
+ const label = input.label?.trim();
66
+ const code = input.code.trim();
67
+ if (!PROFILE_ID.test(id))
68
+ throw new Error("connection id must use 1-64 letters, numbers, dots, underscores, or dashes");
69
+ if (id === "personal")
70
+ throw new Error("the personal profile id is reserved");
71
+ if (label && (label.length > 80 || CONTROL_CHARACTERS.test(label)))
72
+ throw new Error("organization name must be 80 characters or fewer");
73
+ if (!code || code.length > 256 || CONTROL_CHARACTERS.test(code))
74
+ throw new Error("registration code must be 1-256 printable characters");
75
+ return { ...input, id, ...(label ? { label } : {}), gatewayUrl: normalizeGatewayUrl(input.gatewayUrl), code };
76
+ }
19
77
  /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
20
78
  export function gatewayBaseURL(e) {
21
79
  return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
@@ -68,9 +126,18 @@ export function clearEnrollment() {
68
126
  }
69
127
  /** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
70
128
  export function parseEnrollResponse(gatewayUrl, j, now) {
71
- const deviceToken = (j.device_token ?? j.deviceToken);
72
- if (!deviceToken)
73
- throw new Error("enroll response missing device_token");
129
+ const deviceToken = j.device_token ?? j.deviceToken;
130
+ if (typeof deviceToken !== "string" || !deviceToken || deviceToken.length > 16 * 1024 || CONTROL_CHARACTERS.test(deviceToken)) {
131
+ throw new Error("enroll response missing or contains an invalid device_token");
132
+ }
133
+ const rawDeviceId = j.device_id ?? j.deviceId ?? "";
134
+ const rawModel = j.model ?? "";
135
+ if (typeof rawDeviceId !== "string" || rawDeviceId.length > 256 || CONTROL_CHARACTERS.test(rawDeviceId)) {
136
+ throw new Error("enroll response contains an invalid device_id");
137
+ }
138
+ if (typeof rawModel !== "string" || rawModel.length > 512 || CONTROL_CHARACTERS.test(rawModel)) {
139
+ throw new Error("enroll response contains an invalid model");
140
+ }
74
141
  const rawExpiresAt = j.expires_at ?? j.expiresAt;
75
142
  let expiresAt;
76
143
  if (rawExpiresAt !== undefined && rawExpiresAt !== null) {
@@ -80,11 +147,11 @@ export function parseEnrollResponse(gatewayUrl, j, now) {
80
147
  expiresAt = new Date(rawExpiresAt).toISOString();
81
148
  }
82
149
  return {
83
- gatewayUrl: gatewayUrl.replace(/\/$/, ""),
150
+ gatewayUrl: normalizeGatewayUrl(gatewayUrl),
84
151
  deviceToken,
85
- deviceId: String(j.device_id ?? j.deviceId ?? ""),
86
- model: String(j.model ?? ""),
87
- baseURL: (j.base_url ?? j.baseURL),
152
+ deviceId: rawDeviceId,
153
+ model: rawModel,
154
+ baseURL: normalizeGatewayBaseUrl(j.base_url ?? j.baseURL),
88
155
  enrolledAt: now,
89
156
  expiresAt,
90
157
  };
@@ -116,29 +183,104 @@ export function deviceTokenExpiryWarning(expiresAt, now = new Date()) {
116
183
  : `${Math.ceil(remainingMinutes / 60)}h`;
117
184
  return `organization access expires in ${remaining}; ask your admin for a new enrollment code`;
118
185
  }
119
- /** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
186
+ /** Exchange a one-time code without persisting it. Redirects are rejected so the credential is sent
187
+ * only to the exact origin the user entered. Server bodies are never reflected into errors. */
188
+ export async function exchangeEnrollment(gatewayUrl, code, signal) {
189
+ const base = normalizeGatewayUrl(gatewayUrl);
190
+ if (!code.trim() || code.length > 256 || CONTROL_CHARACTERS.test(code)) {
191
+ throw new Error("registration code must be 1-256 printable characters");
192
+ }
193
+ let res;
194
+ try {
195
+ res = await fetch(`${base}/v1/enroll`, {
196
+ method: "POST",
197
+ redirect: "error",
198
+ signal,
199
+ headers: { "content-type": "application/json" },
200
+ body: JSON.stringify({ code: code.trim(), device: deviceInfo() }),
201
+ });
202
+ }
203
+ catch {
204
+ throw new Error("organization enrollment request failed");
205
+ }
206
+ if (!res.ok) {
207
+ throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}`);
208
+ }
209
+ const declaredLength = Number(res.headers.get("content-length"));
210
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_ENROLL_RESPONSE_BYTES) {
211
+ throw new Error("enroll response is too large");
212
+ }
213
+ const raw = await res.text();
214
+ if (Buffer.byteLength(raw, "utf8") > MAX_ENROLL_RESPONSE_BYTES)
215
+ throw new Error("enroll response is too large");
216
+ let payload;
217
+ try {
218
+ const parsed = JSON.parse(raw);
219
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
220
+ throw new Error("invalid");
221
+ payload = parsed;
222
+ }
223
+ catch {
224
+ throw new Error("enroll response is not valid JSON");
225
+ }
226
+ return parseEnrollResponse(base, payload, new Date().toISOString());
227
+ }
228
+ /** Legacy enrollment path: exchange, then persist ~/.hara/org.json for older callers. */
120
229
  export async function enrollDevice(gatewayUrl, code, signal) {
121
- const base = gatewayUrl.replace(/\/$/, "");
122
- const res = await fetch(`${base}/v1/enroll`, {
123
- method: "POST",
124
- signal,
125
- headers: { "content-type": "application/json" },
126
- body: JSON.stringify({ code, device: deviceInfo() }),
127
- });
128
- if (!res.ok)
129
- throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}: ${(await res.text()).slice(0, 200)}`);
130
- const e = parseEnrollResponse(base, (await res.json()), new Date().toISOString());
230
+ const e = await exchangeEnrollment(gatewayUrl, code, signal);
131
231
  saveEnrollment(e);
132
232
  return e;
133
233
  }
134
- /** Best-effort heartbeat so the control plane shows this device online. Never throws. */
135
- export async function heartbeat(signal) {
136
- const e = loadEnrollment();
137
- if (!e)
138
- return false;
234
+ export function gatewayProfileFromEnrollment(id, label, e) {
235
+ return {
236
+ id,
237
+ kind: "gateway",
238
+ label: label || id,
239
+ gatewayUrl: e.gatewayUrl,
240
+ deviceId: e.deviceId,
241
+ deviceToken: e.deviceToken,
242
+ baseURL: e.baseURL,
243
+ defaultModel: e.model || "",
244
+ availableModels: e.model ? [e.model] : [],
245
+ enrolledAt: e.enrolledAt,
246
+ tokenExpiresAt: e.expiresAt,
247
+ };
248
+ }
249
+ /** Desktop/profile-native enrollment: no legacy file is written, and the one-time code is never
250
+ * stored. An existing id is intentionally replaced so re-enrollment rotates the scoped token. */
251
+ export async function enrollGatewayProfile(input, signal) {
252
+ const validated = validateGatewayProfileInput(input);
253
+ const existing = getProfile(validated.id);
254
+ if (existing && existing.kind !== "gateway")
255
+ throw new Error("connection id already belongs to a personal provider profile");
256
+ const enrollment = await exchangeEnrollment(validated.gatewayUrl, validated.code, signal);
257
+ const profile = gatewayProfileFromEnrollment(validated.id, validated.label, enrollment);
258
+ upsertProfile(profile);
259
+ if (validated.activate !== false) {
260
+ const switched = useProfile(validated.id);
261
+ if (!switched.ok)
262
+ throw new Error("organization connection was saved but could not be activated");
263
+ }
264
+ return { enrollment, heartbeatOk: await heartbeatEnrollment(enrollment, signal) };
265
+ }
266
+ export function enrollmentFromProfile(profile) {
267
+ if (profile.kind !== "gateway" || !profile.gatewayUrl || !profile.deviceToken)
268
+ return null;
269
+ return {
270
+ gatewayUrl: profile.gatewayUrl,
271
+ deviceToken: profile.deviceToken,
272
+ deviceId: profile.deviceId || "",
273
+ model: profile.defaultModel || "",
274
+ baseURL: profile.baseURL,
275
+ enrolledAt: profile.enrolledAt || new Date(0).toISOString(),
276
+ expiresAt: profile.tokenExpiresAt,
277
+ };
278
+ }
279
+ export async function heartbeatEnrollment(e, signal) {
139
280
  try {
140
281
  const res = await fetch(`${e.gatewayUrl}/v1/heartbeat`, {
141
282
  method: "POST",
283
+ redirect: "error",
142
284
  signal,
143
285
  headers: { "content-type": "application/json", authorization: `Bearer ${e.deviceToken}` },
144
286
  body: JSON.stringify({ device_id: e.deviceId, ...deviceInfo() }),
@@ -149,6 +291,13 @@ export async function heartbeat(signal) {
149
291
  return false;
150
292
  }
151
293
  }
294
+ /** Best-effort heartbeat so the control plane shows this device online. Never throws. */
295
+ export async function heartbeat(signal) {
296
+ const e = loadEnrollment();
297
+ if (!e)
298
+ return false;
299
+ return heartbeatEnrollment(e, signal);
300
+ }
152
301
  const SAFE_ORG_ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
153
302
  const WINDOWS_RESERVED_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
154
303
  function isSafeBundleRole(value) {
@@ -124,12 +124,12 @@ function sensitiveFieldName(key) {
124
124
  /** Deep-copy a JSON-shaped value while redacting every string. Session history contains secrets not only
125
125
  * in user text but also in assistant tool inputs and tool results, so a top-level content-only pass is
126
126
  * insufficient. The live value is never mutated. */
127
- export function redactSensitiveValue(value) {
127
+ export function redactSensitiveValue(value, knownSecrets = []) {
128
128
  const hits = [];
129
129
  const seen = new WeakMap();
130
130
  const walk = (v) => {
131
131
  if (typeof v === "string") {
132
- const r = redactSensitiveText(v);
132
+ const r = redactKnownSecrets(v, knownSecrets);
133
133
  hits.push(...r.redactions);
134
134
  return r.text;
135
135
  }
@@ -24,6 +24,13 @@
24
24
  // settings.providers.list {} → redacted provider catalog + current profile state
25
25
  // settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
26
26
  // settings.providers.save {provider,model,…} → redacted state (credential is never returned)
27
+ // settings.gateways.list {} → {gateways:[redacted configuration/runtime health]}
28
+ // settings.organizations.list {cwd?} → {activeId,activeSource,switchLocked,connections:[redacted]}
29
+ // settings.organizations.enroll {id,label?,gatewayUrl,code,activate?,cwd?}
30
+ // → organization state (code/token never returned)
31
+ // settings.organizations.use {id,cwd?} → organization state
32
+ // settings.organizations.remove {id,cwd?} → organization state (local removal; no remote revoke)
33
+ // settings.organizations.check {id,cwd?} → {id,ok,checkedAt}
27
34
  // automation.add {name,schedule,task,mode?,cwd?,tz?,deliver?,deliverMode?,alertAfter?}
28
35
  // → {id,name,schedule}
29
36
  // automation.toggle {id,enabled} → {id,enabled}