@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 +1 -1
- package/dist/auth.js +15 -0
- package/dist/claude.js +65 -22
- package/dist/config.js +5 -4
- package/dist/index.js +92 -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
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
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-
|
|
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
|
|
@@ -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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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.
|
|
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
|
}
|