@lightupai/polaris 0.0.76 → 0.0.78
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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import { readFile, appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
@@ -22,14 +22,15 @@ function generateSessionName(): string {
|
|
|
22
22
|
|
|
23
23
|
const sessions = new Map<string, SessionMapping>(); // keyed by ccSessionId
|
|
24
24
|
|
|
25
|
-
//
|
|
26
|
-
const
|
|
25
|
+
// Advisor injects queued for delivery via the UserPromptSubmit hook
|
|
26
|
+
const injectQueues = new Map<string, Array<{ from: string; content: string; timestamp: string }>>(); // keyed by ccSessionId
|
|
27
27
|
|
|
28
28
|
// --- Config resolution (env var > config.json > legacy credentials.json > defaults) ---
|
|
29
29
|
|
|
30
30
|
interface PolarisConfig {
|
|
31
31
|
active: string;
|
|
32
32
|
profiles: Record<string, { api: string; token: string; [key: string]: unknown }>;
|
|
33
|
+
daemonSecret?: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
let cachedConfig: PolarisConfig | null | undefined = undefined;
|
|
@@ -88,6 +89,25 @@ async function authHeaders(): Promise<Record<string, string>> {
|
|
|
88
89
|
return { "Content-Type": "application/json" };
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// --- Daemon shared-secret auth ---
|
|
93
|
+
//
|
|
94
|
+
// When a secret is resolved (env POLARIS_DAEMON_SECRET, else config.json
|
|
95
|
+
// `daemonSecret`), ALL daemon HTTP endpoints require the header
|
|
96
|
+
// `x-polaris-daemon-secret: <secret>` and reply 401 otherwise. When no
|
|
97
|
+
// secret is resolved, no auth is enforced (back-compat / tests).
|
|
98
|
+
// Note: config.json is only consulted via the startup cache (loaded in the
|
|
99
|
+
// import.meta.main block). Tests that call startDaemon() in-process never
|
|
100
|
+
// load the developer's real config, so they always run unauthenticated.
|
|
101
|
+
function getDaemonSecret(): string | null {
|
|
102
|
+
// Env var wins; an explicitly empty value means "no auth" (matches the
|
|
103
|
+
// POLARIS_AUTH_TOKEN convention above).
|
|
104
|
+
if (process.env.POLARIS_DAEMON_SECRET !== undefined) {
|
|
105
|
+
return process.env.POLARIS_DAEMON_SECRET || null;
|
|
106
|
+
}
|
|
107
|
+
const secret = cachedConfig?.daemonSecret;
|
|
108
|
+
return typeof secret === "string" && secret ? secret : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
// --- Cloud WebSocket management ---
|
|
92
112
|
|
|
93
113
|
function connectCloudWs(mapping: SessionMapping) {
|
|
@@ -98,10 +118,18 @@ function connectCloudWs(mapping: SessionMapping) {
|
|
|
98
118
|
ws.onmessage = (event) => {
|
|
99
119
|
try {
|
|
100
120
|
const data = JSON.parse(event.data as string);
|
|
101
|
-
//
|
|
121
|
+
// Queue inject events for delivery via the UserPromptSubmit hook
|
|
102
122
|
if (data.source === "inject") {
|
|
103
|
-
|
|
104
|
-
if (
|
|
123
|
+
let queue = injectQueues.get(mapping.ccSessionId);
|
|
124
|
+
if (!queue) {
|
|
125
|
+
queue = [];
|
|
126
|
+
injectQueues.set(mapping.ccSessionId, queue);
|
|
127
|
+
}
|
|
128
|
+
queue.push({
|
|
129
|
+
from: data.sender,
|
|
130
|
+
content: data.payload?.content ?? "",
|
|
131
|
+
timestamp: data.timestamp,
|
|
132
|
+
});
|
|
105
133
|
}
|
|
106
134
|
} catch {
|
|
107
135
|
// Ignore malformed messages
|
|
@@ -154,6 +182,101 @@ async function logEvent(endpoint: string, payload: unknown, response?: { status:
|
|
|
154
182
|
} catch { /* best-effort — don't break the request */ }
|
|
155
183
|
}
|
|
156
184
|
|
|
185
|
+
// --- Write-ahead outbox ---
|
|
186
|
+
//
|
|
187
|
+
// When an upstream relay fails (network error or 5xx), the event is persisted
|
|
188
|
+
// to ~/.polaris/outbox/ instead of being dropped, and a background retry loop
|
|
189
|
+
// re-POSTs it with exponential backoff (1s doubling, capped at 30s). Files are
|
|
190
|
+
// removed on success — and on a permanent upstream 4xx, which no retry can
|
|
191
|
+
// fix. Re-sends are idempotent upstream (events have stable ids), so a
|
|
192
|
+
// duplicate POST after a lost response is harmless. The JSONL log above is
|
|
193
|
+
// unchanged and still records everything.
|
|
194
|
+
|
|
195
|
+
const OUTBOX_DIR = join(homedir(), ".polaris", "outbox");
|
|
196
|
+
|
|
197
|
+
interface OutboxEntry {
|
|
198
|
+
t: string;
|
|
199
|
+
project: string;
|
|
200
|
+
session: string;
|
|
201
|
+
body: unknown; // the exact { sender, payload } body for the events endpoint
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const pendingOutbox = new Set<string>(); // absolute file paths awaiting retry
|
|
205
|
+
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
|
|
206
|
+
let outboxDelayMs = 1000;
|
|
207
|
+
const OUTBOX_MAX_DELAY_MS = 30_000;
|
|
208
|
+
let outboxReady: Promise<void> | null = null;
|
|
209
|
+
|
|
210
|
+
function ensureOutboxDir(): Promise<void> {
|
|
211
|
+
if (!outboxReady) outboxReady = mkdir(OUTBOX_DIR, { recursive: true }).then(() => {});
|
|
212
|
+
return outboxReady;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function enqueueOutbox(project: string, session: string, body: unknown): Promise<void> {
|
|
216
|
+
try {
|
|
217
|
+
await ensureOutboxDir();
|
|
218
|
+
const entry: OutboxEntry = { t: new Date().toISOString(), project, session, body };
|
|
219
|
+
const file = join(OUTBOX_DIR, `${Date.now()}-${crypto.randomUUID().slice(0, 8)}.json`);
|
|
220
|
+
await writeFile(file, JSON.stringify(entry) + "\n");
|
|
221
|
+
pendingOutbox.add(file);
|
|
222
|
+
scheduleOutboxFlush();
|
|
223
|
+
} catch { /* best-effort — the JSONL log still has the payload */ }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scheduleOutboxFlush(): void {
|
|
227
|
+
if (outboxTimer || pendingOutbox.size === 0) return;
|
|
228
|
+
outboxTimer = setTimeout(() => {
|
|
229
|
+
outboxTimer = null;
|
|
230
|
+
void flushOutbox();
|
|
231
|
+
}, outboxDelayMs);
|
|
232
|
+
// Don't keep the process (or the test runner) alive just for retries
|
|
233
|
+
(outboxTimer as unknown as { unref?: () => void }).unref?.();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function flushOutbox(): Promise<void> {
|
|
237
|
+
const serviceUrl = getServiceUrl();
|
|
238
|
+
let hadFailure = false;
|
|
239
|
+
for (const file of Array.from(pendingOutbox)) {
|
|
240
|
+
try {
|
|
241
|
+
const entry = JSON.parse(await readFile(file, "utf-8")) as OutboxEntry;
|
|
242
|
+
const res = await fetch(
|
|
243
|
+
`${serviceUrl}/projects/${entry.project}/sessions/${entry.session}/events`,
|
|
244
|
+
{ method: "POST", headers: await authHeaders(), body: JSON.stringify(entry.body) }
|
|
245
|
+
);
|
|
246
|
+
if (res.ok || (res.status >= 400 && res.status < 500)) {
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
console.error(`polaris daemon: dropping outbox entry ${file} — upstream rejected it permanently (${res.status})`);
|
|
249
|
+
}
|
|
250
|
+
pendingOutbox.delete(file);
|
|
251
|
+
await unlink(file).catch(() => {});
|
|
252
|
+
} else {
|
|
253
|
+
hadFailure = true; // 5xx — keep for the next pass
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
257
|
+
pendingOutbox.delete(file); // removed externally (e.g. polaris recover)
|
|
258
|
+
} else {
|
|
259
|
+
hadFailure = true; // network/parse error — keep for the next pass
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
outboxDelayMs = hadFailure ? Math.min(outboxDelayMs * 2, OUTBOX_MAX_DELAY_MS) : 1000;
|
|
264
|
+
scheduleOutboxFlush();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Pick up outbox files left over from a previous daemon run. Only called from
|
|
268
|
+
// the import.meta.main block: tests that start the daemon in-process must
|
|
269
|
+
// never replay (or delete) a developer's real outbox against a test server.
|
|
270
|
+
async function scanOutbox(): Promise<void> {
|
|
271
|
+
try {
|
|
272
|
+
await ensureOutboxDir();
|
|
273
|
+
for (const name of await readdir(OUTBOX_DIR)) {
|
|
274
|
+
if (name.endsWith(".json")) pendingOutbox.add(join(OUTBOX_DIR, name));
|
|
275
|
+
}
|
|
276
|
+
scheduleOutboxFlush();
|
|
277
|
+
} catch { /* best-effort */ }
|
|
278
|
+
}
|
|
279
|
+
|
|
157
280
|
// --- Session history (persistent connect/disconnect log for backfill) ---
|
|
158
281
|
|
|
159
282
|
const SESSION_HISTORY_FILE = join(homedir(), ".polaris", "session-history.jsonl");
|
|
@@ -261,7 +384,6 @@ async function backfill(mapping: SessionMapping, duration?: string, from?: strin
|
|
|
261
384
|
|
|
262
385
|
return { recovered, source: "daemon_log", gaps: [] };
|
|
263
386
|
}
|
|
264
|
-
|
|
265
387
|
// --- HTTP Server ---
|
|
266
388
|
|
|
267
389
|
function json(data: unknown, status = 200): Response {
|
|
@@ -278,7 +400,6 @@ function error(message: string, status: number): Response {
|
|
|
278
400
|
export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 4322)): {
|
|
279
401
|
server: Server;
|
|
280
402
|
sessions: Map<string, SessionMapping>;
|
|
281
|
-
mcpCallbacks: Map<string, (event: unknown) => void>;
|
|
282
403
|
stop: () => void;
|
|
283
404
|
} {
|
|
284
405
|
const server = Bun.serve({
|
|
@@ -290,6 +411,13 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
290
411
|
const { pathname } = url;
|
|
291
412
|
const method = req.method;
|
|
292
413
|
|
|
414
|
+
// Shared-secret auth: enforced on ALL endpoints when a secret is
|
|
415
|
+
// configured; no-op when none is resolved (tests / back-compat).
|
|
416
|
+
const daemonSecret = getDaemonSecret();
|
|
417
|
+
if (daemonSecret && req.headers.get("x-polaris-daemon-secret") !== daemonSecret) {
|
|
418
|
+
return error("Unauthorized", 401);
|
|
419
|
+
}
|
|
420
|
+
|
|
293
421
|
// POST /register — MCP server registers its CC session
|
|
294
422
|
if (method === "POST" && pathname === "/register") {
|
|
295
423
|
try {
|
|
@@ -422,6 +550,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
422
550
|
const body = (await req.json()) as { ccSessionId: string };
|
|
423
551
|
if (!body.ccSessionId) return error("ccSessionId required", 400);
|
|
424
552
|
disconnectCloudWs(body.ccSessionId);
|
|
553
|
+
injectQueues.delete(body.ccSessionId);
|
|
425
554
|
const mapping = sessions.get(body.ccSessionId);
|
|
426
555
|
if (mapping && mapping.project) {
|
|
427
556
|
await logSessionChange("disconnect", body.ccSessionId, mapping.project, mapping.session, mapping.user, mapping.agent);
|
|
@@ -444,6 +573,7 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
444
573
|
mapping.user = "";
|
|
445
574
|
}
|
|
446
575
|
sessions.clear();
|
|
576
|
+
injectQueues.clear();
|
|
447
577
|
return json({ status: "all_disconnected" });
|
|
448
578
|
}
|
|
449
579
|
|
|
@@ -455,11 +585,24 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
455
585
|
const ccSessionId = body.session_id;
|
|
456
586
|
if (!ccSessionId) return error("session_id required in hook payload", 400);
|
|
457
587
|
|
|
588
|
+
// Session routing (deterministic, in priority order):
|
|
589
|
+
// 1. Exact match: session_id is a known mapping (registered via
|
|
590
|
+
// MCP, connected via /connect, or a previously learned alias) —
|
|
591
|
+
// always routed by that match, regardless of how many other
|
|
592
|
+
// sessions are connected. A matchable event is never dropped.
|
|
593
|
+
// 2. No match + exactly one connected session: route to it and
|
|
594
|
+
// remember session_id as an alias of that mapping (the MCP
|
|
595
|
+
// client's generated UUID differs from CC's hook session_id),
|
|
596
|
+
// so later events route by rule 1 even once more sessions join.
|
|
597
|
+
// 3. No match + multiple connected sessions: truly unmatchable —
|
|
598
|
+
// drop, but loudly (console warning + JSONL log entry).
|
|
599
|
+
// 4. No match + nothing connected: not_connected (existing).
|
|
458
600
|
let mapping = sessions.get(ccSessionId);
|
|
459
601
|
if (!mapping || !mapping.project) {
|
|
460
|
-
// CC session_id doesn't match any registered MCP client.
|
|
461
|
-
//
|
|
462
|
-
//
|
|
602
|
+
// CC session_id doesn't match any registered MCP client. The MCP
|
|
603
|
+
// server uses a different UUID than CC's session_id. Match to a
|
|
604
|
+
// session with pendingMapping (most recent first); fall back to the
|
|
605
|
+
// single-connected-session heuristic when none is pending.
|
|
463
606
|
const pending = Array.from(sessions.values()).filter((m) => m.project && m.pendingMapping);
|
|
464
607
|
if (pending.length > 0) {
|
|
465
608
|
// Map the CC session ID to the most recently connected pending session
|
|
@@ -468,14 +611,38 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
468
611
|
// Register under the real CC session ID for future events
|
|
469
612
|
sessions.set(ccSessionId, { ...mapping, ccSessionId });
|
|
470
613
|
console.error(`[daemon] Mapped CC session ${ccSessionId.slice(0, 8)} → ${mapping.project}/${mapping.session}`);
|
|
614
|
+
// Share the inject queue between the original ccSessionId (where the
|
|
615
|
+
// cloud WS enqueues) and this CC hook session_id alias, so injected
|
|
616
|
+
// messages are drained regardless of which key later events use.
|
|
617
|
+
const queue = injectQueues.get(mapping.ccSessionId) ?? [];
|
|
618
|
+
injectQueues.set(mapping.ccSessionId, queue);
|
|
619
|
+
injectQueues.set(ccSessionId, queue);
|
|
471
620
|
} else {
|
|
472
|
-
// No pending sessions —
|
|
621
|
+
// No pending sessions — fall back to the single-session heuristic.
|
|
473
622
|
const connectedSessions = Array.from(sessions.values()).filter((m) => m.project);
|
|
474
623
|
if (connectedSessions.length === 1) {
|
|
624
|
+
// Only one active session — route to it and remember the mapping
|
|
475
625
|
mapping = connectedSessions[0];
|
|
476
|
-
sessions.set(ccSessionId, { ...mapping, ccSessionId });
|
|
626
|
+
sessions.set(ccSessionId, { ...mapping, ccSessionId, slackChannel: undefined });
|
|
627
|
+
// Share the inject queue (see note above).
|
|
628
|
+
const queue = injectQueues.get(mapping.ccSessionId) ?? [];
|
|
629
|
+
injectQueues.set(mapping.ccSessionId, queue);
|
|
630
|
+
injectQueues.set(ccSessionId, queue);
|
|
631
|
+
} else if (connectedSessions.length > 1) {
|
|
632
|
+
// Multiple sessions and session_id matches none of them (nor any
|
|
633
|
+
// learned alias) — can't determine which one. Drop with a clear
|
|
634
|
+
// warning, never silently.
|
|
635
|
+
const candidates = connectedSessions.map((m) => `${m.project}/${m.session}`).join(", ");
|
|
636
|
+
console.error(
|
|
637
|
+
`polaris daemon: dropping ${String(body.hook_event_name ?? "hook")} event — ` +
|
|
638
|
+
`session_id ${ccSessionId} matches no known mapping and ${connectedSessions.length} ` +
|
|
639
|
+
`sessions are connected (${candidates}). ` +
|
|
640
|
+
`Reconnect with polaris_connect in the affected Claude session to re-establish routing.`
|
|
641
|
+
);
|
|
642
|
+
await logEvent("/events", body, { status: 0, body: `dropped: ambiguous across ${connectedSessions.length} sessions (${candidates})` });
|
|
643
|
+
return json({ status: "ambiguous" });
|
|
477
644
|
} else {
|
|
478
|
-
return json({ status:
|
|
645
|
+
return json({ status: "not_connected" });
|
|
479
646
|
}
|
|
480
647
|
}
|
|
481
648
|
}
|
|
@@ -484,22 +651,50 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
484
651
|
const hookEvent = body.hook_event_name as string | undefined;
|
|
485
652
|
const sender = hookEvent === "UserPromptSubmit" ? mapping.user : mapping.agent;
|
|
486
653
|
|
|
487
|
-
// Relay to cloud service
|
|
654
|
+
// Relay to cloud service; on network failure or upstream 5xx,
|
|
655
|
+
// persist to the write-ahead outbox instead of dropping
|
|
488
656
|
const serviceUrl = getServiceUrl();
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
657
|
+
const relayBody = { sender, payload: body };
|
|
658
|
+
let res: Response | null = null;
|
|
659
|
+
try {
|
|
660
|
+
res = await fetch(
|
|
661
|
+
`${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
|
|
662
|
+
{
|
|
663
|
+
method: "POST",
|
|
664
|
+
headers: await authHeaders(),
|
|
665
|
+
body: JSON.stringify(relayBody),
|
|
666
|
+
}
|
|
667
|
+
);
|
|
668
|
+
} catch {
|
|
669
|
+
res = null; // network failure
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!res || res.status >= 500) {
|
|
673
|
+
console.error(`polaris daemon: upstream relay failed (${res ? res.status : "network error"}) — queued to outbox`);
|
|
674
|
+
await logEvent("/events", body, { status: res?.status ?? 0, body: "queued to outbox" });
|
|
675
|
+
await enqueueOutbox(mapping.project, mapping.session, relayBody);
|
|
676
|
+
// The event is durably accepted (outbox), so still drain injects
|
|
677
|
+
if (hookEvent === "UserPromptSubmit") {
|
|
678
|
+
const queue = injectQueues.get(mapping.ccSessionId);
|
|
679
|
+
const pendingInjects = queue ? queue.splice(0, queue.length) : [];
|
|
680
|
+
return json({ ok: true, pendingInjects, queued: true });
|
|
495
681
|
}
|
|
496
|
-
|
|
682
|
+
return json({ status: "queued" });
|
|
683
|
+
}
|
|
497
684
|
|
|
498
685
|
if (!res.ok) {
|
|
499
686
|
const err = await res.text();
|
|
500
687
|
await logEvent("/events", body, { status: res.status, body: err });
|
|
501
688
|
return new Response(err, { status: res.status });
|
|
502
689
|
}
|
|
690
|
+
|
|
691
|
+
// UserPromptSubmit: drain queued advisor injects and hand them back
|
|
692
|
+
// to the hook, which surfaces them via additionalContext
|
|
693
|
+
if (hookEvent === "UserPromptSubmit") {
|
|
694
|
+
const queue = injectQueues.get(mapping.ccSessionId);
|
|
695
|
+
const pendingInjects = queue ? queue.splice(0, queue.length) : [];
|
|
696
|
+
return json({ ok: true, pendingInjects });
|
|
697
|
+
}
|
|
503
698
|
return json({ status: "relayed" });
|
|
504
699
|
} catch {
|
|
505
700
|
return error("Invalid JSON", 400);
|
|
@@ -604,21 +799,34 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
604
799
|
if (!mapping || !mapping.project) return error("Not connected", 400);
|
|
605
800
|
|
|
606
801
|
const serviceUrl = getServiceUrl();
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
802
|
+
const relayBody = {
|
|
803
|
+
// Replies come from the agent, not the human driver
|
|
804
|
+
sender: mapping.agent,
|
|
805
|
+
payload: {
|
|
806
|
+
hook_event_name: "Stop",
|
|
807
|
+
session_id: body.ccSessionId,
|
|
808
|
+
stop_response: body.message,
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
let res: Response | null = null;
|
|
812
|
+
try {
|
|
813
|
+
res = await fetch(
|
|
814
|
+
`${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
|
|
815
|
+
{
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers: await authHeaders(),
|
|
818
|
+
body: JSON.stringify(relayBody),
|
|
819
|
+
}
|
|
820
|
+
);
|
|
821
|
+
} catch {
|
|
822
|
+
res = null; // network failure
|
|
823
|
+
}
|
|
824
|
+
if (!res || res.status >= 500) {
|
|
825
|
+
console.error(`polaris daemon: upstream reply relay failed (${res ? res.status : "network error"}) — queued to outbox`);
|
|
826
|
+
await logEvent("/reply", body, { status: res?.status ?? 0, body: "queued to outbox" });
|
|
827
|
+
await enqueueOutbox(mapping.project, mapping.session, relayBody);
|
|
828
|
+
return json({ status: "queued" });
|
|
829
|
+
}
|
|
622
830
|
if (!res.ok) {
|
|
623
831
|
const err = await res.text();
|
|
624
832
|
await logEvent("/reply", body, { status: res.status, body: err });
|
|
@@ -703,14 +911,30 @@ export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 432
|
|
|
703
911
|
},
|
|
704
912
|
});
|
|
705
913
|
|
|
706
|
-
return {
|
|
914
|
+
return {
|
|
915
|
+
server,
|
|
916
|
+
sessions,
|
|
917
|
+
stop: () => {
|
|
918
|
+
if (outboxTimer) {
|
|
919
|
+
clearTimeout(outboxTimer);
|
|
920
|
+
outboxTimer = null;
|
|
921
|
+
}
|
|
922
|
+
server.stop(true);
|
|
923
|
+
},
|
|
924
|
+
};
|
|
707
925
|
}
|
|
708
926
|
|
|
709
927
|
// --- Run if executed directly ---
|
|
710
928
|
if (import.meta.main) {
|
|
711
929
|
// Load config before starting so getServiceUrl() has the active profile
|
|
930
|
+
// (and getDaemonSecret() the configured daemonSecret)
|
|
712
931
|
await loadConfig();
|
|
932
|
+
// Replay any events left in the outbox by a previous daemon run
|
|
933
|
+
await scanOutbox();
|
|
713
934
|
const { server } = startDaemon();
|
|
714
935
|
console.error(`Polaris daemon listening on http://127.0.0.1:${server.port}`);
|
|
715
936
|
console.error(` API endpoint: ${getServiceUrl()}`);
|
|
937
|
+
if (getDaemonSecret()) {
|
|
938
|
+
console.error(" Auth: x-polaris-daemon-secret required on all endpoints");
|
|
939
|
+
}
|
|
716
940
|
}
|
package/src/service/auth.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import * as jose from "jose";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const DEV_DEFAULT_SECRET = "polaris-dev-secret-change-in-prod";
|
|
4
|
+
const JWT_SECRET = new TextEncoder().encode(process.env.POLARIS_JWT_SECRET ?? DEV_DEFAULT_SECRET);
|
|
4
5
|
const JWT_ISSUER = "polaris";
|
|
5
6
|
const JWT_EXPIRY = "30d";
|
|
6
7
|
|
|
8
|
+
// Refuse to run in production with a missing or default JWT secret.
|
|
9
|
+
export function assertSecretConfigured(): void {
|
|
10
|
+
const secret = process.env.POLARIS_JWT_SECRET;
|
|
11
|
+
if (process.env.NODE_ENV === "production" && (!secret || secret === DEV_DEFAULT_SECRET)) {
|
|
12
|
+
throw new Error("POLARIS_JWT_SECRET must be set to a non-default value in production");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
export interface TokenPayload {
|
|
8
17
|
sub: string; // user ID
|
|
9
18
|
email: string;
|