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.
- package/dist/package.json +5 -2
- package/dist/src/connectors/slack/index.d.ts.map +1 -1
- package/dist/src/connectors/slack/index.js +7 -1
- package/dist/src/connectors/slack/index.js.map +1 -1
- package/dist/src/connectors/slack/respond-policy.d.ts +16 -0
- package/dist/src/connectors/slack/respond-policy.d.ts.map +1 -1
- package/dist/src/connectors/slack/respond-policy.js +20 -0
- package/dist/src/connectors/slack/respond-policy.js.map +1 -1
- package/dist/src/gateway/api.d.ts +3 -0
- package/dist/src/gateway/api.d.ts.map +1 -1
- package/dist/src/gateway/api.js +373 -74
- package/dist/src/gateway/api.js.map +1 -1
- package/dist/src/gateway/server.d.ts.map +1 -1
- package/dist/src/gateway/server.js +24 -17
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/web/404.html +1 -1
- package/dist/web/__next.__PAGE__.txt +11 -11
- package/dist/web/__next._full.txt +12 -12
- package/dist/web/__next._tree.txt +2 -2
- package/dist/web/_next/static/chunks/0los6agi5yfk-.js +1 -0
- package/dist/web/_next/static/chunks/{1ftanci12q2ck.js → 0nsg_lm2gtp4v.js} +1 -1
- package/dist/web/_next/static/chunks/22x0rizn0r035.js +7 -0
- package/dist/web/_next/static/chunks/2zl5_vtgxjbs1.js +1 -0
- package/dist/web/_next/static/chunks/318j2wz96k6gv.js +1 -0
- package/dist/web/_next/static/chunks/3stop8a72z6ft.css +1 -0
- package/dist/web/_not-found/__next._full.txt +10 -10
- package/dist/web/_not-found/__next._not-found.__PAGE__.txt +9 -9
- package/dist/web/_not-found/__next._tree.txt +2 -2
- package/dist/web/_not-found.html +1 -1
- package/dist/web/_not-found.txt +10 -10
- package/dist/web/chat/__next._full.txt +12 -12
- package/dist/web/chat/__next._tree.txt +2 -2
- package/dist/web/chat/__next.chat.__PAGE__.txt +11 -11
- package/dist/web/chat.html +1 -1
- package/dist/web/chat.txt +12 -12
- package/dist/web/cron/__next._full.txt +12 -12
- package/dist/web/cron/__next._tree.txt +2 -2
- package/dist/web/cron/__next.cron.__PAGE__.txt +11 -11
- package/dist/web/cron.html +1 -1
- package/dist/web/cron.txt +12 -12
- package/dist/web/index.html +1 -1
- package/dist/web/index.txt +12 -12
- package/dist/web/kanban/__next._full.txt +12 -12
- package/dist/web/kanban/__next._tree.txt +2 -2
- package/dist/web/kanban/__next.kanban.__PAGE__.txt +11 -11
- package/dist/web/kanban.html +1 -1
- package/dist/web/kanban.txt +12 -12
- package/dist/web/logs/__next._full.txt +12 -12
- package/dist/web/logs/__next._tree.txt +2 -2
- package/dist/web/logs/__next.logs.__PAGE__.txt +11 -11
- package/dist/web/logs.html +1 -1
- package/dist/web/logs.txt +12 -12
- package/dist/web/org/__next._full.txt +12 -12
- package/dist/web/org/__next._tree.txt +2 -2
- package/dist/web/org/__next.org.__PAGE__.txt +11 -11
- package/dist/web/org.html +1 -1
- package/dist/web/org.txt +12 -12
- package/dist/web/sessions/__next._full.txt +10 -10
- package/dist/web/sessions/__next._tree.txt +2 -2
- package/dist/web/sessions/__next.sessions.__PAGE__.txt +9 -9
- package/dist/web/sessions.html +1 -1
- package/dist/web/sessions.txt +10 -10
- package/dist/web/settings/__next._full.txt +12 -12
- package/dist/web/settings/__next._tree.txt +2 -2
- package/dist/web/settings/__next.settings.__PAGE__.txt +11 -11
- package/dist/web/settings.html +1 -1
- package/dist/web/settings.txt +12 -12
- package/dist/web/skills/__next._full.txt +12 -12
- package/dist/web/skills/__next._tree.txt +2 -2
- package/dist/web/skills/__next.skills.__PAGE__.txt +11 -11
- package/dist/web/skills.html +1 -1
- package/dist/web/skills.txt +12 -12
- package/package.json +5 -2
- package/scripts/fix-prebuild-permissions.mjs +122 -0
- package/dist/web/_next/static/chunks/0fqryzd-l87nh.js +0 -1
- package/dist/web/_next/static/chunks/1jlzfg7b2qv3x.js +0 -1
- package/dist/web/_next/static/chunks/1v0-xm12dgqnn.css +0 -1
- package/dist/web/_next/static/chunks/2z0rerwculapx.js +0 -7
- package/dist/web/_next/static/chunks/42cww7d-zotwu.js +0 -1
- /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_clientMiddlewareManifest.js +0 -0
- /package/dist/web/_next/static/{ufk1kTKwxd6lBHX-sBAGO → Kudq_QcLnSoz7UYDmluYu}/_ssgManifest.js +0 -0
package/dist/src/gateway/api.js
CHANGED
|
@@ -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/
|
|
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
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
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
|