@1presence/bridge 0.46.0 → 0.50.0

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/README.md CHANGED
@@ -32,7 +32,7 @@ Your OAuth tokens and vault data stay server-side — nothing sensitive is store
32
32
 
33
33
  ## Model
34
34
 
35
- On first run the bridge asks which Claude model you want it to use. Leave the prompt blank to defer to your local Claude Code default (recommended — typically Sonnet on a Pro subscription), or type a specific model id to pin it (e.g. `claude-opus-4-7`, `claude-sonnet-4-5`, `claude-haiku-4-5`). The choice is saved to `~/.1presence/config.json` and reused on every subsequent run.
35
+ On first run the bridge asks which Claude model you want it to use. Pick "Use Claude Code default" to defer to your local Claude Code default, or choose a specific model id to pin it (e.g. `claude-opus-4-8[1m]`, `claude-opus-4-8`, `claude-sonnet-4-6`, `claude-haiku-4-5`). The default selection (auto-chosen if the prompt times out) is `claude-opus-4-8[1m]` — the latest Opus with a 1M-token context window. The choice is saved to `~/.1presence/config.json` and reused on every subsequent run.
36
36
 
37
37
  The first reply each session prints the model and credential source so you can confirm what's running. To change your pick later, edit or delete `~/.1presence/config.json` — the bridge will prompt again on the next start.
38
38
 
package/dist/auth.js CHANGED
@@ -190,6 +190,21 @@ export async function ensureFreshToken(auth) {
190
190
  const newToken = await refreshIdToken(auth.refreshToken);
191
191
  return { ...auth, token: newToken };
192
192
  }
193
+ /**
194
+ * Mints a fresh ID token unconditionally, ignoring local expiry. Used on the
195
+ * reconnect path after the gateway has *rejected* our token (WS close 4001):
196
+ * the gateway's verdict beats our own clock, so we always refresh rather than
197
+ * trusting a token that still looks valid locally (e.g. clock skew). Returns
198
+ * the refreshed auth, or null when there is no refresh token to mint from —
199
+ * the caller must then fall back to an interactive re-sign-in. Throws only if
200
+ * the refresh endpoint itself rejects (refresh token revoked/expired).
201
+ */
202
+ export async function forceRefreshToken(auth) {
203
+ if (!auth.refreshToken)
204
+ return null;
205
+ const newToken = await refreshIdToken(auth.refreshToken);
206
+ return { ...auth, token: newToken };
207
+ }
193
208
  // ─── Public API ───────────────────────────────────────────────────────────────
194
209
  // No cache — every bridge launch goes through the browser flow. This means
195
210
  // permission revocations take effect on the next restart, and the PWA's
package/dist/claude.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from 'fs';
2
2
  import { tmpdir } from 'os';
3
3
  import { join } from 'path';
4
- import { query } from '@anthropic-ai/claude-agent-sdk';
4
+ import { z } from 'zod';
5
+ import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
6
+ import { resolveSessionFilePath } from './sessionPath.js';
5
7
  // ─── Engine ────────────────────────────────────────────────────────────────────
6
8
  //
7
9
  // The bridge drives the local Claude Code install through the Claude Agent SDK's
@@ -295,6 +297,11 @@ export function spawnClaude(params) {
295
297
  // line's 🧠 segment reports against the model's window.
296
298
  let lastContextTokens = 0;
297
299
  let extractedModel = null;
300
+ // Captured from the SDK's system/init event; gates read_session_file so it can
301
+ // only reach files under THIS session's folder. Read by the in-process tool
302
+ // handler, which fires later (when the model invokes it), by which time init
303
+ // has arrived.
304
+ let currentSessionId = null;
298
305
  let killedForViolation = false;
299
306
  let sawApiError = false;
300
307
  let sawAuthFailure = false;
@@ -305,32 +312,28 @@ export function spawnClaude(params) {
305
312
  // runs before any execution) for anything that slips past. Our MCP tools are
306
313
  // auto-approved via allowedTools, so this callback only ever fires to deny.
307
314
  const canUseTool = async (toolName, input) => {
308
- if (toolName.startsWith('mcp__1presence__'))
315
+ // mcp__local__ is the in-process server (read_session_file) — local-only,
316
+ // mechanical, no network/pod hop. Everything else must be a 1Presence tool.
317
+ if (toolName.startsWith('mcp__1presence__') || toolName.startsWith('mcp__local__')) {
309
318
  return { behavior: 'allow', updatedInput: input };
319
+ }
310
320
  return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
311
321
  };
312
322
  // Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
313
323
  // credentials in the Keychain), not an API key that would bill a separate
314
324
  // account. Options.env REPLACES the subprocess env, so spread the rest through.
315
325
  const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
316
- // Claude Code truncates "large" MCP tool results (token count over
317
- // MAX_MCP_OUTPUT_TOKENS, default 25,000) one of two ways, gated by
318
- // ENABLE_MCP_LARGE_OUTPUT_FILES:
319
- // default (feature ON): the full result is written to a tool-results/*.json
320
- // file and the model is handed a `<persisted-output>` stub telling it to Read
321
- // the file. Local Mode disables every built-in tool (`tools: []` below), so
322
- // the model has no Read it reaches for vault_read, which resolves the local
323
- // path against the GCS prefix and fails ("No such object"). Unrecoverable.
324
- // OFF: the result is inline-truncated to the capgraceful degradation the
325
- // model can actually work with.
326
- // Hosted mode pipes results straight to the Anthropic SDK with no truncation, so
327
- // this breaks Local Mode only — a parity bug. Fix on two fronts: force the
328
- // file-save path off so the unrecoverable failure mode is impossible, and raise
329
- // the cap so legitimate large results (skill bodies, vault reads) pass inline
330
- // instead of truncating. Operator overrides (env already set) win on both.
331
- if (!safeEnv['ENABLE_MCP_LARGE_OUTPUT_FILES']) {
332
- safeEnv['ENABLE_MCP_LARGE_OUTPUT_FILES'] = 'false';
333
- }
326
+ // When an MCP tool result is "large", Claude Code spills the full result to a
327
+ // tool-results/*.json file under the session folder and hands the model a
328
+ // `<persisted-output>` stub telling it to read that file. Local Mode disables
329
+ // every built-in tool (`tools: []` below), so there is no Read historically
330
+ // the model reached for vault_read (which reads the cloud vault, not local
331
+ // disk) and stalled. The fix is the in-process read_session_file tool (see the
332
+ // `local` MCP server below), which reads the spilled file directly. We also
333
+ // best-effort raise MAX_MCP_OUTPUT_TOKENS so smaller large-ish results pass
334
+ // inline and never spill in the first place but treat it as advisory: the
335
+ // env var is not honoured by every SDK version (it was a no-op on 0.3.x, which
336
+ // is what made read_session_file necessary). Operator override wins.
334
337
  if (!safeEnv['MAX_MCP_OUTPUT_TOKENS']) {
335
338
  safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
336
339
  }
@@ -344,6 +347,9 @@ export function spawnClaude(params) {
344
347
  if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
345
348
  const keySource = event['apiKeySource'];
346
349
  const model = event['model'];
350
+ const sid = event['session_id'];
351
+ if (sid)
352
+ currentSessionId = sid;
347
353
  if (model)
348
354
  extractedModel = model;
349
355
  if (!modelAnnounced) {
@@ -398,8 +404,9 @@ export function spawnClaude(params) {
398
404
  // make a non-1Presence tool unreachable. If one appears anyway, kill
399
405
  // the turn so any side effect in flight is the only damage done.
400
406
  const isMcp1presence = toolName.startsWith('mcp__1presence__');
407
+ const isMcpLocal = toolName.startsWith('mcp__local__');
401
408
  const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
402
- if (!isMcp1presence && !isBareName) {
409
+ if (!isMcp1presence && !isMcpLocal && !isBareName) {
403
410
  killedForViolation = true;
404
411
  const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
405
412
  process.stderr.write(`[bridge] FATAL ${violation} — aborting\n`);
@@ -512,12 +519,48 @@ export function spawnClaude(params) {
512
519
  onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
513
520
  return;
514
521
  }
522
+ // In-process MCP server for purely-LOCAL operations that cannot run in the
523
+ // pod (the file lives on this machine, not the agent pod). Currently one
524
+ // tool: read_session_file, the recovery path for the SDK's large-output
525
+ // spill (see resolveSessionFilePath above). It is the ONLY way to read a
526
+ // file on the bridge host — built-ins stay disabled (`tools: []`). The tool
527
+ // body is mechanical (read a confined file); no product logic or secrets, so
528
+ // it is safe in this public package. alwaysLoad keeps it in the turn-1
529
+ // prompt instead of behind tool-search, so the model can use it the moment
530
+ // it is handed a persisted-output stub.
531
+ {
532
+ const localServer = createSdkMcpServer({
533
+ name: 'local',
534
+ version: '1.0.0',
535
+ tools: [
536
+ tool('read_session_file', 'Read a file from THIS Local Mode session\'s own working folder — e.g. a large tool result the runtime saved to a tool-results/*.json file and asked you to read. Read-only and confined to the current session directory. Use this (never vault_read, which reads your cloud vault) for any absolute local path the runtime hands you.', { path: z.string().describe('Absolute path to the file, exactly as the runtime gave it to you.') }, async (args) => {
537
+ const safe = resolveSessionFilePath(args.path, currentSessionId);
538
+ if (!safe) {
539
+ return {
540
+ content: [{ type: 'text', text: `Refused: "${args.path}" is outside this session's directory. Only files under the current session folder can be read.` }],
541
+ isError: true,
542
+ };
543
+ }
544
+ try {
545
+ return { content: [{ type: 'text', text: readFileSync(safe, 'utf-8') }] };
546
+ }
547
+ catch (err) {
548
+ return {
549
+ content: [{ type: 'text', text: `Could not read ${safe}: ${err.message}` }],
550
+ isError: true,
551
+ };
552
+ }
553
+ }, { alwaysLoad: true }),
554
+ ],
555
+ });
556
+ mcpServers = { ...mcpServers, local: localServer };
557
+ }
515
558
  const options = {
516
559
  systemPrompt, // custom string → replaces the default Claude Code prompt
517
560
  mcpServers: mcpServers,
518
561
  strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
519
562
  settingSources: [], // no user/project settings or memory
520
- allowedTools: ['mcp__1presence__*'], // auto-approve our MCP surface (no prompt)
563
+ allowedTools: ['mcp__1presence__*', 'mcp__local__read_session_file'], // auto-approve our MCP surface (no prompt)
521
564
  canUseTool, // hard deny anything else
522
565
  tools: [], // disable ALL built-in tools; MCP tools come via mcpServers and survive.
523
566
  // (The old `extraArgs: { tools: '' }` passed a malformed --tools "" that
package/dist/config.js CHANGED
@@ -78,10 +78,11 @@ function detectClaudeDefaultModel() {
78
78
  // `--model` flag passed to the subprocess).
79
79
  const MODEL_OPTIONS = [
80
80
  { num: 1, model: null, label: 'Use Claude Code default' },
81
- { num: 2, model: 'claude-opus-4-8', label: 'claude-opus-4-8' },
82
- { num: 3, model: 'claude-opus-4-7', label: 'claude-opus-4-7' },
83
- { num: 4, model: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
84
- { num: 5, model: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
81
+ { num: 2, model: 'claude-opus-4-8[1m]', label: 'claude-opus-4-8[1m] (1M context)' },
82
+ { num: 3, model: 'claude-opus-4-8', label: 'claude-opus-4-8' },
83
+ { num: 4, model: 'claude-opus-4-7', label: 'claude-opus-4-7' },
84
+ { num: 5, model: 'claude-sonnet-4-6', label: 'claude-sonnet-4-6' },
85
+ { num: 6, model: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
85
86
  ];
86
87
  const PROMPT_TIMEOUT_MS = 10_000;
87
88
  const DEFAULT_OPTION_NUM = 2;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { tmpdir } from 'os';
5
5
  import { join, dirname } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { createRequire } from 'module';
8
- import { getValidAuth, ensureFreshToken, isTokenValid, AuthCancelledError } from './auth.js';
8
+ import { getValidAuth, ensureFreshToken, forceRefreshToken, isTokenValid, AuthCancelledError } from './auth.js';
9
9
  import { spawnClaude, killAll, cancelConversation, setVerbose, setDebug, paint, SECTION_COLORS } from './claude.js';
10
10
  import { ensureModelChoice } from './config.js';
11
11
  import { checkAndUpdate } from './update.js';
@@ -49,6 +49,15 @@ let currentWs = null;
49
49
  // of the per-turn status line. On a pure subscription the CLI often reports a
50
50
  // per-turn cost of 0, in which case this stays at 0 and reads as "plan usage".
51
51
  let sessionCostUsd = 0;
52
+ // Consecutive gateway auth rejections (WS close 4001) since the last good
53
+ // connection. Reset on every successful `open`. A 4001 is almost always a
54
+ // Firebase ID token that expired while the bridge sat idle and a deploy/restart
55
+ // dropped the socket — recoverable by minting a fresh token. But if freshly
56
+ // minted tokens *keep* being rejected, the account itself is the problem
57
+ // (disabled/permission revoked): stop the refresh-and-retry loop and ask the
58
+ // user to sign in again rather than spinning forever.
59
+ let authRejections = 0;
60
+ const MAX_AUTH_REJECTIONS = 3;
52
61
  // ─── Status line ──────────────────────────────────────────────────────────────
53
62
  //
54
63
  // A compact line printed after each completed turn echoing the segments local
@@ -450,6 +459,9 @@ function connect(auth, retryDelay = 1000) {
450
459
  // Reset backoff so that a disconnect after a long-stable session
451
460
  // reconnects quickly instead of waiting at the 30s cap.
452
461
  retryDelay = 1000;
462
+ // A clean connection means whatever token we hold is accepted — clear the
463
+ // 4001 refresh-retry budget so a future expiry gets the full allowance.
464
+ authRejections = 0;
453
465
  console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
454
466
  startPing();
455
467
  // Drain any save records left behind by an earlier crashed/dropped session.
@@ -500,32 +512,17 @@ function connect(auth, retryDelay = 1000) {
500
512
  });
501
513
  ws.on('close', (code) => {
502
514
  stopPing();
503
- if (code === 4001) {
504
- console.error('Authentication failed. Please restart the bridge to sign in again.');
505
- process.exit(1);
506
- }
515
+ // Permission genuinely not granted — no token refresh can fix this, so it
516
+ // stays terminal.
507
517
  if (code === 4003) {
508
518
  console.error('Local Claude Code is not enabled for your account. To request access, email hello@1presence.com.');
509
519
  process.exit(1);
510
520
  }
511
- const delay = Math.min(retryDelay, 30_000);
512
- console.log(`Bridge disconnected (${code}). Reconnecting in ${delay / 1000}s…`);
513
- setTimeout(async () => {
514
- try {
515
- // Refresh setup files on reconnect in case token was refreshed.
516
- // If /system-prompt-for-bridge is down this throws — log and retry; the bridge
517
- // is useless without a current prompt so don't paper over it.
518
- if (currentAuth)
519
- await writeSetupFiles(currentAuth);
520
- connect(currentAuth, Math.min(retryDelay * 2, 30_000));
521
- }
522
- catch (err) {
523
- console.error(`[bridge] reconnect setup failed: ${err.message}`);
524
- console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
525
- if (currentAuth)
526
- connect(currentAuth, Math.min(retryDelay * 2, 30_000));
527
- }
528
- }, delay);
521
+ // Everything else — including 4001 (gateway rejected the JWT) — is handled
522
+ // by scheduleReconnect, which refreshes the token before reconnecting so an
523
+ // expired-token disconnect recovers on its own instead of forcing a manual
524
+ // bridge restart.
525
+ scheduleReconnect(code, retryDelay);
529
526
  });
530
527
  ws.on('error', (err) => {
531
528
  // close event fires after error — reconnect handled there
@@ -535,6 +532,78 @@ function connect(auth, retryDelay = 1000) {
535
532
  });
536
533
  return ws;
537
534
  }
535
+ // ─── Reconnect with token refresh ──────────────────────────────────────────────
536
+ //
537
+ // Schedules a reconnect after any non-terminal disconnect. The job this does
538
+ // that the old inline handler didn't: it mints a fresh ID token BEFORE
539
+ // reconnecting. Firebase ID tokens last ~1h, and the only place the bridge
540
+ // previously refreshed was per-turn (handleMessage). So an idle bridge whose
541
+ // socket dropped during a deploy/restart would reconnect carrying an expired
542
+ // token → 401 on the system-prompt fetch and 4001 on the new socket → the old
543
+ // handler then hard-exited with "please restart the bridge". Now it self-heals.
544
+ //
545
+ // • 4001 (gateway rejected the JWT): force a refresh — the gateway's verdict
546
+ // beats our local clock. Bounded by MAX_AUTH_REJECTIONS so a genuinely dead
547
+ // account (revoked permission, no refresh token) still exits instead of
548
+ // looping. Exits only when refresh is impossible or repeatedly rejected.
549
+ // • Any other code (1006 abnormal close, 1001 going-away on pod restart, …):
550
+ // refresh only if near expiry (ensureFreshToken). A transient refresh
551
+ // failure here is non-fatal — reconnect with the current token; if it's
552
+ // truly expired the gateway returns 4001 and the branch above handles it.
553
+ function scheduleReconnect(closeCode, retryDelay) {
554
+ const authFailure = closeCode === 4001;
555
+ const delay = Math.min(retryDelay, 30_000);
556
+ console.log(authFailure
557
+ ? `Authentication expired (${closeCode}). Refreshing token and reconnecting in ${delay / 1000}s…`
558
+ : `Bridge disconnected (${closeCode}). Reconnecting in ${delay / 1000}s…`);
559
+ setTimeout(async () => {
560
+ if (!currentAuth)
561
+ return;
562
+ const nextDelay = Math.min(retryDelay * 2, 30_000);
563
+ if (authFailure) {
564
+ authRejections++;
565
+ if (authRejections > MAX_AUTH_REJECTIONS) {
566
+ console.error('Authentication keeps being rejected even after refreshing — please restart the bridge to sign in again.');
567
+ process.exit(1);
568
+ }
569
+ try {
570
+ const refreshed = await forceRefreshToken(currentAuth);
571
+ if (!refreshed) {
572
+ console.error('Authentication failed and no refresh token is available — please restart the bridge to sign in again.');
573
+ process.exit(1);
574
+ }
575
+ currentAuth = refreshed;
576
+ console.log(`[bridge] token refreshed (attempt ${authRejections}/${MAX_AUTH_REJECTIONS}) — reconnecting with a new token.`);
577
+ }
578
+ catch (err) {
579
+ // Refresh endpoint rejected us — the refresh token is revoked/expired.
580
+ // No silent recovery is possible; the user must sign in again.
581
+ console.error(`[bridge] token refresh failed: ${err.message}`);
582
+ console.error('Please restart the bridge to sign in again.');
583
+ process.exit(1);
584
+ }
585
+ }
586
+ else {
587
+ try {
588
+ currentAuth = await ensureFreshToken(currentAuth);
589
+ }
590
+ catch (err) {
591
+ console.warn(`[bridge] token refresh on reconnect failed (proceeding with current token): ${err.message}`);
592
+ }
593
+ }
594
+ // Token is as fresh as we can make it — write setup files and reconnect.
595
+ // If /system-prompt-for-bridge is still 503/down (gateway waking) we
596
+ // reconnect anyway; handleMessage refreshes the prompt on the next turn.
597
+ try {
598
+ await writeSetupFiles(currentAuth);
599
+ }
600
+ catch (err) {
601
+ console.error(`[bridge] reconnect setup failed: ${err.message}`);
602
+ console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
603
+ }
604
+ connect(currentAuth, nextDelay);
605
+ }, delay);
606
+ }
538
607
  // ─── Main ─────────────────────────────────────────────────────────────────────
539
608
  async function main() {
540
609
  console.log(`1Presence Bridge v${version}\n`);
@@ -0,0 +1,31 @@
1
+ import { homedir } from 'os';
2
+ import { join, resolve, sep } from 'path';
3
+ // Confinement guard for read_session_file (the in-process `local` MCP server in
4
+ // claude.ts). The Agent SDK occasionally spills a large MCP tool result to a
5
+ // local file under ~/.claude/projects/<cwd-slug>/<session-id>/ and hands the
6
+ // model a stub telling it to read the file. Local Mode disables every built-in
7
+ // tool, so the model has no Read — read_session_file is the recovery path. It
8
+ // MUST stay read-only and confined to the CURRENT session's own folder: file
9
+ // contents read here flow up through the gateway into the model and session
10
+ // history, so an unrestricted reader would leak the operator's machine
11
+ // (~/.ssh, other projects) into the transcript.
12
+ //
13
+ // We deliberately do NOT reconstruct Claude Code's lossy cwd-slug. Instead we
14
+ // require (a) the resolved path to live under ~/.claude/projects/ and (b) the
15
+ // current session UUID to appear as a discrete path segment. The UUID is
16
+ // unguessable, so this pins reads to this session alone while staying robust to
17
+ // changes in how Claude Code encodes the cwd.
18
+ //
19
+ // Returns the resolved absolute path when safe, or null when it must be refused.
20
+ export function resolveSessionFilePath(requestedPath, sessionId, home = homedir()) {
21
+ if (!sessionId || !requestedPath)
22
+ return null;
23
+ const projectsRoot = join(home, '.claude', 'projects');
24
+ const resolved = resolve(requestedPath); // normalises away `..`
25
+ const underProjects = resolved === projectsRoot || resolved.startsWith(projectsRoot + sep);
26
+ if (!underProjects)
27
+ return null;
28
+ if (!resolved.split(sep).includes(sessionId))
29
+ return null;
30
+ return resolved;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.46.0",
3
+ "version": "0.50.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,12 +20,13 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@anthropic-ai/claude-agent-sdk": "^0.3.153",
23
- "ws": "^8.20.0"
23
+ "ws": "^8.20.0",
24
+ "zod": "^4.0.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/node": "^20.0.0",
27
28
  "@types/ws": "^8.18.1",
28
29
  "tsx": "^4.22.3",
29
- "typescript": "^5.5.0"
30
+ "typescript": "^6.0.3"
30
31
  }
31
32
  }