@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.
@@ -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
- // IPC callbacks for MCP servers to receive advisor messages
26
- const mcpCallbacks = new Map<string, (event: unknown) => void>(); // keyed by ccSessionId
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
- // Forward inject events to the registered MCP callback
121
+ // Queue inject events for delivery via the UserPromptSubmit hook
102
122
  if (data.source === "inject") {
103
- const callback = mcpCallbacks.get(mapping.ccSessionId);
104
- if (callback) callback(data);
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
- // The MCP server uses a different UUID than CC's session_id.
462
- // Match to a session with pendingMapping (most recent first).
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 — try single-session fallback
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: connectedSessions.length > 0 ? "ambiguous" : "not_connected" });
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 res = await fetch(
490
- `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
491
- {
492
- method: "POST",
493
- headers: await authHeaders(),
494
- body: JSON.stringify({ sender, payload: body }),
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 res = await fetch(
608
- `${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
609
- {
610
- method: "POST",
611
- headers: await authHeaders(),
612
- body: JSON.stringify({
613
- sender: mapping.user,
614
- payload: {
615
- hook_event_name: "Stop",
616
- session_id: body.ccSessionId,
617
- stop_response: body.message,
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 { server, sessions, mcpCallbacks, stop: () => server.stop(true) };
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
  }
@@ -1,9 +1,18 @@
1
1
  import * as jose from "jose";
2
2
 
3
- const JWT_SECRET = new TextEncoder().encode(process.env.POLARIS_JWT_SECRET ?? "polaris-dev-secret-change-in-prod");
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;