@1presence/bridge 0.45.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 +1 -1
- package/dist/auth.js +15 -0
- package/dist/claude.js +96 -15
- package/dist/config.js +5 -4
- package/dist/index.js +94 -23
- package/dist/sessionPath.js +31 -0
- package/package.json +4 -3
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.
|
|
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 {
|
|
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
|
|
@@ -161,6 +163,27 @@ function describeCliFailure(apiErrorText, authFailure) {
|
|
|
161
163
|
}
|
|
162
164
|
return 'Local Mode stopped unexpectedly. Please try again.';
|
|
163
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Copy for an actionable rate-limit notice. The SDK emits `rate_limit_event`
|
|
168
|
+
* whenever rate-limit info CHANGES — including the routine `allowed` case on
|
|
169
|
+
* (nearly) every turn — so the caller must drop `allowed` and only invoke this
|
|
170
|
+
* for `allowed_warning` / `rejected`. `resetsAt` is a Unix timestamp; the SDK
|
|
171
|
+
* uses seconds, but we accept ms too in case that changes upstream.
|
|
172
|
+
*/
|
|
173
|
+
function formatRateLimitNotice(status, resetsAt) {
|
|
174
|
+
const when = typeof resetsAt === 'number' && resetsAt > 0
|
|
175
|
+
? new Date(resetsAt < 1e12 ? resetsAt * 1000 : resetsAt)
|
|
176
|
+
.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
177
|
+
: null;
|
|
178
|
+
if (status === 'rejected') {
|
|
179
|
+
return when
|
|
180
|
+
? `Pausing for an upstream rate limit — resumes around ${when}.`
|
|
181
|
+
: 'Pausing briefly for an upstream rate limit, then continuing…';
|
|
182
|
+
}
|
|
183
|
+
return when
|
|
184
|
+
? `Approaching your usage limit — window resets around ${when}.`
|
|
185
|
+
: 'Approaching your usage limit for this period.';
|
|
186
|
+
}
|
|
164
187
|
// ─── Prompt construction ─────────────────────────────────────────────────────────
|
|
165
188
|
//
|
|
166
189
|
// The gateway pushes the FULL conversation (sanitised via @presence/shared
|
|
@@ -274,6 +297,11 @@ export function spawnClaude(params) {
|
|
|
274
297
|
// line's 🧠 segment reports against the model's window.
|
|
275
298
|
let lastContextTokens = 0;
|
|
276
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;
|
|
277
305
|
let killedForViolation = false;
|
|
278
306
|
let sawApiError = false;
|
|
279
307
|
let sawAuthFailure = false;
|
|
@@ -284,22 +312,28 @@ export function spawnClaude(params) {
|
|
|
284
312
|
// runs before any execution) for anything that slips past. Our MCP tools are
|
|
285
313
|
// auto-approved via allowedTools, so this callback only ever fires to deny.
|
|
286
314
|
const canUseTool = async (toolName, input) => {
|
|
287
|
-
|
|
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__')) {
|
|
288
318
|
return { behavior: 'allow', updatedInput: input };
|
|
319
|
+
}
|
|
289
320
|
return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
|
|
290
321
|
};
|
|
291
322
|
// Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
|
|
292
323
|
// credentials in the Keychain), not an API key that would bill a separate
|
|
293
324
|
// account. Options.env REPLACES the subprocess env, so spread the rest through.
|
|
294
325
|
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// (`tools: []` below), so
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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.
|
|
303
337
|
if (!safeEnv['MAX_MCP_OUTPUT_TOKENS']) {
|
|
304
338
|
safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
|
|
305
339
|
}
|
|
@@ -313,6 +347,9 @@ export function spawnClaude(params) {
|
|
|
313
347
|
if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
|
|
314
348
|
const keySource = event['apiKeySource'];
|
|
315
349
|
const model = event['model'];
|
|
350
|
+
const sid = event['session_id'];
|
|
351
|
+
if (sid)
|
|
352
|
+
currentSessionId = sid;
|
|
316
353
|
if (model)
|
|
317
354
|
extractedModel = model;
|
|
318
355
|
if (!modelAnnounced) {
|
|
@@ -367,8 +404,9 @@ export function spawnClaude(params) {
|
|
|
367
404
|
// make a non-1Presence tool unreachable. If one appears anyway, kill
|
|
368
405
|
// the turn so any side effect in flight is the only damage done.
|
|
369
406
|
const isMcp1presence = toolName.startsWith('mcp__1presence__');
|
|
407
|
+
const isMcpLocal = toolName.startsWith('mcp__local__');
|
|
370
408
|
const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
|
|
371
|
-
if (!isMcp1presence && !isBareName) {
|
|
409
|
+
if (!isMcp1presence && !isMcpLocal && !isBareName) {
|
|
372
410
|
killedForViolation = true;
|
|
373
411
|
const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
|
|
374
412
|
process.stderr.write(`[bridge] FATAL ${violation} — aborting\n`);
|
|
@@ -481,12 +519,48 @@ export function spawnClaude(params) {
|
|
|
481
519
|
onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
|
|
482
520
|
return;
|
|
483
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
|
+
}
|
|
484
558
|
const options = {
|
|
485
559
|
systemPrompt, // custom string → replaces the default Claude Code prompt
|
|
486
560
|
mcpServers: mcpServers,
|
|
487
561
|
strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
|
|
488
562
|
settingSources: [], // no user/project settings or memory
|
|
489
|
-
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)
|
|
490
564
|
canUseTool, // hard deny anything else
|
|
491
565
|
tools: [], // disable ALL built-in tools; MCP tools come via mcpServers and survive.
|
|
492
566
|
// (The old `extraArgs: { tools: '' }` passed a malformed --tools "" that
|
|
@@ -568,9 +642,16 @@ export function spawnClaude(params) {
|
|
|
568
642
|
break;
|
|
569
643
|
}
|
|
570
644
|
case 'rate_limit_event': {
|
|
571
|
-
// SDK
|
|
572
|
-
//
|
|
573
|
-
|
|
645
|
+
// The SDK emits this whenever rate-limit info CHANGES — including the
|
|
646
|
+
// routine `allowed` case on (nearly) every turn, which previously
|
|
647
|
+
// spammed a phantom "pausing" notice (see vault/Bugs.md). Only surface
|
|
648
|
+
// a notice when the user is actually warned or throttled.
|
|
649
|
+
const info = m.rate_limit_info;
|
|
650
|
+
const status = info?.status;
|
|
651
|
+
if (status === 'allowed_warning' || status === 'rejected') {
|
|
652
|
+
// Admin-only ephemeral notice — jargon is fine in Local Mode.
|
|
653
|
+
onNotice?.(formatRateLimitNotice(status, info?.resetsAt));
|
|
654
|
+
}
|
|
574
655
|
break;
|
|
575
656
|
}
|
|
576
657
|
// Everything else (partial messages, status, hooks, notifications,
|
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-
|
|
83
|
-
{ num: 4, model: 'claude-
|
|
84
|
-
{ num: 5, model: 'claude-
|
|
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
|
|
@@ -327,6 +336,8 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
327
336
|
// Ephemeral, non-persisted thread notice (admin-only Local Mode). Relayed
|
|
328
337
|
// by the gateway to the PWA SSE stream as a `notice` AgentEvent; it does
|
|
329
338
|
// NOT go through the turn accumulator, so it never lands in history.
|
|
339
|
+
// Log it too: anything the user sees in chat must have a bridge-log trail.
|
|
340
|
+
console.log(`[${new Date().toLocaleTimeString()}] ⚠ notice: ${message}`);
|
|
330
341
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
331
342
|
currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
|
|
332
343
|
}
|
|
@@ -448,6 +459,9 @@ function connect(auth, retryDelay = 1000) {
|
|
|
448
459
|
// Reset backoff so that a disconnect after a long-stable session
|
|
449
460
|
// reconnects quickly instead of waiting at the 30s cap.
|
|
450
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;
|
|
451
465
|
console.log('✓ Bridge connected. Local Mode active on all your devices.\n');
|
|
452
466
|
startPing();
|
|
453
467
|
// Drain any save records left behind by an earlier crashed/dropped session.
|
|
@@ -498,32 +512,17 @@ function connect(auth, retryDelay = 1000) {
|
|
|
498
512
|
});
|
|
499
513
|
ws.on('close', (code) => {
|
|
500
514
|
stopPing();
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
process.exit(1);
|
|
504
|
-
}
|
|
515
|
+
// Permission genuinely not granted — no token refresh can fix this, so it
|
|
516
|
+
// stays terminal.
|
|
505
517
|
if (code === 4003) {
|
|
506
518
|
console.error('Local Claude Code is not enabled for your account. To request access, email hello@1presence.com.');
|
|
507
519
|
process.exit(1);
|
|
508
520
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// If /system-prompt-for-bridge is down this throws — log and retry; the bridge
|
|
515
|
-
// is useless without a current prompt so don't paper over it.
|
|
516
|
-
if (currentAuth)
|
|
517
|
-
await writeSetupFiles(currentAuth);
|
|
518
|
-
connect(currentAuth, Math.min(retryDelay * 2, 30_000));
|
|
519
|
-
}
|
|
520
|
-
catch (err) {
|
|
521
|
-
console.error(`[bridge] reconnect setup failed: ${err.message}`);
|
|
522
|
-
console.error('[bridge] will retry connection anyway — system prompt may be stale until next refresh');
|
|
523
|
-
if (currentAuth)
|
|
524
|
-
connect(currentAuth, Math.min(retryDelay * 2, 30_000));
|
|
525
|
-
}
|
|
526
|
-
}, 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);
|
|
527
526
|
});
|
|
528
527
|
ws.on('error', (err) => {
|
|
529
528
|
// close event fires after error — reconnect handled there
|
|
@@ -533,6 +532,78 @@ function connect(auth, retryDelay = 1000) {
|
|
|
533
532
|
});
|
|
534
533
|
return ws;
|
|
535
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
|
+
}
|
|
536
607
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
537
608
|
async function main() {
|
|
538
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.
|
|
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": "^
|
|
30
|
+
"typescript": "^6.0.3"
|
|
30
31
|
}
|
|
31
32
|
}
|