@1presence/bridge 0.73.0 → 0.74.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/accumulator.js +0 -17
- package/dist/auth.js +1 -55
- package/dist/claude.js +17 -289
- package/dist/claudeAuth.js +1 -38
- package/dist/config.js +1 -46
- package/dist/index.js +3 -221
- package/dist/outbox.js +1 -16
- package/dist/sessionPath.js +1 -18
- package/dist/timer.js +0 -4
- package/dist/update.js +0 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ supported (the second displaces the first).
|
|
|
45
45
|
|
|
46
46
|
## How it works
|
|
47
47
|
|
|
48
|
-
The bridge connects outbound to the 1Presence gateway as a persistent WebSocket. When you send a message from the app, the gateway relays it to the bridge, which spawns a `claude` subprocess with your personal system prompt and all 1Presence tools (vault, Gmail, Drive, Calendar,
|
|
48
|
+
The bridge connects outbound to the 1Presence gateway as a persistent WebSocket. When you send a message from the app, the gateway relays it to the bridge, which spawns a `claude` subprocess with your personal system prompt and all 1Presence tools (vault, Gmail, Drive, Calendar, memory, and more) wired in via MCP. Responses stream back in real time.
|
|
49
49
|
|
|
50
50
|
Conversations are stateful — the bridge maps each 1Presence conversation to a Claude Code session ID so context is preserved across messages.
|
|
51
51
|
|
package/dist/accumulator.js
CHANGED
|
@@ -5,17 +5,7 @@ export function makeBridgeAccumulator() {
|
|
|
5
5
|
toolResults: {},
|
|
6
6
|
turns: [],
|
|
7
7
|
};
|
|
8
|
-
// A new API turn bucket only opens when a user event (tool_results) has
|
|
9
|
-
// arrived since the last assistant event — mirrors the gateway translator
|
|
10
|
-
// logic. The CLI splits a single model emission's text and tool_use blocks
|
|
11
|
-
// into separate `{type:'assistant'}` events; without this guard each split
|
|
12
|
-
// creates a stored assistant message and produces consecutive same-role
|
|
13
|
-
// rows that the read-side merger has to repair. Init true so the very first
|
|
14
|
-
// assistant event opens turn 0.
|
|
15
8
|
let sawUserSinceLastAssistant = true;
|
|
16
|
-
// Returns the current open API-turn bucket, creating one lazily if a text
|
|
17
|
-
// event arrives before any `{type:'assistant'}` event (shouldn't normally
|
|
18
|
-
// happen with Claude Code's stream-json, but keep it robust).
|
|
19
9
|
function currentTurn() {
|
|
20
10
|
if (state.turns.length === 0)
|
|
21
11
|
state.turns.push({ text: '', toolUseIds: [] });
|
|
@@ -24,9 +14,7 @@ export function makeBridgeAccumulator() {
|
|
|
24
14
|
function appendText(text) {
|
|
25
15
|
const turn = currentTurn();
|
|
26
16
|
turn.text += text;
|
|
27
|
-
// Mirror to the flat string with `\n\n` between turns for convenience.
|
|
28
17
|
if (state.assistantText && state.turns.length > 1 && turn.text === text) {
|
|
29
|
-
// First text emission of a new turn — separate from prior turn.
|
|
30
18
|
state.assistantText += '\n\n';
|
|
31
19
|
}
|
|
32
20
|
state.assistantText += text;
|
|
@@ -41,10 +29,6 @@ export function makeBridgeAccumulator() {
|
|
|
41
29
|
return;
|
|
42
30
|
}
|
|
43
31
|
if (type === 'assistant') {
|
|
44
|
-
// Only open a fresh bucket when a user event has arrived since the
|
|
45
|
-
// last assistant event. Otherwise treat this as a continuation of
|
|
46
|
-
// the current API turn (CLI splits text and tool_use into separate
|
|
47
|
-
// assistant events within one model emission).
|
|
48
32
|
if (sawUserSinceLastAssistant) {
|
|
49
33
|
state.turns.push({ text: '', toolUseIds: [] });
|
|
50
34
|
sawUserSinceLastAssistant = false;
|
|
@@ -78,7 +62,6 @@ export function makeBridgeAccumulator() {
|
|
|
78
62
|
return;
|
|
79
63
|
}
|
|
80
64
|
if (type === 'user') {
|
|
81
|
-
// Flip the flag so the next assistant event opens a new API turn.
|
|
82
65
|
sawUserSinceLastAssistant = true;
|
|
83
66
|
const msg = event['message'];
|
|
84
67
|
const content = msg?.['content'];
|
package/dist/auth.js
CHANGED
|
@@ -4,12 +4,8 @@ import { homedir } from 'os';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { exec } from 'child_process';
|
|
6
6
|
import { randomBytes, timingSafeEqual } from 'crypto';
|
|
7
|
-
// Auth lives only in process memory. Earlier versions persisted tokens to
|
|
8
|
-
// ~/.1presence/auth.json; remove any leftover file on startup so a stale,
|
|
9
|
-
// permission-bearing token can't survive a bridge restart.
|
|
10
7
|
const LEGACY_AUTH_FILE = join(homedir(), '.1presence', 'auth.json');
|
|
11
8
|
rmSync(LEGACY_AUTH_FILE, { force: true });
|
|
12
|
-
// ─── JWT helpers ──────────────────────────────────────────────────────────────
|
|
13
9
|
function parseJwt(token) {
|
|
14
10
|
try {
|
|
15
11
|
const payload = token.split('.')[1];
|
|
@@ -24,7 +20,6 @@ export function isTokenValid(token) {
|
|
|
24
20
|
const { exp } = parseJwt(token);
|
|
25
21
|
if (!exp)
|
|
26
22
|
return false;
|
|
27
|
-
// Require at least 5 minutes of validity remaining
|
|
28
23
|
return exp > Math.floor(Date.now() / 1000) + 300;
|
|
29
24
|
}
|
|
30
25
|
function uidFromToken(token) {
|
|
@@ -33,7 +28,6 @@ function uidFromToken(token) {
|
|
|
33
28
|
function emailFromToken(token) {
|
|
34
29
|
return parseJwt(token).email;
|
|
35
30
|
}
|
|
36
|
-
// ─── Browser auth flow ────────────────────────────────────────────────────────
|
|
37
31
|
function openBrowser(url) {
|
|
38
32
|
const platform = process.platform;
|
|
39
33
|
const cmd = platform === 'darwin' ? `open "${url}"`
|
|
@@ -47,18 +41,6 @@ function openBrowser(url) {
|
|
|
47
41
|
export class AuthCancelledError extends Error {
|
|
48
42
|
constructor() { super('Sign-in cancelled — the browser tab was closed.'); }
|
|
49
43
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Applies the localhost auth server's CORS headers, scoped to the legitimate
|
|
52
|
-
* PWA origin. Exported so the Private Network Access behaviour stays under test.
|
|
53
|
-
*
|
|
54
|
-
* The `Access-Control-Allow-Private-Network` reflection is load-bearing: when
|
|
55
|
-
* the HTTPS PWA (a public/secure context) fetches this 127.0.0.1 server (a
|
|
56
|
-
* private address), Chrome sends a CORS preflight carrying
|
|
57
|
-
* `Access-Control-Request-Private-Network: true`. Without the matching allow
|
|
58
|
-
* header, Chrome (PNA enforcement, ~v130+) blocks the request outright — even a
|
|
59
|
-
* plain GET, even for localhost, which is exempt from mixed-content blocking —
|
|
60
|
-
* and the PWA shows "Could not reach the bridge" though the server is up.
|
|
61
|
-
*/
|
|
62
44
|
export function applyAuthCorsHeaders(req, res, pwaOrigin) {
|
|
63
45
|
const reqOrigin = req.headers['origin'] ?? '';
|
|
64
46
|
if (pwaOrigin && reqOrigin === pwaOrigin) {
|
|
@@ -74,10 +56,6 @@ export function applyAuthCorsHeaders(req, res, pwaOrigin) {
|
|
|
74
56
|
function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
75
57
|
return new Promise((resolve, reject) => {
|
|
76
58
|
let resolved = false;
|
|
77
|
-
// Per-launch nonce. Embedded in the auth URL the bridge prints/opens and
|
|
78
|
-
// required on every request to this localhost server. Without it, a
|
|
79
|
-
// malicious page in the user's browser could scan ephemeral ports during
|
|
80
|
-
// the auth window and POST a forged token to hijack the bridge.
|
|
81
59
|
const nonce = randomBytes(32).toString('base64url');
|
|
82
60
|
const nonceBuf = Buffer.from(nonce, 'utf-8');
|
|
83
61
|
function checkNonce(provided) {
|
|
@@ -88,7 +66,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
|
88
66
|
return false;
|
|
89
67
|
return timingSafeEqual(provBuf, nonceBuf);
|
|
90
68
|
}
|
|
91
|
-
// CORS allowlist scoped to the legitimate PWA origin only.
|
|
92
69
|
const pwaOrigin = (() => {
|
|
93
70
|
try {
|
|
94
71
|
return new URL(pwaUrl).origin;
|
|
@@ -106,13 +83,11 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
|
106
83
|
}
|
|
107
84
|
const reqUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
108
85
|
const path = reqUrl.pathname;
|
|
109
|
-
// Every request must carry the launch-specific nonce.
|
|
110
86
|
if (!checkNonce(reqUrl.searchParams.get('nonce'))) {
|
|
111
87
|
res.writeHead(403);
|
|
112
88
|
res.end();
|
|
113
89
|
return;
|
|
114
90
|
}
|
|
115
|
-
// Lets the PWA verify the bridge is still listening before POSTing the token.
|
|
116
91
|
if (req.method === 'GET' && (path === '/' || path === '')) {
|
|
117
92
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
118
93
|
res.end('1Presence bridge waiting for sign-in');
|
|
@@ -123,8 +98,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
|
123
98
|
res.end();
|
|
124
99
|
return;
|
|
125
100
|
}
|
|
126
|
-
// Status beacon from the PWA — used so we exit early when the user closes
|
|
127
|
-
// the auth tab before signing in (sendBeacon path).
|
|
128
101
|
if (path === '/status') {
|
|
129
102
|
const event = reqUrl.searchParams.get('event');
|
|
130
103
|
res.writeHead(204);
|
|
@@ -181,8 +154,6 @@ function runBrowserAuthFlow(gatewayUrl, pwaUrl) {
|
|
|
181
154
|
}, 5 * 60 * 1000);
|
|
182
155
|
});
|
|
183
156
|
}
|
|
184
|
-
// ─── Token refresh ────────────────────────────────────────────────────────────
|
|
185
|
-
// Firebase web API key — public, safe to embed
|
|
186
157
|
const FIREBASE_API_KEY = 'AIzaSyAz16A3eRIMhdLGF9ptsVsWZx9LjKeZwi8';
|
|
187
158
|
async function refreshIdToken(refreshToken) {
|
|
188
159
|
const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`, {
|
|
@@ -197,47 +168,22 @@ async function refreshIdToken(refreshToken) {
|
|
|
197
168
|
throw new Error('Token refresh returned no id_token');
|
|
198
169
|
return data.id_token;
|
|
199
170
|
}
|
|
200
|
-
/** Headroom below which ensureFreshToken mints a new ID token at turn start.
|
|
201
|
-
* Firebase ID tokens live 60 min; the token is baked into the MCP SSE config
|
|
202
|
-
* header at spawn (writeMcpConfig) and reused by that connection for the whole
|
|
203
|
-
* turn — it can't be re-tokened mid-turn without reconnecting the MCP server.
|
|
204
|
-
* So a turn must START with enough headroom to outlive itself. At 10 min a turn
|
|
205
|
-
* that began with ~12 min of life and ran 17 min lost its MCP OAuth AND its
|
|
206
|
-
* save-turn token mid-flight ("MCP OAuth not configured" + save-turn 401 — see
|
|
207
|
-
* the 2026-07-07 stuck-run bug). 30 min comfortably covers the 10-min bridge
|
|
208
|
-
* stage wall-clock and any normal chat turn; near-full-hour refresh would churn
|
|
209
|
-
* a token every turn for little gain. */
|
|
210
171
|
const TOKEN_REFRESH_HEADROOM_SEC = 30 * 60;
|
|
211
|
-
/** Returns auth with a fresh ID token. Refreshes in-memory if less than
|
|
212
|
-
* TOKEN_REFRESH_HEADROOM_SEC of validity remains. */
|
|
213
172
|
export async function ensureFreshToken(auth) {
|
|
214
173
|
const { exp } = parseJwt(auth.token);
|
|
215
174
|
if (exp && exp > Math.floor(Date.now() / 1000) + TOKEN_REFRESH_HEADROOM_SEC)
|
|
216
175
|
return auth;
|
|
217
176
|
if (!auth.refreshToken)
|
|
218
|
-
return auth;
|
|
177
|
+
return auth;
|
|
219
178
|
const newToken = await refreshIdToken(auth.refreshToken);
|
|
220
179
|
return { ...auth, token: newToken };
|
|
221
180
|
}
|
|
222
|
-
/**
|
|
223
|
-
* Mints a fresh ID token unconditionally, ignoring local expiry. Used on the
|
|
224
|
-
* reconnect path after the gateway has *rejected* our token (WS close 4001):
|
|
225
|
-
* the gateway's verdict beats our own clock, so we always refresh rather than
|
|
226
|
-
* trusting a token that still looks valid locally (e.g. clock skew). Returns
|
|
227
|
-
* the refreshed auth, or null when there is no refresh token to mint from —
|
|
228
|
-
* the caller must then fall back to an interactive re-sign-in. Throws only if
|
|
229
|
-
* the refresh endpoint itself rejects (refresh token revoked/expired).
|
|
230
|
-
*/
|
|
231
181
|
export async function forceRefreshToken(auth) {
|
|
232
182
|
if (!auth.refreshToken)
|
|
233
183
|
return null;
|
|
234
184
|
const newToken = await refreshIdToken(auth.refreshToken);
|
|
235
185
|
return { ...auth, token: newToken };
|
|
236
186
|
}
|
|
237
|
-
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
238
|
-
// No cache — every bridge launch goes through the browser flow. This means
|
|
239
|
-
// permission revocations take effect on the next restart, and the PWA's
|
|
240
|
-
// CliAuthPage no-permission screen is what users see if access is denied.
|
|
241
187
|
export async function getValidAuth(gatewayUrl, pwaUrl) {
|
|
242
188
|
console.log('Sign-in required.');
|
|
243
189
|
return runBrowserAuthFlow(gatewayUrl, pwaUrl);
|