@loicngr/kobo 1.11.27 → 1.11.28
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/AGENTS.md +19 -1
- package/CHANGELOG.md +6 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
- package/dist/server/db/migrations.js +34 -0
- package/dist/server/db/schema.js +3 -1
- package/dist/server/index.js +80 -23
- package/dist/server/middleware/host-check-middleware.js +80 -0
- package/dist/server/routes/changelog.js +51 -3
- package/dist/server/routes/notion.js +1 -1
- package/dist/server/routes/pull-requests.js +6 -2
- package/dist/server/routes/sentry.js +1 -1
- package/dist/server/routes/settings.js +23 -8
- package/dist/server/routes/usage.js +12 -0
- package/dist/server/routes/workspaces.js +212 -80
- package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/orchestrator.js +129 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/auto-loop-service.js +65 -0
- package/dist/server/services/awaiting-user-reminder-service.js +115 -0
- package/dist/server/services/change-source-branch-service.js +1 -1
- package/dist/server/services/forge/resolve.js +29 -1
- package/dist/server/services/lifecycle-hook-service.js +114 -0
- package/dist/server/services/network-access-service.js +187 -6
- package/dist/server/services/pr-watcher-service.js +27 -1
- package/dist/server/services/settings-service.js +147 -4
- package/dist/server/services/usage/reliability.js +81 -0
- package/dist/server/services/wakeup-service.js +23 -0
- package/dist/server/services/websocket-service.js +31 -3
- package/dist/server/services/workspace-service.js +36 -4
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +159 -1
- package/dist/server/utils/paths.js +19 -0
- package/dist/server/utils/safe-path.js +21 -1
- package/dist/server/utils/script-runner.js +2 -1
- package/dist/server/utils/workspace-name.js +37 -0
- package/dist/shared/consts.js +22 -0
- package/dist/shared/models.js +4 -4
- package/package.json +3 -2
- package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
- package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
- package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-CSPk8QtK.js} +3 -3
- package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
- package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
- package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-Db4w0alg.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
- package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
- package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-KxB1BtrL.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-7TByd_Zc.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-BoVZ_gre.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
- package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
- package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-BLL8vPQy.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-DzcT-R6k.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-Bguo999w.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-C7mNmzKx.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-54JZHVy2.js} +1 -1
- package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-D5RQNiPF.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-CVwoDJol.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-NAmPVIQp.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-qdgwClKe.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DNSfQSlk.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-6gnX2NnP.js} +1 -1
- package/src/client/dist/pwa/index.html +5 -4
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
- package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
- package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
- package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
- package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
- package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
- package/src/client/dist/pwa/assets/workers-D28S2iKq.js +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { getDb } from '../db/index.js';
|
|
3
|
+
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
4
|
+
import { runScript } from '../utils/script-runner.js';
|
|
5
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
|
+
import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
|
|
7
|
+
/** Which effective-settings key holds the script for each event. */
|
|
8
|
+
const SCRIPT_KEY = {
|
|
9
|
+
'session-ended': 'sessionEndedScript',
|
|
10
|
+
'pr-merged': 'prMergedScript',
|
|
11
|
+
'autoloop-disabled': 'autoLoopDisabledScript',
|
|
12
|
+
};
|
|
13
|
+
function getRow(workspaceId) {
|
|
14
|
+
const row = getDb()
|
|
15
|
+
.prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path
|
|
16
|
+
FROM workspaces WHERE id = ?`)
|
|
17
|
+
.get(workspaceId);
|
|
18
|
+
return row ?? null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run the user's hook for `event`, if they configured one.
|
|
22
|
+
*
|
|
23
|
+
* Resolves to `null` when there is nothing to run — no script, unknown
|
|
24
|
+
* workspace, or a worktree that is no longer on disk — and to the script's exit
|
|
25
|
+
* code otherwise. Never rejects: the caller is always in the middle of a
|
|
26
|
+
* lifecycle transition that must complete regardless.
|
|
27
|
+
*/
|
|
28
|
+
export async function runLifecycleHook(event, workspaceId, extraEnv) {
|
|
29
|
+
try {
|
|
30
|
+
const row = getRow(workspaceId);
|
|
31
|
+
if (!row)
|
|
32
|
+
return null;
|
|
33
|
+
const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
|
|
34
|
+
// Empty (or blank) means the user never wrote one. Hooks are opt-in: they
|
|
35
|
+
// run arbitrary shell, so absence of configuration is absence of a hook.
|
|
36
|
+
if (!script.trim())
|
|
37
|
+
return null;
|
|
38
|
+
const global = getGlobalSettings();
|
|
39
|
+
const projectSettings = getProjectSettings(row.project_path);
|
|
40
|
+
const projectSlug = global.worktreesPrefixByProject
|
|
41
|
+
? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
|
|
42
|
+
: undefined;
|
|
43
|
+
const worktreePath = row.worktree_path ??
|
|
44
|
+
resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
|
|
45
|
+
// A purged or manually deleted worktree has no cwd to spawn in. Routine
|
|
46
|
+
// for `pr-merged` once auto-purge has run; for the other events it means
|
|
47
|
+
// the user's script silently did not run, which deserves a line in the log.
|
|
48
|
+
if (!fs.existsSync(worktreePath)) {
|
|
49
|
+
if (event !== 'pr-merged') {
|
|
50
|
+
console.warn(`[lifecycle-hook] '${event}' hook skipped for workspace '${workspaceId}': worktree missing at ${worktreePath}`);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return await runScript({
|
|
55
|
+
workspaceId,
|
|
56
|
+
worktreePath,
|
|
57
|
+
script,
|
|
58
|
+
// Namespaced per event: three hooks streaming into one `hook:output` feed
|
|
59
|
+
// would be indistinguishable in the UI.
|
|
60
|
+
eventPrefix: `hook:${event}`,
|
|
61
|
+
tmpFileName: `.hook-${event}.tmp`,
|
|
62
|
+
env: {
|
|
63
|
+
workspaceName: row.name,
|
|
64
|
+
branchName: row.working_branch,
|
|
65
|
+
sourceBranch: row.source_branch,
|
|
66
|
+
projectPath: row.project_path,
|
|
67
|
+
},
|
|
68
|
+
extraEnv: { ...extraEnv, KOBO_HOOK_EVENT: event },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error(`[lifecycle-hook] '${event}' hook failed for workspace '${workspaceId}':`, err);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* An agent session ended. Fires for every real end — clean finish, error, user
|
|
78
|
+
* stop, watchdog kill — and lets the script tell them apart via
|
|
79
|
+
* `KOBO_SESSION_END_REASON`. Typical use: run the test suite, or notify.
|
|
80
|
+
*/
|
|
81
|
+
export async function onSessionEnded(workspaceId, outcome) {
|
|
82
|
+
await runLifecycleHook('session-ended', workspaceId, {
|
|
83
|
+
KOBO_SESSION_ID: outcome.sessionId,
|
|
84
|
+
KOBO_SESSION_END_REASON: outcome.reason,
|
|
85
|
+
// A missing exit code is an empty variable, not the string 'null': a shell
|
|
86
|
+
// script testing `-z "$KOBO_SESSION_EXIT_CODE"` should see nothing there.
|
|
87
|
+
KOBO_SESSION_EXIT_CODE: outcome.exitCode === null ? '' : String(outcome.exitCode),
|
|
88
|
+
KOBO_SESSION_STOP_CAUSE: outcome.stopCause ?? '',
|
|
89
|
+
// A hook that runs the test suite should know the next iteration may
|
|
90
|
+
// already be editing the same files.
|
|
91
|
+
KOBO_AUTOLOOP_ACTIVE: outcome.autoLoopActive ? '1' : '0',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A PR was merged. Runs before the workspace is archived or purged, so the
|
|
96
|
+
* worktree is still on disk and the script can act on it (deploy, tag, notify).
|
|
97
|
+
*/
|
|
98
|
+
export async function onPrMerged(workspaceId, pr) {
|
|
99
|
+
await runLifecycleHook('pr-merged', workspaceId, {
|
|
100
|
+
KOBO_PR_NUMBER: String(pr.prNumber),
|
|
101
|
+
KOBO_PR_URL: pr.prUrl,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Auto-loop turned itself off. `KOBO_AUTOLOOP_REASON` separates the loop
|
|
106
|
+
* finishing its work from it stalling or erroring out — the difference between
|
|
107
|
+
* a notification worth celebrating and one worth investigating.
|
|
108
|
+
*/
|
|
109
|
+
export async function onAutoLoopDisabled(workspaceId, info) {
|
|
110
|
+
await runLifecycleHook('autoloop-disabled', workspaceId, {
|
|
111
|
+
KOBO_AUTOLOOP_REASON: info.reason,
|
|
112
|
+
KOBO_TASKS_PENDING: String(info.tasksPending),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -11,19 +11,200 @@ export function isLoopbackAddress(address) {
|
|
|
11
11
|
export function resolveBindHost(enabled) {
|
|
12
12
|
return enabled ? undefined : '127.0.0.1';
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Non-internal addresses of the running server, one per LAN interface. Both
|
|
16
|
+
* families: with network access on, the server binds every interface, so an
|
|
17
|
+
* IPv6 address is as legitimate a way to reach it as an IPv4 one. A scoped
|
|
18
|
+
* link-local address (`fe80::1%eth0`) loses its zone, which is the form a URL
|
|
19
|
+
* carries.
|
|
20
|
+
*/
|
|
21
|
+
export function getLanHostnames() {
|
|
22
|
+
return collectLanAddresses(null);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Non-internal IPv4 URLs for the running server, for display + QR. IPv4 only:
|
|
26
|
+
* these are meant to be read off a screen or scanned from a phone, and an IPv6
|
|
27
|
+
* literal is neither.
|
|
28
|
+
*/
|
|
15
29
|
export function getLanUrls(port) {
|
|
16
|
-
|
|
30
|
+
return collectLanAddresses('IPv4').map((address) => `http://${address}:${port}`);
|
|
31
|
+
}
|
|
32
|
+
function collectLanAddresses(family) {
|
|
33
|
+
const addresses = [];
|
|
17
34
|
for (const infos of Object.values(os.networkInterfaces())) {
|
|
18
35
|
if (!infos)
|
|
19
36
|
continue;
|
|
20
37
|
for (const info of infos) {
|
|
21
|
-
if (info.
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
if (info.internal)
|
|
39
|
+
continue;
|
|
40
|
+
if (family !== null && info.family !== family)
|
|
41
|
+
continue;
|
|
42
|
+
// A scoped link-local address (`fe80::1%eth0`) loses its zone, which is
|
|
43
|
+
// the form a URL carries.
|
|
44
|
+
addresses.push(info.address.split('%')[0]);
|
|
24
45
|
}
|
|
25
46
|
}
|
|
26
|
-
return
|
|
47
|
+
return addresses;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Hostnames that always designate the host machine itself. A request or a page
|
|
51
|
+
* carrying one of these is, by construction, already running on the user's own
|
|
52
|
+
* machine, whatever port it came from — which is what keeps the Quasar dev
|
|
53
|
+
* server (localhost:8080 proxying to localhost:3000) working.
|
|
54
|
+
*/
|
|
55
|
+
// `::ffff:7f00:1` is what the URL parser normalises `::ffff:127.0.0.1` into,
|
|
56
|
+
// so both spellings of the IPv4-mapped loopback land here. `0.0.0.0` and `::`
|
|
57
|
+
// name the wildcard bind rather than a host, but Linux routes them to loopback
|
|
58
|
+
// and `http://0.0.0.0:3000` is a common bookmark; a rebinding page can never
|
|
59
|
+
// produce them, since the browser puts its own domain in the Host.
|
|
60
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '0.0.0.0', '::']);
|
|
61
|
+
/**
|
|
62
|
+
* Hostname carried by a raw `Host` or `Origin` header, or null when the value
|
|
63
|
+
* cannot be parsed. `Host` arrives bare (`127.0.0.1:3000`), `Origin` with a
|
|
64
|
+
* scheme (`http://127.0.0.1:3000`); adding a scheme when none is present runs
|
|
65
|
+
* both through the same URL rules, including the bracketed IPv6 form.
|
|
66
|
+
*/
|
|
67
|
+
function headerHostname(value) {
|
|
68
|
+
try {
|
|
69
|
+
const hostname = new URL(value.includes('://') ? value : `http://${value}`).hostname;
|
|
70
|
+
if (!hostname)
|
|
71
|
+
return null;
|
|
72
|
+
// The URL parser keeps IPv6 hostnames bracketed; compare on the bare address.
|
|
73
|
+
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True when a raw `Host`/`Origin` value names the machine itself. Always
|
|
81
|
+
* allowed, whatever the settings say, which lets a caller settle the common
|
|
82
|
+
* case without reading the settings file or listing the network interfaces —
|
|
83
|
+
* both synchronous, and this runs on every request.
|
|
84
|
+
*/
|
|
85
|
+
export function isLocalRequestHost(value) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return false;
|
|
88
|
+
const hostname = headerHostname(value);
|
|
89
|
+
return hostname !== null && LOCAL_HOSTNAMES.has(hostname);
|
|
90
|
+
}
|
|
91
|
+
function isAllowedHostname(hostname, enabled, lanHostnames) {
|
|
92
|
+
if (!hostname)
|
|
93
|
+
return false;
|
|
94
|
+
if (LOCAL_HOSTNAMES.has(hostname))
|
|
95
|
+
return true;
|
|
96
|
+
return enabled && lanHostnames.includes(hostname);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Guards against DNS rebinding: a page on `evil.com` whose domain re-resolves
|
|
100
|
+
* to 127.0.0.1 reaches this server with `Host: evil.com`, and the browser then
|
|
101
|
+
* treats every response as same-origin. Only the names by which the machine is
|
|
102
|
+
* legitimately reachable are accepted — its own loopback names, plus its LAN
|
|
103
|
+
* addresses once network access is enabled.
|
|
104
|
+
*
|
|
105
|
+
* Behind a reverse proxy the Host is whatever domain the operator chose. When
|
|
106
|
+
* they declare it via `KOBO_NETWORK_ACCESS_PROXY_HOST` it is enforced like any
|
|
107
|
+
* other name; without it we cannot tell their domain from an attacker's, so
|
|
108
|
+
* anything is accepted. That mode also turns off the loopback exemption, so
|
|
109
|
+
* every `/api/*` request has to carry the token.
|
|
110
|
+
*
|
|
111
|
+
* Residual risk when the hostname is left undeclared: the SPA shell is served
|
|
112
|
+
* outside `/api/*` and therefore behind no token, so a rebinding page can be
|
|
113
|
+
* handed the real interface and ask the user to paste their token into it.
|
|
114
|
+
* Declaring the hostname closes that; otherwise, authenticate at the proxy.
|
|
115
|
+
*/
|
|
116
|
+
export function isAllowedRequestHost(params) {
|
|
117
|
+
if (!params.host)
|
|
118
|
+
return false;
|
|
119
|
+
const hostname = headerHostname(params.host);
|
|
120
|
+
if (params.behindProxy) {
|
|
121
|
+
// No declared hostname means we cannot tell the operator's domain from an
|
|
122
|
+
// attacker's, so anything goes — the historical behaviour.
|
|
123
|
+
if (!params.proxyHostname)
|
|
124
|
+
return true;
|
|
125
|
+
return hostname === params.proxyHostname || (hostname !== null && LOCAL_HOSTNAMES.has(hostname));
|
|
126
|
+
}
|
|
127
|
+
return isAllowedHostname(hostname, params.enabled, params.lanHostnames);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Hostname declared via `KOBO_NETWORK_ACCESS_PROXY_HOST`, or null when unset.
|
|
131
|
+
*
|
|
132
|
+
* Behind a reverse proxy we otherwise have to accept any Host, which leaves the
|
|
133
|
+
* door open to a rebinding page being served the real interface and asking the
|
|
134
|
+
* user to paste their token into it. Declaring the domain the proxy serves
|
|
135
|
+
* closes that, and costs one environment variable in the compose file where
|
|
136
|
+
* the other network-access settings already live.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveProxyHostname(env = process.env) {
|
|
139
|
+
const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
|
|
140
|
+
if (!raw)
|
|
141
|
+
return null;
|
|
142
|
+
return headerHostname(raw);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Names the site a request was driven from. Guards two things.
|
|
146
|
+
*
|
|
147
|
+
* The WebSocket upgrade: WebSockets are exempt from the same-origin policy and
|
|
148
|
+
* send no preflight, so without this any page the user visits could open
|
|
149
|
+
* `/ws/terminal/<id>` and drive a real shell on the host.
|
|
150
|
+
*
|
|
151
|
+
* And cross-site writes: a page on another site reaching `http://localhost:3000`
|
|
152
|
+
* sends a perfectly legitimate `Host: localhost:3000` — the browser puts it
|
|
153
|
+
* there — so the Host check cannot see it. Reading the reply is already blocked
|
|
154
|
+
* (we send no CORS headers), but the write would still land, because Hono parses
|
|
155
|
+
* a JSON body whatever the Content-Type: a `text/plain` POST is a CORS simple
|
|
156
|
+
* request and needs no preflight.
|
|
157
|
+
*
|
|
158
|
+
* A missing Origin is allowed. Browsers attach one to every WebSocket handshake
|
|
159
|
+
* and every state-changing request, so its absence means a non-browser caller —
|
|
160
|
+
* curl, or Kōbō's own MCP server calling back into the API. Such a caller on
|
|
161
|
+
* loopback already has the machine, and over the LAN still needs the token.
|
|
162
|
+
*
|
|
163
|
+
* The rule is that the page must have been served by us: its authority has to
|
|
164
|
+
* match the Host this very request carries. That survives a port remap
|
|
165
|
+
* (`docker -p 3001:3000`, `ssh -L`) which a fixed port list would break, while
|
|
166
|
+
* still rejecting a page on another loopback port — including the per-workspace
|
|
167
|
+
* dev servers Kōbō itself spawns, serving whatever code an agent just wrote.
|
|
168
|
+
*
|
|
169
|
+
* Matching the Host is not a defense on its own, since a rebinding page matches
|
|
170
|
+
* it too. The Host check above is what rejects that one; this must simply not
|
|
171
|
+
* undo it.
|
|
172
|
+
*/
|
|
173
|
+
export function isAllowedOrigin(params) {
|
|
174
|
+
if (params.origin === undefined)
|
|
175
|
+
return true;
|
|
176
|
+
const origin = authority(params.origin);
|
|
177
|
+
if (origin === null)
|
|
178
|
+
return false;
|
|
179
|
+
if (params.behindProxy) {
|
|
180
|
+
if (!params.proxyHostname)
|
|
181
|
+
return true;
|
|
182
|
+
return headerHostname(params.origin) === params.proxyHostname;
|
|
183
|
+
}
|
|
184
|
+
// `quasar dev` proxies to the backend with changeOrigin, so it rewrites the
|
|
185
|
+
// Host to ours while the browser stays on the Quasar port. The two cannot
|
|
186
|
+
// match there, hence the explicit declaration.
|
|
187
|
+
if (params.devOrigin && origin === authority(params.devOrigin))
|
|
188
|
+
return true;
|
|
189
|
+
return params.requestHost !== undefined && origin === authority(params.requestHost);
|
|
190
|
+
}
|
|
191
|
+
/** Host and port of a raw `Host`/`Origin` value, or null when unparseable. */
|
|
192
|
+
function authority(value) {
|
|
193
|
+
try {
|
|
194
|
+
return new URL(value.includes('://') ? value : `http://${value}`).host || null;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Origin the development client is served from, declared by the `dev` script
|
|
202
|
+
* via `KOBO_DEV_CLIENT_ORIGIN`. Never set in a published build, so it cannot
|
|
203
|
+
* widen anything in production.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveDevClientOrigin(env = process.env) {
|
|
206
|
+
const raw = env.KOBO_DEV_CLIENT_ORIGIN?.trim();
|
|
207
|
+
return raw ? raw : null;
|
|
27
208
|
}
|
|
28
209
|
/** ~32-char url-safe random token. */
|
|
29
210
|
export function generateToken() {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { fetchSourceBranchAsync, isGitWorktree } from '../utils/git-ops.js';
|
|
3
3
|
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
4
|
+
import { hasController } from './agent/orchestrator.js';
|
|
4
5
|
import { stopDevServer } from './dev-server-service.js';
|
|
5
6
|
import { getForgeProvider } from './forge/registry.js';
|
|
6
7
|
import { resolveForge } from './forge/resolve.js';
|
|
7
8
|
import { computeGitStats } from './git-stats-service.js';
|
|
9
|
+
import * as lifecycleHookService from './lifecycle-hook-service.js';
|
|
8
10
|
import { getGlobalSettings } from './settings-service.js';
|
|
9
11
|
import { destroyTerminal } from './terminal-service.js';
|
|
10
12
|
import { emitEphemeral } from './websocket-service.js';
|
|
@@ -21,6 +23,8 @@ import { purgeWorktree } from './worktree-purge-service.js';
|
|
|
21
23
|
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
22
24
|
const WORKSPACE_CHECK_CONCURRENCY = 4;
|
|
23
25
|
let timer = null;
|
|
26
|
+
/** Latched by `stopPrWatcher` so a tick already in flight does not re-arm. */
|
|
27
|
+
let stopped = false;
|
|
24
28
|
let checking = false;
|
|
25
29
|
async function runBounded(items, concurrency, worker) {
|
|
26
30
|
let nextIndex = 0;
|
|
@@ -186,16 +190,28 @@ export async function checkPrStatuses() {
|
|
|
186
190
|
// Archive on a transition FROM OPEN to CLOSED/MERGED. Skips the
|
|
187
191
|
// base-change detection below — archiving wins.
|
|
188
192
|
if (prev?.state === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
|
|
193
|
+
// Started here, awaited just before the purge below: the worktree is
|
|
194
|
+
// still on disk at this point, and a hook that deploys or tags from it
|
|
195
|
+
// must not have the ground removed under it by auto-purge.
|
|
196
|
+
let prMergedHook = Promise.resolve();
|
|
189
197
|
if (pr.state === 'MERGED') {
|
|
190
198
|
emitEphemeral(ws.id, 'pr:merged', {
|
|
191
199
|
prNumber: pr.number,
|
|
192
200
|
prUrl: pr.url,
|
|
193
201
|
});
|
|
202
|
+
prMergedHook = lifecycleHookService.onPrMerged(ws.id, { prNumber: pr.number, prUrl: pr.url });
|
|
194
203
|
}
|
|
195
|
-
if (['extracting', 'brainstorming', 'executing'].includes(ws.status)) {
|
|
204
|
+
if (['extracting', 'brainstorming', 'executing'].includes(ws.status) || hasController(ws.id)) {
|
|
196
205
|
// Agent is working — update the cache but skip auto-archive.
|
|
197
206
|
// (The defensive base preservation from the no-base branch doesn't apply here
|
|
198
207
|
// because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
|
|
208
|
+
//
|
|
209
|
+
// The status list alone is not enough: `awaiting-user` is a live
|
|
210
|
+
// session parked on a tool approval, and a controller can still be
|
|
211
|
+
// draining while the status already reads idle or error. Unlike the
|
|
212
|
+
// manual archive route and purge-worktree, this path never stops the
|
|
213
|
+
// agent, so archiving under one orphans its pending question and
|
|
214
|
+
// leaves it writing into a workspace the UI now shows as archived.
|
|
199
215
|
lastKnownPr.set(ws.id, pr);
|
|
200
216
|
return;
|
|
201
217
|
}
|
|
@@ -226,6 +242,9 @@ export async function checkPrStatuses() {
|
|
|
226
242
|
try {
|
|
227
243
|
const { autoPurgeOnPrMerged } = getGlobalSettings();
|
|
228
244
|
if (autoPurgeOnPrMerged) {
|
|
245
|
+
// `onPrMerged` never rejects and is capped by the script-runner's
|
|
246
|
+
// own timeout, so this cannot hang the watcher indefinitely.
|
|
247
|
+
await prMergedHook;
|
|
229
248
|
void purgeWorktree(ws.id)
|
|
230
249
|
.then((result) => {
|
|
231
250
|
// Auto-purge runs with nobody watching: without this trace an
|
|
@@ -360,6 +379,11 @@ async function runOneCheck() {
|
|
|
360
379
|
function scheduleNext() {
|
|
361
380
|
timer = setTimeout(async () => {
|
|
362
381
|
await runOneCheck();
|
|
382
|
+
// A stop can land while the tick above is awaiting a `gh` call, which takes
|
|
383
|
+
// seconds. Without this the loop re-arms itself after shutdown was asked
|
|
384
|
+
// for, and `startPrWatcher`'s `if (timer) return` then sees a live timer.
|
|
385
|
+
if (stopped)
|
|
386
|
+
return;
|
|
363
387
|
scheduleNext();
|
|
364
388
|
}, POLL_INTERVAL_MS);
|
|
365
389
|
timer.unref?.();
|
|
@@ -368,6 +392,7 @@ function scheduleNext() {
|
|
|
368
392
|
export function startPrWatcher() {
|
|
369
393
|
if (timer)
|
|
370
394
|
return;
|
|
395
|
+
stopped = false;
|
|
371
396
|
// Kick off an immediate check so the front-end has fresh PR data on boot
|
|
372
397
|
// without waiting for the first 30s tick. Fire-and-forget; the recurring
|
|
373
398
|
// loop is scheduled independently and the `checking` guard prevents overlap.
|
|
@@ -376,6 +401,7 @@ export function startPrWatcher() {
|
|
|
376
401
|
}
|
|
377
402
|
/** Stop the PR watcher polling loop. */
|
|
378
403
|
export function stopPrWatcher() {
|
|
404
|
+
stopped = true;
|
|
379
405
|
if (timer) {
|
|
380
406
|
clearTimeout(timer);
|
|
381
407
|
timer = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { WORKTREES_PATH } from '../../shared/consts.js';
|
|
3
|
+
import { MASK_CHARACTER, MASKED_SECRET, SECRET_GLOBAL_KEYS, WORKTREES_PATH } from '../../shared/consts.js';
|
|
4
4
|
import { isValidProjectColor } from '../../shared/project-colors.js';
|
|
5
5
|
import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
|
|
6
6
|
import { DEFAULT_WHIP_SHORTCUT, isValidWhipShortcut } from '../../shared/whip-shortcut.js';
|
|
@@ -853,6 +853,36 @@ const settingsMigrations = [
|
|
|
853
853
|
}
|
|
854
854
|
},
|
|
855
855
|
},
|
|
856
|
+
{
|
|
857
|
+
version: 55,
|
|
858
|
+
name: 'add-lifecycle-hook-scripts',
|
|
859
|
+
migrate: ({ global, projects }) => {
|
|
860
|
+
// Seeded empty everywhere: a hook runs arbitrary shell on the user's
|
|
861
|
+
// machine, so it only ever exists because the user wrote it.
|
|
862
|
+
for (const key of ['sessionEndedScript', 'prMergedScript', 'autoLoopDisabledScript']) {
|
|
863
|
+
if (typeof global[key] !== 'string')
|
|
864
|
+
global[key] = '';
|
|
865
|
+
for (const p of projects) {
|
|
866
|
+
if (typeof p[key] !== 'string')
|
|
867
|
+
p[key] = '';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
version: 56,
|
|
874
|
+
name: 'add-awaiting-user-reminder',
|
|
875
|
+
migrate: ({ global }) => {
|
|
876
|
+
// Seeded off. Same reasoning as the retention window: a feature that
|
|
877
|
+
// spends the user's attention without being asked is not a default.
|
|
878
|
+
if (typeof global.awaitingUserReminderMinutes !== 'number' ||
|
|
879
|
+
!Number.isInteger(global.awaitingUserReminderMinutes) ||
|
|
880
|
+
global.awaitingUserReminderMinutes < 0 ||
|
|
881
|
+
global.awaitingUserReminderMinutes > 1440) {
|
|
882
|
+
global.awaitingUserReminderMinutes = 0;
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
},
|
|
856
886
|
];
|
|
857
887
|
/** Current settings schema version — always equals the highest migration version. */
|
|
858
888
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -875,6 +905,8 @@ function ensureSettingsPathInitialized() {
|
|
|
875
905
|
/** Override the settings file path (used by tests). */
|
|
876
906
|
export function _setSettingsPath(p) {
|
|
877
907
|
settingsFilePath = p;
|
|
908
|
+
// Pointing at a different file must not serve the previous one's contents.
|
|
909
|
+
invalidateSettingsCache();
|
|
878
910
|
}
|
|
879
911
|
function defaultSettings() {
|
|
880
912
|
return {
|
|
@@ -895,11 +927,16 @@ function defaultSettings() {
|
|
|
895
927
|
cleanupScriptOnlyOnChanges: false,
|
|
896
928
|
archiveScript: '',
|
|
897
929
|
changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
|
|
930
|
+
sessionEndedScript: '',
|
|
931
|
+
prMergedScript: '',
|
|
932
|
+
autoLoopDisabledScript: '',
|
|
898
933
|
editorCommand: '',
|
|
899
934
|
fileManagerCommand: '',
|
|
900
935
|
terminalCommand: '',
|
|
901
936
|
autoPurgeOnPrMerged: false,
|
|
902
937
|
autoLoopMaxRetries: 5,
|
|
938
|
+
awaitingUserReminderMinutes: 0,
|
|
939
|
+
maxConcurrentAgents: 0,
|
|
903
940
|
wsEventsRetentionDays: 0,
|
|
904
941
|
wsEventsKeepPerWorkspace: 0,
|
|
905
942
|
networkAccessEnabled: false,
|
|
@@ -998,6 +1035,9 @@ function defaultProjectSettings(projectPath) {
|
|
|
998
1035
|
cleanupScriptMode: '',
|
|
999
1036
|
archiveScript: '',
|
|
1000
1037
|
changeSourceBranchScript: '',
|
|
1038
|
+
sessionEndedScript: '',
|
|
1039
|
+
prMergedScript: '',
|
|
1040
|
+
autoLoopDisabledScript: '',
|
|
1001
1041
|
devServer: {
|
|
1002
1042
|
startCommand: '',
|
|
1003
1043
|
stopCommand: '',
|
|
@@ -1043,7 +1083,47 @@ export function runSettingsMigrations(raw) {
|
|
|
1043
1083
|
current.schemaVersion = version;
|
|
1044
1084
|
return current;
|
|
1045
1085
|
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Parsed settings, kept between calls. `getGlobalSettings` runs on every HTTP
|
|
1088
|
+
* request through the auth middleware and 50-odd other call sites, while
|
|
1089
|
+
* `readSettings` below does existsSync + readFileSync + JSON.parse + the whole
|
|
1090
|
+
* migration chain — all synchronous, on the event loop that also drives the
|
|
1091
|
+
* WebSocket streams.
|
|
1092
|
+
*
|
|
1093
|
+
* The file stays hand-editable (CONFIGURATION.md says so), so the cache is
|
|
1094
|
+
* keyed on the file's mtime and size rather than held blindly: an external edit
|
|
1095
|
+
* is picked up on the very next call, and the check costs one stat instead of a
|
|
1096
|
+
* full parse and migration pass.
|
|
1097
|
+
*/
|
|
1098
|
+
let settingsCache = null;
|
|
1099
|
+
/** Drop the cached settings, so the next read goes back to disk. */
|
|
1100
|
+
function invalidateSettingsCache() {
|
|
1101
|
+
settingsCache = null;
|
|
1102
|
+
}
|
|
1103
|
+
/** Identity of the settings file on disk, or null when it is unreadable. */
|
|
1104
|
+
function settingsFileStamp() {
|
|
1105
|
+
try {
|
|
1106
|
+
const stat = fs.statSync(settingsFilePath);
|
|
1107
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1046
1113
|
function readSettings() {
|
|
1114
|
+
ensureSettingsPathInitialized();
|
|
1115
|
+
const stamp = settingsFileStamp();
|
|
1116
|
+
if (stamp && settingsCache && settingsCache.mtimeMs === stamp.mtimeMs && settingsCache.size === stamp.size) {
|
|
1117
|
+
return settingsCache.value;
|
|
1118
|
+
}
|
|
1119
|
+
const settings = readSettingsFromDisk();
|
|
1120
|
+
// Re-stat: readSettingsFromDisk writes the file when it was missing or
|
|
1121
|
+
// corrupt, so the stamp taken above would be stale.
|
|
1122
|
+
const after = settingsFileStamp();
|
|
1123
|
+
settingsCache = after ? { value: settings, ...after } : null;
|
|
1124
|
+
return settings;
|
|
1125
|
+
}
|
|
1126
|
+
function readSettingsFromDisk() {
|
|
1047
1127
|
ensureSettingsPathInitialized();
|
|
1048
1128
|
if (!fs.existsSync(settingsFilePath)) {
|
|
1049
1129
|
const defaults = defaultSettings();
|
|
@@ -1124,6 +1204,8 @@ function createSettingsBackupIfPresent() {
|
|
|
1124
1204
|
pruneSettingsBackups(dir);
|
|
1125
1205
|
}
|
|
1126
1206
|
function writeSettings(settings, options) {
|
|
1207
|
+
// Our own writes must be visible at once, whatever the TTL says.
|
|
1208
|
+
invalidateSettingsCache();
|
|
1127
1209
|
ensureSettingsPathInitialized();
|
|
1128
1210
|
const tmpPath = `${settingsFilePath}.tmp`;
|
|
1129
1211
|
const dir = path.dirname(settingsFilePath);
|
|
@@ -1140,8 +1222,29 @@ function writeSettings(settings, options) {
|
|
|
1140
1222
|
export function getSettings() {
|
|
1141
1223
|
return readSettings();
|
|
1142
1224
|
}
|
|
1143
|
-
/**
|
|
1144
|
-
|
|
1225
|
+
/** True for a value made of nothing but mask characters (never a real token). */
|
|
1226
|
+
function isMaskOnly(value) {
|
|
1227
|
+
return typeof value === 'string' && value.length > 0 && [...value].every((c) => c === MASK_CHARACTER);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Copy of the global settings safe to hand to a client: every stored credential
|
|
1231
|
+
* becomes `MASKED_SECRET`, an unset one stays empty so the UI can tell "not
|
|
1232
|
+
* configured" from "configured, hidden". The caller's object is left alone.
|
|
1233
|
+
*
|
|
1234
|
+
* The credentials are of no use to the settings screen — it only ever shows
|
|
1235
|
+
* them in a password field — but they are very useful to anything else that
|
|
1236
|
+
* gets to read a response: a paired device on the LAN, a proxy log, a
|
|
1237
|
+
* screenshot. `updateGlobalSettings` reads the mask back as "keep the stored
|
|
1238
|
+
* value", so a round-trip through the form is lossless.
|
|
1239
|
+
*/
|
|
1240
|
+
export function redactGlobalSecrets(global) {
|
|
1241
|
+
const redacted = { ...global };
|
|
1242
|
+
for (const key of SECRET_GLOBAL_KEYS) {
|
|
1243
|
+
if (redacted[key])
|
|
1244
|
+
redacted[key] = MASKED_SECRET;
|
|
1245
|
+
}
|
|
1246
|
+
return redacted;
|
|
1247
|
+
}
|
|
1145
1248
|
/** Build an export bundle with settings + templates. MCP keys are stripped. */
|
|
1146
1249
|
export function exportConfigBundle(templates) {
|
|
1147
1250
|
const settings = readSettings();
|
|
@@ -1235,6 +1338,9 @@ export function getEffectiveSettings(projectPath) {
|
|
|
1235
1338
|
cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
|
|
1236
1339
|
archiveScript: settings.global.archiveScript,
|
|
1237
1340
|
changeSourceBranchScript: settings.global.changeSourceBranchScript,
|
|
1341
|
+
sessionEndedScript: settings.global.sessionEndedScript,
|
|
1342
|
+
prMergedScript: settings.global.prMergedScript,
|
|
1343
|
+
autoLoopDisabledScript: settings.global.autoLoopDisabledScript,
|
|
1238
1344
|
notionStatusProperty: settings.global.notionStatusProperty,
|
|
1239
1345
|
notionInProgressStatus: settings.global.notionInProgressStatus,
|
|
1240
1346
|
};
|
|
@@ -1262,13 +1368,32 @@ export function getEffectiveSettings(projectPath) {
|
|
|
1262
1368
|
cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
|
|
1263
1369
|
archiveScript: project.archiveScript || settings.global.archiveScript,
|
|
1264
1370
|
changeSourceBranchScript: project.changeSourceBranchScript || settings.global.changeSourceBranchScript,
|
|
1371
|
+
sessionEndedScript: project.sessionEndedScript || settings.global.sessionEndedScript,
|
|
1372
|
+
prMergedScript: project.prMergedScript || settings.global.prMergedScript,
|
|
1373
|
+
autoLoopDisabledScript: project.autoLoopDisabledScript || settings.global.autoLoopDisabledScript,
|
|
1265
1374
|
notionStatusProperty: settings.global.notionStatusProperty,
|
|
1266
1375
|
notionInProgressStatus: settings.global.notionInProgressStatus,
|
|
1267
1376
|
};
|
|
1268
1377
|
}
|
|
1269
1378
|
/** Merge partial updates into global settings and persist. */
|
|
1270
|
-
export function updateGlobalSettings(
|
|
1379
|
+
export function updateGlobalSettings(input) {
|
|
1271
1380
|
const settings = readSettings();
|
|
1381
|
+
// Work on a copy: the validation below drops keys, and mutating the caller's
|
|
1382
|
+
// object would be a surprise (the route hands us its parsed request body).
|
|
1383
|
+
const data = { ...input };
|
|
1384
|
+
// A client only ever sees the mask in place of a stored credential, so it
|
|
1385
|
+
// hands the mask back on every save. Dropping the key preserves the stored
|
|
1386
|
+
// value (same "drop it and keep the previous one" pattern as below), while a
|
|
1387
|
+
// real value still overwrites and an empty one still clears.
|
|
1388
|
+
//
|
|
1389
|
+
// Any run of mask characters counts, not just the exact sentinel: a stray
|
|
1390
|
+
// Backspace in the field leaves a shorter run, and writing that as the new
|
|
1391
|
+
// credential would break the integration silently, long after the fact. No
|
|
1392
|
+
// real token is made of nothing but bullets.
|
|
1393
|
+
for (const key of SECRET_GLOBAL_KEYS) {
|
|
1394
|
+
if (isMaskOnly(data[key]))
|
|
1395
|
+
delete data[key];
|
|
1396
|
+
}
|
|
1272
1397
|
// Validate skillSuite before merging: drop invalid values so the previous
|
|
1273
1398
|
// value is preserved (same pattern as `upsertProject`'s color validation).
|
|
1274
1399
|
if ('skillSuite' in data) {
|
|
@@ -1293,6 +1418,16 @@ export function updateGlobalSettings(data) {
|
|
|
1293
1418
|
delete data.autoLoopMaxRetries;
|
|
1294
1419
|
}
|
|
1295
1420
|
}
|
|
1421
|
+
// 0 is a legitimate value here — it means "never remind me" — so the floor is
|
|
1422
|
+
// 0, not 1. The ceiling of a day keeps a typo from arming a reminder that
|
|
1423
|
+
// never fires and looks like a broken feature.
|
|
1424
|
+
if ('awaitingUserReminderMinutes' in data) {
|
|
1425
|
+
const value = data.awaitingUserReminderMinutes;
|
|
1426
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 1440) {
|
|
1427
|
+
console.warn(`[settings] Invalid awaitingUserReminderMinutes value rejected: ${value}`);
|
|
1428
|
+
delete data.awaitingUserReminderMinutes;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1296
1431
|
// Same shape as the autoLoopMaxRetries guard above: an invalid value is
|
|
1297
1432
|
// dropped so the stored one survives. Retention deletes history — a bad write
|
|
1298
1433
|
// here would delete more than the user ever asked for. `0` is NOT invalid:
|
|
@@ -1323,11 +1458,16 @@ export function updateGlobalSettings(data) {
|
|
|
1323
1458
|
'cleanupScriptOnlyOnChanges',
|
|
1324
1459
|
'archiveScript',
|
|
1325
1460
|
'changeSourceBranchScript',
|
|
1461
|
+
'sessionEndedScript',
|
|
1462
|
+
'prMergedScript',
|
|
1463
|
+
'autoLoopDisabledScript',
|
|
1326
1464
|
'editorCommand',
|
|
1327
1465
|
'fileManagerCommand',
|
|
1328
1466
|
'terminalCommand',
|
|
1329
1467
|
'autoPurgeOnPrMerged',
|
|
1330
1468
|
'autoLoopMaxRetries',
|
|
1469
|
+
'awaitingUserReminderMinutes',
|
|
1470
|
+
'maxConcurrentAgents',
|
|
1331
1471
|
'wsEventsRetentionDays',
|
|
1332
1472
|
'wsEventsKeepPerWorkspace',
|
|
1333
1473
|
'browserNotifications',
|
|
@@ -1532,6 +1672,9 @@ export function upsertProject(projectPath, data) {
|
|
|
1532
1672
|
'cleanupScriptMode',
|
|
1533
1673
|
'archiveScript',
|
|
1534
1674
|
'changeSourceBranchScript',
|
|
1675
|
+
'sessionEndedScript',
|
|
1676
|
+
'prMergedScript',
|
|
1677
|
+
'autoLoopDisabledScript',
|
|
1535
1678
|
'devServer',
|
|
1536
1679
|
'e2e',
|
|
1537
1680
|
'finalization',
|