openryoko 2026.8.31 → 2026.9.2

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.
Files changed (82) hide show
  1. package/dist/package.json +5 -2
  2. package/dist/src/connectors/slack/index.d.ts.map +1 -1
  3. package/dist/src/connectors/slack/index.js +7 -1
  4. package/dist/src/connectors/slack/index.js.map +1 -1
  5. package/dist/src/connectors/slack/respond-policy.d.ts +16 -0
  6. package/dist/src/connectors/slack/respond-policy.d.ts.map +1 -1
  7. package/dist/src/connectors/slack/respond-policy.js +20 -0
  8. package/dist/src/connectors/slack/respond-policy.js.map +1 -1
  9. package/dist/src/gateway/api.d.ts +3 -0
  10. package/dist/src/gateway/api.d.ts.map +1 -1
  11. package/dist/src/gateway/api.js +373 -74
  12. package/dist/src/gateway/api.js.map +1 -1
  13. package/dist/src/gateway/server.d.ts.map +1 -1
  14. package/dist/src/gateway/server.js +24 -17
  15. package/dist/src/gateway/server.js.map +1 -1
  16. package/dist/web/404.html +1 -1
  17. package/dist/web/__next.__PAGE__.txt +11 -11
  18. package/dist/web/__next._full.txt +12 -12
  19. package/dist/web/__next._tree.txt +2 -2
  20. package/dist/web/_next/static/chunks/0los6agi5yfk-.js +1 -0
  21. package/dist/web/_next/static/chunks/{1ftanci12q2ck.js → 0nsg_lm2gtp4v.js} +1 -1
  22. package/dist/web/_next/static/chunks/22x0rizn0r035.js +7 -0
  23. package/dist/web/_next/static/chunks/2zl5_vtgxjbs1.js +1 -0
  24. package/dist/web/_next/static/chunks/318j2wz96k6gv.js +1 -0
  25. package/dist/web/_next/static/chunks/3stop8a72z6ft.css +1 -0
  26. package/dist/web/_not-found/__next._full.txt +10 -10
  27. package/dist/web/_not-found/__next._not-found.__PAGE__.txt +9 -9
  28. package/dist/web/_not-found/__next._tree.txt +2 -2
  29. package/dist/web/_not-found.html +1 -1
  30. package/dist/web/_not-found.txt +10 -10
  31. package/dist/web/chat/__next._full.txt +12 -12
  32. package/dist/web/chat/__next._tree.txt +2 -2
  33. package/dist/web/chat/__next.chat.__PAGE__.txt +11 -11
  34. package/dist/web/chat.html +1 -1
  35. package/dist/web/chat.txt +12 -12
  36. package/dist/web/cron/__next._full.txt +12 -12
  37. package/dist/web/cron/__next._tree.txt +2 -2
  38. package/dist/web/cron/__next.cron.__PAGE__.txt +11 -11
  39. package/dist/web/cron.html +1 -1
  40. package/dist/web/cron.txt +12 -12
  41. package/dist/web/index.html +1 -1
  42. package/dist/web/index.txt +12 -12
  43. package/dist/web/kanban/__next._full.txt +12 -12
  44. package/dist/web/kanban/__next._tree.txt +2 -2
  45. package/dist/web/kanban/__next.kanban.__PAGE__.txt +11 -11
  46. package/dist/web/kanban.html +1 -1
  47. package/dist/web/kanban.txt +12 -12
  48. package/dist/web/logs/__next._full.txt +12 -12
  49. package/dist/web/logs/__next._tree.txt +2 -2
  50. package/dist/web/logs/__next.logs.__PAGE__.txt +11 -11
  51. package/dist/web/logs.html +1 -1
  52. package/dist/web/logs.txt +12 -12
  53. package/dist/web/org/__next._full.txt +12 -12
  54. package/dist/web/org/__next._tree.txt +2 -2
  55. package/dist/web/org/__next.org.__PAGE__.txt +11 -11
  56. package/dist/web/org.html +1 -1
  57. package/dist/web/org.txt +12 -12
  58. package/dist/web/sessions/__next._full.txt +10 -10
  59. package/dist/web/sessions/__next._tree.txt +2 -2
  60. package/dist/web/sessions/__next.sessions.__PAGE__.txt +9 -9
  61. package/dist/web/sessions.html +1 -1
  62. package/dist/web/sessions.txt +10 -10
  63. package/dist/web/settings/__next._full.txt +12 -12
  64. package/dist/web/settings/__next._tree.txt +2 -2
  65. package/dist/web/settings/__next.settings.__PAGE__.txt +11 -11
  66. package/dist/web/settings.html +1 -1
  67. package/dist/web/settings.txt +12 -12
  68. package/dist/web/skills/__next._full.txt +12 -12
  69. package/dist/web/skills/__next._tree.txt +2 -2
  70. package/dist/web/skills/__next.skills.__PAGE__.txt +11 -11
  71. package/dist/web/skills.html +1 -1
  72. package/dist/web/skills.txt +12 -12
  73. package/package.json +5 -2
  74. package/scripts/fix-prebuild-permissions.mjs +122 -0
  75. package/dist/web/_next/static/chunks/0fqryzd-l87nh.js +0 -1
  76. package/dist/web/_next/static/chunks/1jlzfg7b2qv3x.js +0 -1
  77. package/dist/web/_next/static/chunks/1v0-xm12dgqnn.css +0 -1
  78. package/dist/web/_next/static/chunks/2z0rerwculapx.js +0 -7
  79. package/dist/web/_next/static/chunks/42cww7d-zotwu.js +0 -1
  80. /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_buildManifest.js +0 -0
  81. /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_clientMiddlewareManifest.js +0 -0
  82. /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_ssgManifest.js +0 -0
@@ -1,6 +1,7 @@
1
1
  import http from "node:http";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import yaml from "js-yaml";
6
7
  import cron from "node-cron";
@@ -368,7 +369,56 @@ export async function handleApiRequest(req, res, context) {
368
369
  if (await handleWorkflowApi(req, res, { method, pathname, url }, { service: context.workflowService, authenticated }))
369
370
  return;
370
371
  }
371
- // GET /api/automation/templates — the fill-in-the-blanks workflow shapes.
372
+ // GET /api/onboarding/engines — is each configured engine INSTALLED and
373
+ // STARTABLE, and what login state can be observed locally? This is an
374
+ // install check with the best available auth signal — the wizard words it
375
+ // that way. It never spends tokens or reaches the network. One probe runs
376
+ // at a time (shared in-flight promise) and the result is cached briefly,
377
+ // so a burst of requests spawns one set of child processes, not many.
378
+ if (method === "GET" && pathname === "/api/onboarding/engines") {
379
+ const config = context.getConfig();
380
+ res.setHeader("Cache-Control", "no-store");
381
+ return json(res, await probeOnboardingEngines(config));
382
+ }
383
+ // POST /api/onboarding/slack/connect — the whole Slack hookup as ONE
384
+ // server-side operation: verify both tokens, save, reload, and if the
385
+ // connector still fails to start, put the previous Slack config back and
386
+ // reload again. The wizard calls only this, so "a wrong token never
387
+ // overwrites a working connection" holds across the verify→save gap too.
388
+ if (method === "POST" && pathname === "/api/onboarding/slack/connect") {
389
+ const { readJsonBody } = await import("./http-helpers.js");
390
+ const read = await readJsonBody(req, res, { maxBytes: 16 * 1024 });
391
+ if (!read.ok)
392
+ return;
393
+ const body = read.body;
394
+ const botToken = typeof body?.botToken === "string" ? body.botToken.trim() : "";
395
+ const appToken = typeof body?.appToken === "string" ? body.appToken.trim() : "";
396
+ if (!botToken.startsWith("xoxb-") || !appToken.startsWith("xapp-")) {
397
+ return badRequest(res, "botToken must start with xoxb- and appToken with xapp-");
398
+ }
399
+ res.setHeader("Cache-Control", "no-store");
400
+ return json(res, await connectSlack(context, botToken, appToken));
401
+ }
402
+ // POST /api/onboarding/slack/verify — prove BOTH Slack tokens before
403
+ // anything is saved: auth.test for the bot token, apps.connections.open
404
+ // for the app token (Socket Mode). Nothing is written here; the wizard
405
+ // saves only after both answer ok, so a wrong token can never overwrite a
406
+ // working connection.
407
+ if (method === "POST" && pathname === "/api/onboarding/slack/verify") {
408
+ const { readJsonBody } = await import("./http-helpers.js");
409
+ const read = await readJsonBody(req, res, { maxBytes: 16 * 1024 });
410
+ if (!read.ok)
411
+ return;
412
+ const body = read.body;
413
+ const botToken = typeof body?.botToken === "string" ? body.botToken.trim() : "";
414
+ const appToken = typeof body?.appToken === "string" ? body.appToken.trim() : "";
415
+ if (!botToken.startsWith("xoxb-") || !appToken.startsWith("xapp-")) {
416
+ return badRequest(res, "botToken must start with xoxb- and appToken with xapp-");
417
+ }
418
+ res.setHeader("Cache-Control", "no-store");
419
+ return json(res, await verifySlackTokens(botToken, appToken));
420
+ }
421
+ // GET /api/automation/templates — the fill-in-the-blanks workflow shapes. // GET /api/automation/templates — the fill-in-the-blanks workflow shapes.
372
422
  // Lives outside /api/workflows so a definition id can never shadow it.
373
423
  if (method === "GET" && pathname === "/api/automation/templates") {
374
424
  const { AUTOMATION_TEMPLATES } = await import("../workflows/templates.js");
@@ -1678,83 +1728,89 @@ Handle this as a priority request from a colleague.`;
1678
1728
  if (body.engines !== undefined && (typeof body.engines !== "object" || Array.isArray(body.engines))) {
1679
1729
  return badRequest(res, "engines must be an object");
1680
1730
  }
1681
- // Deep-merge incoming config with existing config to preserve
1682
- // fields not included in the update (e.g. connector tokens).
1683
- let existing = {};
1684
- try {
1685
- existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
1686
- }
1687
- catch { /* start fresh if unreadable */ }
1688
- const merged = deepMerge(existing, body);
1689
- const yamlStr = yaml.dump(merged);
1690
- // Compare AGAINST THE MERGED config, not the request body — partial
1691
- // updates (e.g. saving only "portal.portalName") leave body.connectors
1692
- // undefined, which would falsely look like the connectors block was
1693
- // wiped. Use the deep-merged result so we only reload when the
1694
- // effective on-disk values actually changed.
1695
- //
1696
- // We also reload connectors when portal.portalName / portal.operatorName
1697
- // change, because SlackConnector captures those at construction and
1698
- // would otherwise keep displaying the old portal name in triage prompts
1699
- // until a daemon restart.
1700
- const portalSliceChanged = (() => {
1701
- const prev = existing.portal ?? {};
1702
- const next = merged.portal ?? {};
1703
- return prev.portalName !== next.portalName || prev.operatorName !== next.operatorName;
1704
- })();
1705
- const connectorsChanged = JSON.stringify(existing.connectors ?? null) !==
1706
- JSON.stringify(merged.connectors ?? null) ||
1707
- portalSliceChanged;
1708
- // Tell the watcher to skip its connector-reload reaction for the file
1709
- // write we're about to do — we'll handle the reload ourselves below
1710
- // so the user's "Save" feels instant. Without this, the watcher would
1711
- // fire ~500ms later and start a redundant disconnect/reconnect cycle.
1712
- if (connectorsChanged) {
1713
- context.suppressNextConnectorReload?.();
1714
- }
1715
- fs.writeFileSync(CONFIG_PATH, yamlStr);
1716
- invalidateModelRegistry(); // models/engines may have changed — rebuild on next read
1717
- logger.info("Config updated via API");
1718
- if (connectorsChanged && context.reloadAllConnectors) {
1731
+ // Every read→merge→write→reload of config.yaml runs under one lock, so a
1732
+ // concurrent writer (another PUT, the onboarding Slack connect and its
1733
+ // rollback) can never interleave with — or be overwritten by — this one.
1734
+ return withConfigLock(async () => {
1735
+ // Deep-merge incoming config with existing config to preserve
1736
+ // fields not included in the update (e.g. connector tokens).
1737
+ let existing = {};
1719
1738
  try {
1720
- const reload = await context.reloadAllConnectors();
1721
- // reloadAllConnectors() already updated currentConfig + sessionManager
1722
- // internally. Make sure apiContext.config (returned by GET /api/config)
1723
- // reflects the on-disk merge too — without this, the GET handler
1724
- // would return the previous snapshot until the (suppressed) watcher
1725
- // event fires.
1726
- context.config = context.getConfig();
1727
- context.emit("connectors:reloaded", reload);
1728
- // reloadAllConnectors() collects per-connector start/stop failures
1729
- // into reload.errors instead of throwing. Surface them clearly so
1730
- // the UI doesn't show a misleading "Settings saved" toast when
1731
- // the new Slack token actually failed to authenticate.
1732
- const status = reload.errors.length > 0 ? "partial" : "ok";
1733
- if (status === "partial") {
1734
- logger.warn(`Config saved but ${reload.errors.length} connector(s) failed to (re)start: ${reload.errors.join(" | ")}`);
1735
- // Allow the chokidar event for the file we just wrote to fire
1736
- // a second reload attempt — useful for transient failures
1737
- // (network blip during Slack reconnect, brief Discord gateway
1738
- // outage, etc.). For permanent failures (bad token), the second
1739
- // attempt will fail the same way; no harm done.
1739
+ existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
1740
+ }
1741
+ catch { /* start fresh if unreadable */ }
1742
+ const merged = deepMerge(existing, body);
1743
+ const yamlStr = yaml.dump(merged);
1744
+ // Compare AGAINST THE MERGED config, not the request body — partial
1745
+ // updates (e.g. saving only "portal.portalName") leave body.connectors
1746
+ // undefined, which would falsely look like the connectors block was
1747
+ // wiped. Use the deep-merged result so we only reload when the
1748
+ // effective on-disk values actually changed.
1749
+ //
1750
+ // We also reload connectors when portal.portalName / portal.operatorName
1751
+ // change, because SlackConnector captures those at construction and
1752
+ // would otherwise keep displaying the old portal name in triage prompts
1753
+ // until a daemon restart.
1754
+ const portalSliceChanged = (() => {
1755
+ const prev = existing.portal ?? {};
1756
+ const next = merged.portal ?? {};
1757
+ return prev.portalName !== next.portalName || prev.operatorName !== next.operatorName;
1758
+ })();
1759
+ const connectorsChanged = JSON.stringify(existing.connectors ?? null) !==
1760
+ JSON.stringify(merged.connectors ?? null) ||
1761
+ portalSliceChanged;
1762
+ // Tell the watcher to skip its connector-reload reaction for the file
1763
+ // write we're about to do — we'll handle the reload ourselves below
1764
+ // so the user's "Save" feels instant. Without this, the watcher would
1765
+ // fire ~500ms later and start a redundant disconnect/reconnect cycle.
1766
+ if (connectorsChanged) {
1767
+ context.suppressNextConnectorReload?.();
1768
+ }
1769
+ fs.writeFileSync(CONFIG_PATH, yamlStr);
1770
+ invalidateModelRegistry(); // models/engines may have changed — rebuild on next read
1771
+ _resetEngineProbeCache(); // engine bins may have changed — the onboarding probe must re-run
1772
+ logger.info("Config updated via API");
1773
+ if (connectorsChanged && context.reloadAllConnectors) {
1774
+ try {
1775
+ const reload = await context.reloadAllConnectors();
1776
+ // reloadAllConnectors() already updated currentConfig + sessionManager
1777
+ // internally. Make sure apiContext.config (returned by GET /api/config)
1778
+ // reflects the on-disk merge too — without this, the GET handler
1779
+ // would return the previous snapshot until the (suppressed) watcher
1780
+ // event fires.
1781
+ context.config = context.getConfig();
1782
+ context.emit("connectors:reloaded", reload);
1783
+ // reloadAllConnectors() collects per-connector start/stop failures
1784
+ // into reload.errors instead of throwing. Surface them clearly so
1785
+ // the UI doesn't show a misleading "Settings saved" toast when
1786
+ // the new Slack token actually failed to authenticate.
1787
+ const status = reload.errors.length > 0 ? "partial" : "ok";
1788
+ if (status === "partial") {
1789
+ logger.warn(`Config saved but ${reload.errors.length} connector(s) failed to (re)start: ${reload.errors.join(" | ")}`);
1790
+ // Allow the chokidar event for the file we just wrote to fire
1791
+ // a second reload attempt — useful for transient failures
1792
+ // (network blip during Slack reconnect, brief Discord gateway
1793
+ // outage, etc.). For permanent failures (bad token), the second
1794
+ // attempt will fail the same way; no harm done.
1795
+ context.clearSuppressNextConnectorReload?.();
1796
+ }
1797
+ return json(res, { status, connectorsReload: reload });
1798
+ }
1799
+ catch (err) {
1800
+ // Eager reload failed. Re-arm the watcher so the chokidar event
1801
+ // for the file we just wrote is NOT suppressed — it's now our only
1802
+ // chance to retry connector reload before the next external edit
1803
+ // or manual /api/connectors/reload. (suppressNextConnectorReload
1804
+ // was armed above; clearing it via a fresh suppress with 0 effect
1805
+ // isn't possible, so we expose a clear() instead via the API.)
1740
1806
  context.clearSuppressNextConnectorReload?.();
1807
+ const message = err instanceof Error ? err.message : String(err);
1808
+ logger.error(`Eager connector reload after config save failed: ${message}`);
1809
+ return json(res, { status: "ok", connectorsReloadError: message });
1741
1810
  }
1742
- return json(res, { status, connectorsReload: reload });
1743
1811
  }
1744
- catch (err) {
1745
- // Eager reload failed. Re-arm the watcher so the chokidar event
1746
- // for the file we just wrote is NOT suppressed — it's now our only
1747
- // chance to retry connector reload before the next external edit
1748
- // or manual /api/connectors/reload. (suppressNextConnectorReload
1749
- // was armed above; clearing it via a fresh suppress with 0 effect
1750
- // isn't possible, so we expose a clear() instead via the API.)
1751
- context.clearSuppressNextConnectorReload?.();
1752
- const message = err instanceof Error ? err.message : String(err);
1753
- logger.error(`Eager connector reload after config save failed: ${message}`);
1754
- return json(res, { status: "ok", connectorsReloadError: message });
1755
- }
1756
- }
1757
- return json(res, { status: "ok" });
1812
+ return json(res, { status: "ok" });
1813
+ });
1758
1814
  }
1759
1815
  // GET /api/logs
1760
1816
  if (method === "GET" && pathname === "/api/logs") {
@@ -2896,4 +2952,247 @@ deliverToConnector) {
2896
2952
  logger.error(`Web session ${currentSession.id} error: ${errMsg}`);
2897
2953
  }
2898
2954
  }
2955
+ // ---------------------------------------------------------------------------
2956
+ // Config write serialization (fork addition)
2957
+ // ---------------------------------------------------------------------------
2958
+ /** One writer at a time for config.yaml + connector reload. The chain never
2959
+ * rejects: a failing writer settles its own promise and the next waiter runs. */
2960
+ let configWriteChain = Promise.resolve();
2961
+ export function withConfigLock(work) {
2962
+ const run = configWriteChain.then(work, work);
2963
+ configWriteChain = run.then(() => undefined, () => undefined);
2964
+ return run;
2965
+ }
2966
+ const ENGINE_PROBE_TTL_MS = 10_000;
2967
+ /** Cache and in-flight probe are keyed by the engine config they were taken
2968
+ * under, so a bin/default change is never answered from a stale result — and
2969
+ * a probe started under the old config only ever caches under the old key. */
2970
+ let engineProbeInFlight = null;
2971
+ let engineProbeCache = null;
2972
+ function engineProbeKey(config) {
2973
+ return JSON.stringify({
2974
+ default: config.engines.default,
2975
+ claude: config.engines.claude?.bin, codex: config.engines.codex?.bin, gemini: config.engines.gemini?.bin,
2976
+ });
2977
+ }
2978
+ function runBinary(bin, args, timeoutMs) {
2979
+ return new Promise((resolve) => {
2980
+ import("node:child_process").then(({ execFile }) => {
2981
+ execFile(bin, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
2982
+ resolve({ ok: !err, stdout: String(stdout ?? ""), stderr: String(stderr ?? err?.message ?? "") });
2983
+ });
2984
+ });
2985
+ });
2986
+ }
2987
+ async function probeEngine(name, bin) {
2988
+ if (!bin)
2989
+ return { name, configured: false, installed: false, runnable: false };
2990
+ const { tryResolveBin } = await import("../shared/resolveBin.js");
2991
+ const resolved = tryResolveBin(bin);
2992
+ if (!resolved)
2993
+ return { name, configured: true, installed: false, runnable: false, bin, error: `${bin} が PATH にありません` };
2994
+ const version = await runBinary(resolved, ["--version"], 8_000);
2995
+ if (!version.ok) {
2996
+ return { name, configured: true, installed: true, runnable: false, bin: resolved,
2997
+ error: version.stderr.trim().split("\n")[0] || "起動に失敗しました" };
2998
+ }
2999
+ return { name, configured: true, installed: true, runnable: true, bin: resolved, version: version.stdout.trim().split("\n")[0] };
3000
+ }
3001
+ /** What can be observed about Claude's login WITHOUT spending anything: an API
3002
+ * key in the environment, or the subscription OAuth file's expiry. Neither
3003
+ * proves the credential still works — the note says so. */
3004
+ function observeClaudeAuth() {
3005
+ if (process.env.ANTHROPIC_API_KEY)
3006
+ return { method: "api-key", note: "API キーが設定されています(有効性は初回実行時に判明)" };
3007
+ try {
3008
+ const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
3009
+ const raw = fs.readFileSync(path.join(dir, ".credentials.json"), "utf-8");
3010
+ const expiresAt = JSON.parse(raw)?.claudeAiOauth?.expiresAt;
3011
+ if (typeof expiresAt === "number" && expiresAt > 0) {
3012
+ const expired = expiresAt < Date.now();
3013
+ return { method: "oauth", expiresAt: new Date(expiresAt).toISOString(), expired,
3014
+ note: expired ? "ログインの期限が切れています(claude を一度起動すると更新されます)" : "ログイン情報があります(有効性は初回実行時に判明)" };
3015
+ }
3016
+ return { method: "unknown", note: "ログイン情報の形式を判定できませんでした" };
3017
+ }
3018
+ catch {
3019
+ return { method: "none", note: "ログイン情報が見つかりません(端末で claude と入力してログイン)" };
3020
+ }
3021
+ }
3022
+ /** `codex login status` answers with its exit code — the one engine that can
3023
+ * be asked directly, still without spending anything. */
3024
+ async function observeCodexAuth(resolvedBin) {
3025
+ const status = await runBinary(resolvedBin, ["login", "status"], 8_000);
3026
+ if (status.ok)
3027
+ return { method: "chatgpt", note: (status.stdout.trim().split("\n")[0] || "ログイン済み") };
3028
+ return { method: "none", note: "未ログインです(端末で codex login を実行)" };
3029
+ }
3030
+ async function probeOnboardingEngines(config) {
3031
+ const key = engineProbeKey(config);
3032
+ if (engineProbeCache && engineProbeCache.key === key && Date.now() - engineProbeCache.at < ENGINE_PROBE_TTL_MS) {
3033
+ return engineProbeCache.value;
3034
+ }
3035
+ if (engineProbeInFlight && engineProbeInFlight.key === key)
3036
+ return engineProbeInFlight.promise;
3037
+ const promise = (async () => {
3038
+ const [claude, codex, gemini] = await Promise.all([
3039
+ probeEngine("claude", config.engines.claude?.bin),
3040
+ probeEngine("codex", config.engines.codex?.bin),
3041
+ probeEngine("gemini", config.engines.gemini?.bin),
3042
+ ]);
3043
+ if (claude.runnable)
3044
+ claude.auth = observeClaudeAuth();
3045
+ if (codex.runnable && codex.bin)
3046
+ codex.auth = await observeCodexAuth(codex.bin);
3047
+ const value = {
3048
+ default: config.engines.default, probedAt: new Date().toISOString(),
3049
+ engines: [claude, codex, ...(gemini.configured ? [gemini] : [])],
3050
+ };
3051
+ // Only the newest config's probe may become "the" cache; a probe that ran
3052
+ // under a config since replaced must not resurrect a stale answer.
3053
+ if (!engineProbeCache || engineProbeCache.key === key || engineProbeInFlight?.key === key) {
3054
+ engineProbeCache = { key, at: Date.now(), value };
3055
+ }
3056
+ return value;
3057
+ })();
3058
+ engineProbeInFlight = { key, promise };
3059
+ promise.finally(() => { if (engineProbeInFlight?.promise === promise)
3060
+ engineProbeInFlight = null; });
3061
+ return promise;
3062
+ }
3063
+ /** Exposed for tests: forget the cached probe. */
3064
+ export function _resetEngineProbeCache() {
3065
+ engineProbeCache = null;
3066
+ engineProbeInFlight = null;
3067
+ }
3068
+ async function slackApi(method, token) {
3069
+ try {
3070
+ const response = await fetch(`https://slack.com/api/${method}`, {
3071
+ method: "POST",
3072
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-www-form-urlencoded" },
3073
+ signal: AbortSignal.timeout(10_000),
3074
+ });
3075
+ if (!response.ok) {
3076
+ logger.warn(`[onboarding] Slack ${method} answered HTTP ${response.status}`);
3077
+ return { ok: false, error: `http_${response.status}` };
3078
+ }
3079
+ const payload = await response.json();
3080
+ return { ...payload, ok: payload.ok === true };
3081
+ }
3082
+ catch (err) {
3083
+ // The raw message (DNS, TLS, timeout details) goes to the log; the client
3084
+ // gets a stable, non-leaky code.
3085
+ logger.warn(`[onboarding] Slack ${method} request failed: ${err instanceof Error ? err.message : String(err)}`);
3086
+ return { ok: false, error: "network_error" };
3087
+ }
3088
+ }
3089
+ async function verifySlackTokens(botToken, appToken) {
3090
+ const [bot, app] = await Promise.all([
3091
+ slackApi("auth.test", botToken),
3092
+ slackApi("apps.connections.open", appToken),
3093
+ ]);
3094
+ const botResult = bot.ok
3095
+ ? { ok: true, team: typeof bot.team === "string" ? bot.team : undefined, user: typeof bot.user === "string" ? bot.user : undefined }
3096
+ : { ok: false, error: bot.error ?? "auth.test failed" };
3097
+ const appResult = app.ok ? { ok: true } : { ok: false, error: app.error ?? "apps.connections.open failed" };
3098
+ return { ok: botResult.ok && appResult.ok, bot: botResult, app: appResult };
3099
+ }
3100
+ /** Merge a patch into config.yaml and reload connectors — the same steps
3101
+ * PUT /api/config performs, callable from a server-side flow that has to
3102
+ * read the outcome and possibly undo the write. */
3103
+ async function writeConfigPatchAndReload(context, patch) {
3104
+ let existing = {};
3105
+ try {
3106
+ existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
3107
+ }
3108
+ catch { /* first write */ }
3109
+ const merged = deepMerge(existing, patch);
3110
+ context.suppressNextConnectorReload?.();
3111
+ fs.writeFileSync(CONFIG_PATH, yaml.dump(merged));
3112
+ invalidateModelRegistry();
3113
+ _resetEngineProbeCache();
3114
+ if (!context.reloadAllConnectors)
3115
+ return { status: "ok" };
3116
+ try {
3117
+ const reload = await context.reloadAllConnectors();
3118
+ context.config = context.getConfig();
3119
+ context.emit("connectors:reloaded", reload);
3120
+ if (reload.errors.length > 0)
3121
+ context.clearSuppressNextConnectorReload?.();
3122
+ return { status: reload.errors.length > 0 ? "partial" : "ok", connectorsReload: reload };
3123
+ }
3124
+ catch (err) {
3125
+ context.clearSuppressNextConnectorReload?.();
3126
+ return { status: "ok", connectorsReloadError: err instanceof Error ? err.message : String(err) };
3127
+ }
3128
+ }
3129
+ function slackFailure(outcome) {
3130
+ if (outcome.connectorsReloadError)
3131
+ return outcome.connectorsReloadError;
3132
+ const errors = outcome.connectorsReload?.errors ?? [];
3133
+ const slack = errors.find((line) => /slack/i.test(line));
3134
+ if (slack)
3135
+ return slack;
3136
+ if (outcome.status === "partial" && errors.length > 0)
3137
+ return errors.join(" | ");
3138
+ return null;
3139
+ }
3140
+ function readSlackBlock() {
3141
+ try {
3142
+ const current = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
3143
+ return current.connectors?.slack ?? null;
3144
+ }
3145
+ catch {
3146
+ return null;
3147
+ }
3148
+ }
3149
+ async function connectSlack(context, botToken, appToken) {
3150
+ const verified = await verifySlackTokens(botToken, appToken);
3151
+ if (!verified.ok)
3152
+ return { ok: false, stage: "verify", error: "token verification failed", bot: verified.bot, app: verified.app };
3153
+ // Snapshot → write → reload → (rollback) is ONE critical section: a PUT
3154
+ // /api/config or a second connect waits for it, so the snapshot we would
3155
+ // restore can never be older than what a concurrent writer put down.
3156
+ return withConfigLock(async () => {
3157
+ const previousSlack = readSlackBlock();
3158
+ const previous = previousSlack ? "config" : "none";
3159
+ const outcome = await writeConfigPatchAndReload(context, { connectors: { slack: { botToken, appToken } } });
3160
+ const failure = slackFailure(outcome);
3161
+ if (!failure)
3162
+ return { ok: true, team: verified.bot.team, user: verified.bot.user };
3163
+ // The connector did not come up on tokens Slack itself accepted. Put the
3164
+ // previous configuration back (or remove the block if there was none) —
3165
+ // but only if the block on disk is still OURS: an out-of-band editor may
3166
+ // have written something else meanwhile, and that is theirs to keep.
3167
+ const onDisk = readSlackBlock();
3168
+ if (onDisk?.botToken !== botToken || onDisk?.appToken !== appToken) {
3169
+ logger.warn(`[onboarding] Slack connect failed (${failure}) but the Slack config changed concurrently — leaving it as is`);
3170
+ return { ok: false, stage: "reload", error: failure, previous, rolledBack: false,
3171
+ restored: { disk: false, running: false }, rollbackSkipped: "config changed concurrently" };
3172
+ }
3173
+ logger.warn(`[onboarding] Slack connect failed after save (${failure}) — ${previous === "config" ? "restoring previous Slack config" : "removing the Slack block again"}`);
3174
+ let rollback;
3175
+ try {
3176
+ rollback = await writeConfigPatchAndReload(context, { connectors: { slack: previousSlack ?? null } });
3177
+ }
3178
+ catch (err) {
3179
+ const rollbackError = err instanceof Error ? err.message : String(err);
3180
+ logger.error(`[onboarding] Slack rollback write failed: ${rollbackError}`);
3181
+ return { ok: false, stage: "reload", error: failure, previous, rolledBack: false,
3182
+ restored: { disk: false, running: false }, rollbackError };
3183
+ }
3184
+ // The rollback is only a rollback if the live side settled on the restored
3185
+ // file: the previous connector came back up (previous:"config"), or the
3186
+ // removal reload finished without a Slack error (previous:"none"). A
3187
+ // partial/errored second reload is reported as such in both cases — the
3188
+ // file is back, the connectors are not known to be.
3189
+ const rollbackFailure = slackFailure(rollback);
3190
+ if (rollbackFailure) {
3191
+ logger.error(`[onboarding] Slack rollback wrote the previous config but the connectors did not settle on it: ${rollbackFailure}`);
3192
+ return { ok: false, stage: "reload", error: failure, previous, rolledBack: false,
3193
+ restored: { disk: true, running: false }, rollbackError: rollbackFailure };
3194
+ }
3195
+ return { ok: false, stage: "reload", error: failure, previous, rolledBack: true, restored: { disk: true, running: previous === "config" } };
3196
+ });
3197
+ }
2899
3198
  //# sourceMappingURL=api.js.map