@oar-sh/oar 0.9.9 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -72,11 +72,24 @@ export function isDeniedName(fileName) {
|
|
|
72
72
|
return DENIED_NAME_RE.test(name);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// A path that is not there answers "no"; any other fs error (EACCES, EIO, too
|
|
76
|
+
// many open files, …) may pass, so a caller that would delete something over
|
|
77
|
+
// the answer can tell the two apart.
|
|
78
|
+
const ABSENT_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
79
|
+
|
|
80
|
+
function refusal(error, cause) {
|
|
81
|
+
const code = toText(cause?.code);
|
|
82
|
+
return code && !ABSENT_CODES.has(code)
|
|
83
|
+
? { ok: false, error: `${error} (${code})`, transient: true }
|
|
84
|
+
: { ok: false, error };
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/**
|
|
76
88
|
* Registration-time jail: resolve `dir` (absolute, or relative to the
|
|
77
89
|
* workspace root), realpath it, and require it to live inside the workspace
|
|
78
90
|
* root's realpath. Returns `{ ok, rootDir }` or `{ ok:false, error }` with a
|
|
79
|
-
* message that names exactly what was refused
|
|
91
|
+
* message that names exactly what was refused; `transient: true` marks a
|
|
92
|
+
* refusal over an fs error other than a missing path.
|
|
80
93
|
*/
|
|
81
94
|
export function validateStaticRoot(dir, { workspaceRoot, fsImpl = fs, pathImpl = path } = {}) {
|
|
82
95
|
const dirInput = toText(dir);
|
|
@@ -89,22 +102,22 @@ export function validateStaticRoot(dir, { workspaceRoot, fsImpl = fs, pathImpl =
|
|
|
89
102
|
let workspaceReal;
|
|
90
103
|
try {
|
|
91
104
|
workspaceReal = fsImpl.realpathSync(pathImpl.resolve(workspaceInput));
|
|
92
|
-
} catch {
|
|
93
|
-
return
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return refusal(`Workspace root does not exist: ${workspaceInput}`, error);
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
const resolved = pathImpl.resolve(workspaceReal, dirInput);
|
|
97
110
|
let rootReal;
|
|
98
111
|
try {
|
|
99
112
|
rootReal = fsImpl.realpathSync(resolved);
|
|
100
|
-
} catch {
|
|
101
|
-
return
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return refusal(`Directory not found: ${dirInput}`, error);
|
|
102
115
|
}
|
|
103
116
|
let stats;
|
|
104
117
|
try {
|
|
105
118
|
stats = fsImpl.statSync(rootReal);
|
|
106
|
-
} catch {
|
|
107
|
-
return
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return refusal(`Directory not found: ${dirInput}`, error);
|
|
108
121
|
}
|
|
109
122
|
if (!stats.isDirectory()) {
|
|
110
123
|
return { ok: false, error: `Not a directory: ${dirInput}` };
|
|
@@ -115,6 +128,26 @@ export function validateStaticRoot(dir, { workspaceRoot, fsImpl = fs, pathImpl =
|
|
|
115
128
|
return { ok: true, rootDir: rootReal };
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
/**
|
|
132
|
+
* validateStaticRoot against several workspace roots: the first that accepts
|
|
133
|
+
* wins. A transient refusal from any root is returned as such, so the caller
|
|
134
|
+
* keeps what it would otherwise drop; else the first root's refusal.
|
|
135
|
+
*/
|
|
136
|
+
export function validateStaticRootInAny(dir, workspaceRoots = [], options = {}) {
|
|
137
|
+
const roots = [...new Set((Array.isArray(workspaceRoots) ? workspaceRoots : [workspaceRoots])
|
|
138
|
+
.map(toText).filter(Boolean))];
|
|
139
|
+
if (roots.length === 0) return validateStaticRoot(dir, { ...options, workspaceRoot: '' });
|
|
140
|
+
let first = null;
|
|
141
|
+
let transient = null;
|
|
142
|
+
for (const workspaceRoot of roots) {
|
|
143
|
+
const checked = validateStaticRoot(dir, { ...options, workspaceRoot });
|
|
144
|
+
if (checked.ok) return checked;
|
|
145
|
+
if (checked.transient && !transient) transient = checked;
|
|
146
|
+
if (!first) first = checked;
|
|
147
|
+
}
|
|
148
|
+
return transient || first;
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
/**
|
|
119
152
|
* Request-time resolution inside a registered root. Returns one of:
|
|
120
153
|
* { kind:'file', filePath, contentType, size }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import childProcess from 'child_process';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
import { previewsTableProblem } from '../migrations/0013-previews.mjs';
|
|
8
|
+
|
|
9
|
+
// Persistence for the preview registry: the `previews` table (migration 0013)
|
|
10
|
+
// behind a small store, so the registry can restore its links after a
|
|
11
|
+
// restart and stays testable with a plain in-memory store.
|
|
12
|
+
//
|
|
13
|
+
// Also the host boot id, which decides whether a port preview may come back:
|
|
14
|
+
// only within the boot it was registered in.
|
|
15
|
+
//
|
|
16
|
+
// Both are exported for a one-off carry-over script as well: open the relay's
|
|
17
|
+
// database, `createSqlitePreviewStore(db).insert({ ...preview, hostBootId:
|
|
18
|
+
// readHostBootId() })` for every preview from `GET /api/previews`, and the next
|
|
19
|
+
// runtime restores them.
|
|
20
|
+
|
|
21
|
+
const PREVIEW_MODES = new Set(['port', 'static']);
|
|
22
|
+
const TOKEN_RE = /^[0-9a-f]{32}$/;
|
|
23
|
+
|
|
24
|
+
function toText(value) {
|
|
25
|
+
return String(value ?? '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toEpochMs(value) {
|
|
29
|
+
if (value === null || value === undefined || value === '') return null;
|
|
30
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
31
|
+
if (Number.isFinite(num)) return Math.trunc(num);
|
|
32
|
+
const parsed = Date.parse(String(value));
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toPort(value) {
|
|
37
|
+
const num = Number(value);
|
|
38
|
+
return Number.isInteger(num) && num > 0 && num <= 65535 ? num : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A registry record (the shape `GET /api/previews` lists, plus `hostBootId`)
|
|
43
|
+
* as a row. Returns null for a record no restore could use, a token not in
|
|
44
|
+
* its canonical form (32 lower-case hex) included.
|
|
45
|
+
*/
|
|
46
|
+
export function previewRecordToRow(record = {}) {
|
|
47
|
+
const token = typeof record.token === 'string' ? record.token : '';
|
|
48
|
+
const mode = toText(record.mode);
|
|
49
|
+
if (!TOKEN_RE.test(token) || !PREVIEW_MODES.has(mode)) return null;
|
|
50
|
+
const createdAt = toEpochMs(record.createdAt);
|
|
51
|
+
return {
|
|
52
|
+
token,
|
|
53
|
+
mode,
|
|
54
|
+
conversation_id: toText(record.conversationId) || null,
|
|
55
|
+
label: toText(record.label).slice(0, 120) || (mode === 'static' ? 'static preview' : 'preview'),
|
|
56
|
+
target_host: mode === 'port' ? (toText(record.targetHost) || null) : null,
|
|
57
|
+
target_port: mode === 'port' ? toPort(record.targetPort) : null,
|
|
58
|
+
root_dir: mode === 'static' ? (toText(record.rootDir) || null) : null,
|
|
59
|
+
created_at: createdAt ?? Date.now(),
|
|
60
|
+
last_hit_at: toEpochMs(record.lastHitAt),
|
|
61
|
+
host_boot_id: toText(record.hostBootId) || null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The token as stored, not normalised: a row the relay did not write itself
|
|
66
|
+
// may hold one in another form, and only the stored value deletes it.
|
|
67
|
+
export function previewRowToRecord(row = {}) {
|
|
68
|
+
return {
|
|
69
|
+
token: String(row.token ?? ''),
|
|
70
|
+
mode: toText(row.mode),
|
|
71
|
+
conversationId: toText(row.conversation_id) || null,
|
|
72
|
+
label: toText(row.label),
|
|
73
|
+
targetHost: toText(row.target_host) || null,
|
|
74
|
+
targetPort: toPort(row.target_port),
|
|
75
|
+
rootDir: toText(row.root_dir) || null,
|
|
76
|
+
createdAt: toEpochMs(row.created_at),
|
|
77
|
+
lastHitAt: toEpochMs(row.last_hit_at),
|
|
78
|
+
hostBootId: toText(row.host_boot_id) || null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unavailableStore(reason) {
|
|
83
|
+
return {
|
|
84
|
+
available: false,
|
|
85
|
+
reason,
|
|
86
|
+
list: () => [],
|
|
87
|
+
insert: () => false,
|
|
88
|
+
remove: () => false,
|
|
89
|
+
removeForConversation: () => 0,
|
|
90
|
+
touch: () => false,
|
|
91
|
+
withBusyTimeout: (_ms, run) => run(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The store on the relay's database. Without a usable table (migration 0013
|
|
97
|
+
* failed and is retried next boot, or a `previews` table of other columns)
|
|
98
|
+
* every call is a no-op and the registry keeps its previews in memory only,
|
|
99
|
+
* as before. A table that is there but unusable is logged once; it never
|
|
100
|
+
* stops the relay.
|
|
101
|
+
*/
|
|
102
|
+
export function createSqlitePreviewStore(db, { logger = console } = {}) {
|
|
103
|
+
let hasTable = false;
|
|
104
|
+
try {
|
|
105
|
+
hasTable = !!db
|
|
106
|
+
?.prepare?.(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'previews'`)
|
|
107
|
+
.get();
|
|
108
|
+
} catch {}
|
|
109
|
+
if (!hasTable) return unavailableStore('no previews table');
|
|
110
|
+
let stmts;
|
|
111
|
+
try {
|
|
112
|
+
const problem = previewsTableProblem(db);
|
|
113
|
+
if (problem) throw new Error(problem);
|
|
114
|
+
stmts = createStatements(db);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const reason = error?.message || String(error);
|
|
117
|
+
logger?.warn?.(`[preview] previews are not persisted: ${reason}`);
|
|
118
|
+
return unavailableStore(reason);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
available: true,
|
|
122
|
+
list: () => stmts.list.all().map(previewRowToRecord),
|
|
123
|
+
insert(record) {
|
|
124
|
+
const row = previewRecordToRow(record);
|
|
125
|
+
if (!row) return false;
|
|
126
|
+
stmts.insert.run(row);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
remove: (token) => stmts.remove.run(String(token ?? '')).changes > 0,
|
|
130
|
+
// `exceptTokens`: rows to leave, for a delete that is carried out late and
|
|
131
|
+
// must not take a preview registered since.
|
|
132
|
+
removeForConversation(conversationId, { exceptTokens = [] } = {}) {
|
|
133
|
+
if (exceptTokens.length === 0) return stmts.removeForConversation.run(toText(conversationId)).changes;
|
|
134
|
+
return stmts.removeForConversationExcept
|
|
135
|
+
.run(toText(conversationId), JSON.stringify(exceptTokens.map(String))).changes;
|
|
136
|
+
},
|
|
137
|
+
touch: (token, at) => stmts.touch.run(toEpochMs(at), toText(token)).changes > 0,
|
|
138
|
+
// Runs `run` with another wait on a locked database, then puts the
|
|
139
|
+
// connection's own back.
|
|
140
|
+
withBusyTimeout(ms, run) {
|
|
141
|
+
let previous = null;
|
|
142
|
+
try {
|
|
143
|
+
previous = Number(db.pragma('busy_timeout', { simple: true }));
|
|
144
|
+
db.pragma(`busy_timeout = ${Math.max(0, Math.trunc(Number(ms) || 0))}`);
|
|
145
|
+
} catch {}
|
|
146
|
+
try {
|
|
147
|
+
return run();
|
|
148
|
+
} finally {
|
|
149
|
+
if (Number.isFinite(previous)) {
|
|
150
|
+
try { db.pragma(`busy_timeout = ${previous}`); } catch {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createStatements(db) {
|
|
158
|
+
return {
|
|
159
|
+
list: db.prepare(`SELECT * FROM previews ORDER BY created_at ASC, token ASC`),
|
|
160
|
+
insert: db.prepare(`
|
|
161
|
+
INSERT OR REPLACE INTO previews (
|
|
162
|
+
token, mode, conversation_id, label, target_host, target_port, root_dir,
|
|
163
|
+
created_at, last_hit_at, host_boot_id
|
|
164
|
+
) VALUES (
|
|
165
|
+
@token, @mode, @conversation_id, @label, @target_host, @target_port, @root_dir,
|
|
166
|
+
@created_at, @last_hit_at, @host_boot_id
|
|
167
|
+
)
|
|
168
|
+
`),
|
|
169
|
+
remove: db.prepare(`DELETE FROM previews WHERE token = ?`),
|
|
170
|
+
removeForConversation: db.prepare(`DELETE FROM previews WHERE conversation_id = ?`),
|
|
171
|
+
removeForConversationExcept: db.prepare(`
|
|
172
|
+
DELETE FROM previews
|
|
173
|
+
WHERE conversation_id = ? AND token NOT IN (SELECT value FROM json_each(?))
|
|
174
|
+
`),
|
|
175
|
+
touch: db.prepare(`UPDATE previews SET last_hit_at = ? WHERE token = ?`),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Same calls, kept in a Map: for tests and for a relay without a database. */
|
|
180
|
+
export function createMemoryPreviewStore(initialRecords = []) {
|
|
181
|
+
const rows = new Map();
|
|
182
|
+
const store = {
|
|
183
|
+
available: true,
|
|
184
|
+
rows,
|
|
185
|
+
list: () => [...rows.values()]
|
|
186
|
+
.sort((a, b) => (a.created_at - b.created_at) || a.token.localeCompare(b.token))
|
|
187
|
+
.map(previewRowToRecord),
|
|
188
|
+
insert(record) {
|
|
189
|
+
const row = previewRecordToRow(record);
|
|
190
|
+
if (!row) return false;
|
|
191
|
+
rows.set(row.token, row);
|
|
192
|
+
return true;
|
|
193
|
+
},
|
|
194
|
+
remove: (token) => rows.delete(String(token ?? '')),
|
|
195
|
+
removeForConversation(conversationId, { exceptTokens = [] } = {}) {
|
|
196
|
+
const id = toText(conversationId);
|
|
197
|
+
let removed = 0;
|
|
198
|
+
for (const [token, row] of rows) {
|
|
199
|
+
if (row.conversation_id !== id || exceptTokens.includes(token)) continue;
|
|
200
|
+
rows.delete(token);
|
|
201
|
+
removed += 1;
|
|
202
|
+
}
|
|
203
|
+
return removed;
|
|
204
|
+
},
|
|
205
|
+
touch(token, at) {
|
|
206
|
+
const row = rows.get(toText(token));
|
|
207
|
+
if (!row) return false;
|
|
208
|
+
row.last_hit_at = toEpochMs(at);
|
|
209
|
+
return true;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
for (const record of initialRecords) store.insert(record);
|
|
213
|
+
return store;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Host boot id ────────────────────────────────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// An opaque string that changes when the host boots again:
|
|
219
|
+
// - Linux: the kernel's random boot id (/proc/sys/kernel/random/boot_id).
|
|
220
|
+
// - macOS: `sysctl -n kern.bootsessionuuid`, a UUID minted per boot.
|
|
221
|
+
// - Anything else (Windows) or a failed read: the boot time, derived from the
|
|
222
|
+
// uptime, as `boot-time:<epoch seconds>`. That form is compared with a
|
|
223
|
+
// tolerance, because it moves a little with clock adjustments. On Windows
|
|
224
|
+
// "fast startup" a shutdown hibernates the kernel and keeps the uptime, so
|
|
225
|
+
// such a shutdown and power-on counts as the same boot there.
|
|
226
|
+
// An empty id never matches anything, so port previews are then not restored.
|
|
227
|
+
|
|
228
|
+
const BOOT_TIME_PREFIX = 'boot-time:';
|
|
229
|
+
const BOOT_TIME_TOLERANCE_S = 120;
|
|
230
|
+
|
|
231
|
+
export function readHostBootId({
|
|
232
|
+
platform = process.platform,
|
|
233
|
+
readFileSync = fs.readFileSync,
|
|
234
|
+
execFileSync = childProcess.execFileSync,
|
|
235
|
+
uptime = os.uptime,
|
|
236
|
+
now = Date.now,
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (platform === 'linux') {
|
|
239
|
+
try {
|
|
240
|
+
const id = toText(readFileSync('/proc/sys/kernel/random/boot_id', 'utf8'));
|
|
241
|
+
if (id) return `linux:${id}`;
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
if (platform === 'darwin') {
|
|
245
|
+
try {
|
|
246
|
+
const id = toText(execFileSync('sysctl', ['-n', 'kern.bootsessionuuid'], {
|
|
247
|
+
encoding: 'utf8',
|
|
248
|
+
timeout: 2000,
|
|
249
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
250
|
+
}));
|
|
251
|
+
if (id) return `darwin:${id}`;
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const seconds = Number(uptime());
|
|
256
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
257
|
+
return `${BOOT_TIME_PREFIX}${Math.round(now() / 1000 - seconds)}`;
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
return '';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function isSameHostBoot(storedId, currentId) {
|
|
264
|
+
const stored = toText(storedId);
|
|
265
|
+
const current = toText(currentId);
|
|
266
|
+
if (!stored || !current) return false;
|
|
267
|
+
if (stored === current) return true;
|
|
268
|
+
if (stored.startsWith(BOOT_TIME_PREFIX) && current.startsWith(BOOT_TIME_PREFIX)) {
|
|
269
|
+
const a = Number(stored.slice(BOOT_TIME_PREFIX.length));
|
|
270
|
+
const b = Number(current.slice(BOOT_TIME_PREFIX.length));
|
|
271
|
+
return Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= BOOT_TIME_TOLERANCE_S;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
@@ -324,6 +324,22 @@ export function createPushDispatchService({
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// A turn that answers relay events (its message is the relay's event row,
|
|
328
|
+
// kind 'relay-event') pushes no "reply ready" or "failed" either: the
|
|
329
|
+
// watcher answers events many times an hour and pushes by itself when one
|
|
330
|
+
// matters. Question pushes are unchanged.
|
|
331
|
+
let messageKindStatement = null;
|
|
332
|
+
function turnAnswersRelayEvents(sourceMessageId) {
|
|
333
|
+
const id = String(sourceMessageId || '').trim();
|
|
334
|
+
if (!id) return false;
|
|
335
|
+
try {
|
|
336
|
+
messageKindStatement ??= db.prepare(`SELECT kind FROM messages WHERE id = ?`);
|
|
337
|
+
return String(messageKindStatement.get(id)?.kind || '').trim() === 'relay-event';
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
327
343
|
async function sendToSubscription(row, event) {
|
|
328
344
|
const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
|
|
329
345
|
const payload = renderPushNotification(event, preferences);
|
|
@@ -440,6 +456,7 @@ export function createPushDispatchService({
|
|
|
440
456
|
// the turn.
|
|
441
457
|
function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
442
458
|
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
459
|
+
if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
|
|
443
460
|
return dispatch({
|
|
444
461
|
type: 'turnComplete',
|
|
445
462
|
conversationId: conversationId || null,
|
|
@@ -450,6 +467,7 @@ export function createPushDispatchService({
|
|
|
450
467
|
|
|
451
468
|
function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
452
469
|
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
470
|
+
if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
|
|
453
471
|
return dispatch({
|
|
454
472
|
type: 'turnFailed',
|
|
455
473
|
conversationId: conversationId || null,
|
|
@@ -59,5 +59,16 @@ export function createFixedWindowRateLimiter({
|
|
|
59
59
|
return { ok: true, retryAfterSeconds: 0 };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// Whether consume() would refuse now, without counting a hit.
|
|
63
|
+
function peek(key, reference = nowMs()) {
|
|
64
|
+
const bucket = buckets.get(String(key || '').trim());
|
|
65
|
+
if (!bucket || !Number.isFinite(bucket.windowStartAt) || (reference - bucket.windowStartAt) >= windowMs) {
|
|
66
|
+
return { ok: true, retryAfterSeconds: 0 };
|
|
67
|
+
}
|
|
68
|
+
if (bucket.count < limit) return { ok: true, retryAfterSeconds: 0 };
|
|
69
|
+
const retryAfterMs = Math.max(250, windowMs - (reference - bucket.windowStartAt));
|
|
70
|
+
return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { consume, peek, prune, size: () => buckets.size };
|
|
63
74
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { createFixedWindowRateLimiter } from './rate-limit-service.mjs';
|
|
6
|
+
import { createLocalRequestCheck, expressRequestView } from './request-boundary.mjs';
|
|
7
|
+
|
|
8
|
+
// The relay's token check for the API (`auth`) and the Socket.IO handshake.
|
|
9
|
+
//
|
|
10
|
+
// The token is read from the Authorization header or the HttpOnly cookie,
|
|
11
|
+
// nothing else. A `?token=` in the page address is the app's sign-in link:
|
|
12
|
+
// the page (bootstrap.js) takes it out of the address and exchanges it once
|
|
13
|
+
// for the cookie with a Bearer request; no API route accepts it, so the token
|
|
14
|
+
// does not sit in API or asset URLs, logs and the browser history.
|
|
15
|
+
//
|
|
16
|
+
// Wrong tokens are counted per client address (Express `req.ip`, which
|
|
17
|
+
// follows `trust proxy`): after AUTH_FAILURE_LIMIT failures in one window,
|
|
18
|
+
// every request from that address is answered 429 with Retry-After until the
|
|
19
|
+
// window ends, the right token included, so guessing does not go on behind
|
|
20
|
+
// the 429s. Truly local requests (request-boundary.mjs) are never counted or
|
|
21
|
+
// refused: the relay's own workers and agent tools must not be locked out.
|
|
22
|
+
|
|
23
|
+
export const AUTH_FAILURE_LIMIT = 10;
|
|
24
|
+
export const AUTH_FAILURE_WINDOW_MS = 60_000;
|
|
25
|
+
const AUTH_FAILURE_MAX_CLIENTS = 4_096;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time comparison so the token cannot be recovered through response
|
|
29
|
+
* timing. The token length is fixed (a UUID), so the length short-circuit
|
|
30
|
+
* leaks nothing secret; any empty or missing value is a non-match.
|
|
31
|
+
*/
|
|
32
|
+
export function tokensMatch(presented, expected) {
|
|
33
|
+
const a = Buffer.from(String(presented || ''), 'utf8');
|
|
34
|
+
const b = Buffer.from(String(expected || ''), 'utf8');
|
|
35
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length) return false;
|
|
36
|
+
return crypto.timingSafeEqual(a, b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeClientKey(value) {
|
|
40
|
+
const text = String(value || '').trim();
|
|
41
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(text);
|
|
42
|
+
return (mapped ? mapped[1] : text).slice(0, 128);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fixed-window count of wrong tokens per client. A browser tab that still
|
|
47
|
+
* holds an old cookie repeats the same wrong value on every request; one
|
|
48
|
+
* value counts once per window, so only distinct guesses add up.
|
|
49
|
+
*/
|
|
50
|
+
export function createAuthFailureLockout({
|
|
51
|
+
limit = AUTH_FAILURE_LIMIT,
|
|
52
|
+
windowMs = AUTH_FAILURE_WINDOW_MS,
|
|
53
|
+
maxClients = AUTH_FAILURE_MAX_CLIENTS,
|
|
54
|
+
nowMs = Date.now,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const failures = createFixedWindowRateLimiter({ windowMs, limit, bucketTtlMs: windowMs, maxBuckets: maxClients, nowMs });
|
|
57
|
+
// Evicting a fingerprint early only makes that value count again.
|
|
58
|
+
const counted = createFixedWindowRateLimiter({ windowMs, limit: 1, bucketTtlMs: windowMs, maxBuckets: maxClients, nowMs });
|
|
59
|
+
|
|
60
|
+
function check(clientKey) {
|
|
61
|
+
const key = normalizeClientKey(clientKey);
|
|
62
|
+
if (!key) return { ok: true, retryAfterSeconds: 0 };
|
|
63
|
+
return failures.peek(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function recordFailure(clientKey, presentedToken) {
|
|
67
|
+
const key = normalizeClientKey(clientKey);
|
|
68
|
+
if (!key) return { ok: true, retryAfterSeconds: 0 };
|
|
69
|
+
const fingerprint = crypto.createHash('sha256').update(String(presentedToken || '')).digest('hex').slice(0, 24);
|
|
70
|
+
if (counted.consume(`${key}\n${fingerprint}`).ok) failures.consume(key);
|
|
71
|
+
return failures.peek(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { check, recordFailure, size: () => failures.size() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createRelayAuth({
|
|
78
|
+
app,
|
|
79
|
+
getAuthToken,
|
|
80
|
+
cookieName,
|
|
81
|
+
cookiePath = '/',
|
|
82
|
+
parseCookies,
|
|
83
|
+
appendSetCookie,
|
|
84
|
+
localRequestCheck = createLocalRequestCheck(),
|
|
85
|
+
lockout = createAuthFailureLockout(),
|
|
86
|
+
logger = console,
|
|
87
|
+
} = {}) {
|
|
88
|
+
function bearerToken(headers) {
|
|
89
|
+
return String(headers?.authorization || '').replace(/^Bearer\s+/i, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function lockedPayload(retryAfterSeconds) {
|
|
93
|
+
return {
|
|
94
|
+
error: `Too many failed sign-in attempts. Try again in ${retryAfterSeconds} s.`,
|
|
95
|
+
retryAfterSeconds,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function noteLocked(clientKey, state, via) {
|
|
100
|
+
logger?.warn?.(`[auth] ${via} from ${clientKey} refused: too many wrong tokens (retry after ${state.retryAfterSeconds} s)`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The lockout key of a request; null for a truly local one (exempt).
|
|
104
|
+
function lockoutKeyOf(req) {
|
|
105
|
+
if (localRequestCheck.isTrulyLocal(req)) return null;
|
|
106
|
+
return normalizeClientKey(req?.ip || req?.socket?.remoteAddress) || 'unknown';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function auth(req, res, next) {
|
|
110
|
+
const lockoutKey = lockoutKeyOf(req);
|
|
111
|
+
if (lockoutKey) {
|
|
112
|
+
const state = lockout.check(lockoutKey);
|
|
113
|
+
if (!state.ok) {
|
|
114
|
+
res.setHeader('Retry-After', String(state.retryAfterSeconds));
|
|
115
|
+
res.status(429).json(lockedPayload(state.retryAfterSeconds));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const expected = getAuthToken();
|
|
120
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
121
|
+
// req.secure is derived through the configured `trust proxy` boundary, so a
|
|
122
|
+
// direct (untrusted) client cannot force a non-Secure cookie by omitting
|
|
123
|
+
// X-Forwarded-Proto.
|
|
124
|
+
const secureAttr = req.secure ? '; Secure' : '';
|
|
125
|
+
// The token is intentionally NOT read from req.body (an easy CSRF/logging
|
|
126
|
+
// vector) nor from the query string (see the top of this file).
|
|
127
|
+
const token = bearerToken(req.headers) || cookies[cookieName];
|
|
128
|
+
if (tokensMatch(token, expected)) {
|
|
129
|
+
if (res && !tokensMatch(cookies[cookieName], expected)) {
|
|
130
|
+
appendSetCookie(res, `${cookieName}=${encodeURIComponent(expected)}; Path=${cookiePath}; Max-Age=2592000; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
131
|
+
}
|
|
132
|
+
next();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (lockoutKey && token) {
|
|
136
|
+
const state = lockout.recordFailure(lockoutKey, token);
|
|
137
|
+
if (!state.ok) noteLocked(lockoutKey, state, `${req.method || 'request'} ${req.path || req.url || ''}`.trim());
|
|
138
|
+
}
|
|
139
|
+
if (res && cookies[cookieName]) {
|
|
140
|
+
appendSetCookie(res, `${cookieName}=; Path=${cookiePath}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
141
|
+
}
|
|
142
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The token a Socket.IO handshake presents: `auth.token`, else the cookie. */
|
|
146
|
+
function socketAuthToken(socket) {
|
|
147
|
+
const cookies = parseCookies(socket?.request?.headers?.cookie);
|
|
148
|
+
return socket?.handshake?.auth?.token || cookies[cookieName];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function socketTokenValid(socket) {
|
|
152
|
+
return tokensMatch(socketAuthToken(socket), getAuthToken());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// io.use() middleware.
|
|
156
|
+
function socketMiddleware(socket, next) {
|
|
157
|
+
const rawReq = socket?.request;
|
|
158
|
+
const lockoutKey = localRequestCheck.isTrulyLocal(rawReq)
|
|
159
|
+
? null
|
|
160
|
+
: normalizeClientKey(expressRequestView(app, rawReq).ip || rawReq?.socket?.remoteAddress) || 'unknown';
|
|
161
|
+
if (lockoutKey) {
|
|
162
|
+
const state = lockout.check(lockoutKey);
|
|
163
|
+
if (!state.ok) {
|
|
164
|
+
const error = new Error('Too many failed sign-in attempts');
|
|
165
|
+
error.data = { retryAfterSeconds: state.retryAfterSeconds };
|
|
166
|
+
next(error);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (socketTokenValid(socket)) {
|
|
171
|
+
next();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const token = socketAuthToken(socket);
|
|
175
|
+
if (lockoutKey && token) {
|
|
176
|
+
const state = lockout.recordFailure(lockoutKey, token);
|
|
177
|
+
if (!state.ok) noteLocked(lockoutKey, state, 'socket handshake');
|
|
178
|
+
}
|
|
179
|
+
next(new Error('Unauthorized'));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { auth, socketMiddleware, socketAuthToken, socketTokenValid, lockout };
|
|
183
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { execFileSync, spawn } from 'child_process';
|
|
4
4
|
import { createSessionWorkerProcessInspector } from './session-worker-process-service.mjs';
|
|
5
5
|
import { launchSessionCli } from './session-worker-launch-service.mjs';
|
|
6
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
6
7
|
|
|
7
8
|
function sleep(ms) {
|
|
8
9
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -79,7 +80,7 @@ export function createRelayCliLauncherService({
|
|
|
79
80
|
' try { Stop-Process -Id $id -Force -ErrorAction SilentlyContinue } catch {}',
|
|
80
81
|
'}',
|
|
81
82
|
].join('; ');
|
|
82
|
-
execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', script], {
|
|
83
|
+
execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', script], {
|
|
83
84
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
84
85
|
});
|
|
85
86
|
return ids;
|
|
@@ -117,6 +118,7 @@ export function createRelayCliLauncherService({
|
|
|
117
118
|
spawnImpl,
|
|
118
119
|
execFileSyncImpl,
|
|
119
120
|
processInspector,
|
|
121
|
+
log,
|
|
120
122
|
});
|
|
121
123
|
return launched;
|
|
122
124
|
}
|