@abide/abide 0.113.0 → 0.114.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/CHANGELOG.md +6 -0
- package/docs/REGISTRY.md +3 -3
- package/docs/RULEBOOK.md +8 -0
- package/docs/abide.md +5 -3
- package/package.json +1 -1
- package/src/server/bridgeRelay.ts +93 -31
- package/src/server/claudeCode.ts +42 -9
- package/src/server/principal.ts +47 -8
- package/src/server/requestScope.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @abide/abide
|
|
2
2
|
|
|
3
|
+
## 0.114.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 595ccb1: a Claude Code pairing belongs to the sign-in that minted its code — another sign-in at the browser is refused 409 and offered its own, and an open bridge status follows a sign-in or sign-out ([`7036ed1`](https://github.com/briancray/abide/commit/7036ed181763c74eaf72ad4550e05c9d63a2d6ab))
|
|
8
|
+
|
|
3
9
|
## 0.113.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/docs/REGISTRY.md
CHANGED
|
@@ -338,10 +338,10 @@ type Gated<Gate, Accepted> = [Schema<Accepted>] extends [Gate] ? never : Failed<
|
|
|
338
338
|
|
|
339
339
|
| Name | Signature | Meaning | Rules |
|
|
340
340
|
| --- | --- | --- | --- |
|
|
341
|
-
| `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.27, 44.30 |
|
|
341
|
+
| `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | 44.5, 44.13, 44.17, 44.18, 44.19, 44.24, 44.25, 44.27, 44.30, 44.33 |
|
|
342
342
|
| `ClaudeCodeConfig` | `{ model?: string; fallbackModel?: string; systemPrompt?: string; appendSystemPrompt?: string; effort?: 'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max'; maxBudgetUsd?: number; betas?: string[]; disallowedTools?: string[]; tools?: string[] }` | The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. | 44.16, 44.29 |
|
|
343
|
-
| `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | 44.14, 44.32 |
|
|
344
|
-
| `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22, 44.32 |
|
|
343
|
+
| `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | 44.14, 44.32, 44.33 |
|
|
344
|
+
| `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | 44.20, 44.22, 44.32, 44.33, 44.34 |
|
|
345
345
|
| `BridgeStatus` | `{ paired: boolean; command: string \| undefined }` | Whether a bridge is paired, and the command that pairs one where none is. | 44.20 |
|
|
346
346
|
| `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | 36.7, 44.14, 44.15, 44.19, 44.23, 44.26, 16.57, 16.78 |
|
|
347
347
|
|
package/docs/RULEBOOK.md
CHANGED
|
@@ -3191,6 +3191,14 @@ and naming it `unfinished` where it ended without a `done`.
|
|
|
3191
3191
|
44.32 A command `claudeCode.bridgeCommand` or `claudeCode.bridge` mints with `tools` MUST grant
|
|
3192
3192
|
those built-in tools after its `--` as `--tools`, and one minted without `tools` MUST carry no `--`.
|
|
3193
3193
|
|
|
3194
|
+
44.33 A pairing MUST be to the claims the principal was sealed with (26.11), or to none, where its
|
|
3195
|
+
code was minted, and `claudeCode` and `claudeCode.bridge` MUST read it as absent to a request from
|
|
3196
|
+
that browser carrying any other.
|
|
3197
|
+
|
|
3198
|
+
44.34 A `claudeCode.bridge` stream MUST yield the pairing of whoever a `principal.set` or a
|
|
3199
|
+
`principal.clear` made at its browser since it opened signs in, at the caller 26.9 rotates it to,
|
|
3200
|
+
and the command it yields MUST pair that one.
|
|
3201
|
+
|
|
3194
3202
|
# 45. Offline
|
|
3195
3203
|
|
|
3196
3204
|
45.1 Where `ABIDE_OFFLINE` is on, a page `abide build` built MUST register the worker
|
package/docs/abide.md
CHANGED
|
@@ -5644,6 +5644,8 @@ Pass the same `tools` to `claudeCode.bridge` or `claudeCode.bridgeCommand` and t
|
|
|
5644
5644
|
|
|
5645
5645
|
The visitor pairs once per visit by running a command, which dials out to the site. Asking with nothing paired is refused 409.
|
|
5646
5646
|
|
|
5647
|
+
The tools the conversation calls run as the visitor, on the sign-in their ask carried, so a tool reads `principal` as any handler does. The pairing belongs to that sign-in too. If someone else signs in at the same browser, their asks are refused 409 and the status offers them a command of their own, so nobody's turn runs on another member's Claude Code.
|
|
5648
|
+
|
|
5647
5649
|
| Name | What it does for the CRM |
|
|
5648
5650
|
| --- | --- |
|
|
5649
5651
|
| `claudeCode.bridge` | Streams this browser's pairing: unpaired with the command to run, then paired. |
|
|
@@ -7860,9 +7862,9 @@ Generated surfaces: what is written in place rather than imported.
|
|
|
7860
7862
|
|
|
7861
7863
|
| Name | Signature | Meaning | Rules |
|
|
7862
7864
|
| --- | --- | --- | --- |
|
|
7863
|
-
| `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30) |
|
|
7864
|
-
| `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32) |
|
|
7865
|
-
| `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.32](/rulebook#44.32) |
|
|
7865
|
+
| `claudeCode` | `(config?: ClaudeCodeConfig) => Provider` | The visitor's own Claude Code, reached through the bridge paired with their browser, as a provider. | [44.5](/rulebook#44.5), [44.13](/rulebook#44.13), [44.17](/rulebook#44.17), [44.18](/rulebook#44.18), [44.19](/rulebook#44.19), [44.24](/rulebook#44.24), [44.25](/rulebook#44.25), [44.27](/rulebook#44.27), [44.30](/rulebook#44.30), [44.33](/rulebook#44.33) |
|
|
7866
|
+
| `claudeCode.bridgeCommand` | `(options?: { tools?: string[] }) => { command: string; expires: number }` | The command that pairs a bridge with this browser, granting the built-in tools it is given. | [44.14](/rulebook#44.14), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33) |
|
|
7867
|
+
| `claudeCode.bridge` | `(options?: { tools?: string[] }) => AsyncGenerator<BridgeStatus>` | This browser's pairing, as it changes. | [44.20](/rulebook#44.20), [44.22](/rulebook#44.22), [44.32](/rulebook#44.32), [44.33](/rulebook#44.33), [44.34](/rulebook#44.34) |
|
|
7866
7868
|
| `/__abide/bridge` | `GET`, `POST` | Where a bridge pairs, and is relayed its turns. | [36.7](/rulebook#36.7), [44.14](/rulebook#44.14), [44.15](/rulebook#44.15), [44.19](/rulebook#44.19), [44.23](/rulebook#44.23), [44.26](/rulebook#44.26), [16.57](/rulebook#16.57), [16.78](/rulebook#16.78) |
|
|
7867
7869
|
|
|
7868
7870
|
## Taught in
|
package/package.json
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// POST /__abide/bridge what the bridge reports, one frame per line: a turn's events, the
|
|
9
9
|
// MCP requests its Claude Code made, the turn's end or failure.
|
|
10
10
|
//
|
|
11
|
-
// A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) —
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — and to whoever was signed
|
|
12
|
+
// in there when its code was minted (44.33), for as long as the bridge's stream is open, or
|
|
13
|
+
// resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach the
|
|
14
|
+
// one its bridge dialed.
|
|
14
15
|
|
|
15
16
|
import { isFailed } from '#shared/Failed.ts'
|
|
16
17
|
import { refuse } from '#shared/refuse.ts'
|
|
@@ -35,7 +36,8 @@ export type TurnItem = {
|
|
|
35
36
|
|
|
36
37
|
/** One paired bridge: what it is sent, and the turns it is answering. */
|
|
37
38
|
export type Bridge = {
|
|
38
|
-
|
|
39
|
+
/** The browser and the sign-in it is paired to (`pairingKey`). 44.33. */
|
|
40
|
+
key: string
|
|
39
41
|
token: string
|
|
40
42
|
frames: Inbox<unknown>
|
|
41
43
|
turns: Map<string, Inbox<TurnItem>>
|
|
@@ -53,40 +55,68 @@ const IDLE_GRACE = 30_000
|
|
|
53
55
|
/** How long a pairing whose stream dropped waits to be resumed. 44.26. */
|
|
54
56
|
export const RESUME_MILLISECONDS = 60_000
|
|
55
57
|
|
|
56
|
-
const CODES = new Map<string, {
|
|
57
|
-
const
|
|
58
|
+
const CODES = new Map<string, { key: string; expires: number }>()
|
|
59
|
+
const BY_KEY = new Map<string, Bridge>()
|
|
58
60
|
const BY_TOKEN = new Map<string, Bridge>()
|
|
59
61
|
const WATCHERS = new Map<string, Set<() => void>>()
|
|
62
|
+
const FOLLOWERS = new Map<
|
|
63
|
+
string,
|
|
64
|
+
Set<(caller: string, signedIn: string) => void>
|
|
65
|
+
>()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What a pairing is filed under: the browser, and who was signed in there (`signedInAs`).
|
|
69
|
+
*
|
|
70
|
+
* 44.33 — ONE PER SIGN-IN, so pairing at a browser replaces only that sign-in's own bridge, and a
|
|
71
|
+
* page watching for one sign-in neither reaches another's nor keeps it from going idle (44.22).
|
|
72
|
+
*/
|
|
73
|
+
function pairingKey(caller: string, signedIn: string): string {
|
|
74
|
+
return `${caller}\n${signedIn}`
|
|
75
|
+
}
|
|
60
76
|
|
|
61
77
|
// 44.19 — the relay answers once the app has handed out a pairing command, and never before.
|
|
62
78
|
let minted = false
|
|
63
79
|
|
|
64
|
-
/** A single-use pairing code for `caller`, and when it stops standing. 44.14. */
|
|
65
|
-
export function mintCode(
|
|
80
|
+
/** A single-use pairing code for `caller` signed in as `signedIn`, and when it stops standing. 44.14. */
|
|
81
|
+
export function mintCode(
|
|
82
|
+
caller: string,
|
|
83
|
+
signedIn: string,
|
|
84
|
+
): { code: string; expires: number } {
|
|
66
85
|
const now = Date.now()
|
|
67
86
|
for (const [code, pairing] of CODES)
|
|
68
87
|
if (pairing.expires <= now) CODES.delete(code)
|
|
69
88
|
const code = randomToken(12, 'base64url')
|
|
70
89
|
const expires = now + CODE_LIFETIME
|
|
71
|
-
CODES.set(code, { caller, expires })
|
|
90
|
+
CODES.set(code, { key: pairingKey(caller, signedIn), expires })
|
|
72
91
|
minted = true
|
|
73
92
|
return { code, expires }
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
/** The bridge paired to `caller`, if its stream is open. */
|
|
77
|
-
export function pairedBridge(
|
|
78
|
-
|
|
95
|
+
/** The bridge paired to `caller` signed in as `signedIn`, if its stream is open. 44.33. */
|
|
96
|
+
export function pairedBridge(
|
|
97
|
+
caller: string,
|
|
98
|
+
signedIn: string,
|
|
99
|
+
): Bridge | undefined {
|
|
100
|
+
return BY_KEY.get(pairingKey(caller, signedIn))
|
|
79
101
|
}
|
|
80
102
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Registers `wake` to run whenever the pairing of `caller` signed in as `signedIn` changes; the
|
|
105
|
+
* returned function undoes it.
|
|
106
|
+
*/
|
|
107
|
+
export function watchPairing(
|
|
108
|
+
caller: string,
|
|
109
|
+
signedIn: string,
|
|
110
|
+
wake: () => void,
|
|
111
|
+
): () => void {
|
|
112
|
+
const key = pairingKey(caller, signedIn)
|
|
113
|
+
let watchers = WATCHERS.get(key)
|
|
84
114
|
if (watchers === undefined) {
|
|
85
115
|
watchers = new Set()
|
|
86
|
-
WATCHERS.set(
|
|
116
|
+
WATCHERS.set(key, watchers)
|
|
87
117
|
}
|
|
88
118
|
watchers.add(wake)
|
|
89
|
-
const bridge =
|
|
119
|
+
const bridge = BY_KEY.get(key)
|
|
90
120
|
if (bridge !== undefined) {
|
|
91
121
|
bridge.watched = true
|
|
92
122
|
if (bridge.idle !== undefined) clearTimeout(bridge.idle)
|
|
@@ -95,8 +125,8 @@ export function watchPairing(caller: string, wake: () => void): () => void {
|
|
|
95
125
|
return () => {
|
|
96
126
|
watchers.delete(wake)
|
|
97
127
|
if (watchers.size > 0) return
|
|
98
|
-
WATCHERS.delete(
|
|
99
|
-
armIdle(
|
|
128
|
+
WATCHERS.delete(key)
|
|
129
|
+
armIdle(key)
|
|
100
130
|
}
|
|
101
131
|
}
|
|
102
132
|
|
|
@@ -114,15 +144,15 @@ export function openTurn(bridge: Bridge): {
|
|
|
114
144
|
inbox,
|
|
115
145
|
close: () => {
|
|
116
146
|
bridge.turns.delete(id)
|
|
117
|
-
if (!WATCHERS.has(bridge.
|
|
147
|
+
if (!WATCHERS.has(bridge.key)) armIdle(bridge.key)
|
|
118
148
|
},
|
|
119
149
|
}
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
// 44.22 — A BRIDGE A PAGE WATCHED, THAT NO PAGE WATCHES NOW AND THAT IS ANSWERING NO TURN, is sent
|
|
123
153
|
// away once the grace runs.
|
|
124
|
-
function armIdle(
|
|
125
|
-
const bridge =
|
|
154
|
+
function armIdle(key: string): void {
|
|
155
|
+
const bridge = BY_KEY.get(key)
|
|
126
156
|
if (
|
|
127
157
|
bridge === undefined ||
|
|
128
158
|
!bridge.watched ||
|
|
@@ -136,8 +166,40 @@ function armIdle(caller: string): void {
|
|
|
136
166
|
}, IDLE_GRACE)
|
|
137
167
|
}
|
|
138
168
|
|
|
139
|
-
function wakeWatchers(
|
|
140
|
-
for (const wake of WATCHERS.get(
|
|
169
|
+
function wakeWatchers(key: string): void {
|
|
170
|
+
for (const wake of WATCHERS.get(key) ?? []) wake()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Registers `follow` to hear who signs in at `caller` from here on, and the caller a sign-out rotates
|
|
175
|
+
* it to (26.9); the returned function undoes it.
|
|
176
|
+
*
|
|
177
|
+
* 44.34 — A STATUS STREAM OPENED BEFORE A SIGN-IN carries the cookies it was opened with, so it hears
|
|
178
|
+
* of the new one here rather than off its own request.
|
|
179
|
+
*/
|
|
180
|
+
export function followSignIn(
|
|
181
|
+
caller: string,
|
|
182
|
+
follow: (caller: string, signedIn: string) => void,
|
|
183
|
+
): () => void {
|
|
184
|
+
let followers = FOLLOWERS.get(caller)
|
|
185
|
+
if (followers === undefined) {
|
|
186
|
+
followers = new Set()
|
|
187
|
+
FOLLOWERS.set(caller, followers)
|
|
188
|
+
}
|
|
189
|
+
followers.add(follow)
|
|
190
|
+
return () => {
|
|
191
|
+
followers.delete(follow)
|
|
192
|
+
if (followers.size === 0) FOLLOWERS.delete(caller)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Tells every status stream at `caller` that the browser is `now`, signed in as `signedIn`. 44.34. */
|
|
197
|
+
export function signedInAt(
|
|
198
|
+
caller: string,
|
|
199
|
+
now: string,
|
|
200
|
+
signedIn: string,
|
|
201
|
+
): void {
|
|
202
|
+
for (const follow of FOLLOWERS.get(caller) ?? []) follow(now, signedIn)
|
|
141
203
|
}
|
|
142
204
|
|
|
143
205
|
function item(
|
|
@@ -161,7 +223,7 @@ function item(
|
|
|
161
223
|
}
|
|
162
224
|
|
|
163
225
|
function dropBridge(bridge: Bridge): void {
|
|
164
|
-
if (
|
|
226
|
+
if (BY_KEY.get(bridge.key) === bridge) BY_KEY.delete(bridge.key)
|
|
165
227
|
BY_TOKEN.delete(bridge.token)
|
|
166
228
|
if (bridge.idle !== undefined) clearTimeout(bridge.idle)
|
|
167
229
|
bridge.idle = undefined
|
|
@@ -175,7 +237,7 @@ function dropBridge(bridge: Bridge): void {
|
|
|
175
237
|
}
|
|
176
238
|
bridge.turns.clear()
|
|
177
239
|
bridge.frames.close()
|
|
178
|
-
wakeWatchers(bridge.
|
|
240
|
+
wakeWatchers(bridge.key)
|
|
179
241
|
}
|
|
180
242
|
|
|
181
243
|
/** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 44.19. */
|
|
@@ -199,24 +261,24 @@ function pair(request: Request): Response {
|
|
|
199
261
|
'This pairing code was not issued here, or it has been used, or it has expired.',
|
|
200
262
|
),
|
|
201
263
|
)
|
|
202
|
-
const standing =
|
|
264
|
+
const standing = BY_KEY.get(pairing.key)
|
|
203
265
|
if (standing !== undefined) {
|
|
204
266
|
standing.frames.push({ type: 'bye' })
|
|
205
267
|
dropBridge(standing)
|
|
206
268
|
}
|
|
207
269
|
const bridge: Bridge = {
|
|
208
|
-
|
|
270
|
+
key: pairing.key,
|
|
209
271
|
token: randomToken(24, 'base64url'),
|
|
210
272
|
frames: new Inbox(),
|
|
211
273
|
turns: new Map(),
|
|
212
|
-
watched: WATCHERS.has(pairing.
|
|
274
|
+
watched: WATCHERS.has(pairing.key),
|
|
213
275
|
idle: undefined,
|
|
214
276
|
held: undefined,
|
|
215
277
|
}
|
|
216
|
-
|
|
278
|
+
BY_KEY.set(bridge.key, bridge)
|
|
217
279
|
BY_TOKEN.set(bridge.token, bridge)
|
|
218
280
|
bridge.frames.push({ type: 'paired', token: bridge.token })
|
|
219
|
-
wakeWatchers(bridge.
|
|
281
|
+
wakeWatchers(bridge.key)
|
|
220
282
|
return attach(bridge, request)
|
|
221
283
|
}
|
|
222
284
|
|
package/src/server/claudeCode.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './assistant.ts'
|
|
19
19
|
import { BRIDGE_VERSION } from './BRIDGE_VERSION.ts'
|
|
20
20
|
import {
|
|
21
|
+
followSignIn,
|
|
21
22
|
mintCode,
|
|
22
23
|
openTurn,
|
|
23
24
|
pairedBridge,
|
|
@@ -27,6 +28,7 @@ import { callerOf } from './callerOf.ts'
|
|
|
27
28
|
import { appOrigin } from './config.ts'
|
|
28
29
|
import { mcpFailure } from './generatedSurfaces.ts'
|
|
29
30
|
import { Inbox } from './Inbox.ts'
|
|
31
|
+
import { signedInAs } from './principal.ts'
|
|
30
32
|
import { request } from './requestScope.ts'
|
|
31
33
|
|
|
32
34
|
/** The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. */
|
|
@@ -52,12 +54,12 @@ export type BridgeStatus = { paired: boolean; command: string | undefined }
|
|
|
52
54
|
function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
|
|
53
55
|
return {
|
|
54
56
|
async *run(messages, tools, { maxTurns, signal }) {
|
|
55
|
-
const bridge = pairedBridge(askingCaller())
|
|
56
|
-
// 44.13 — nothing paired is the page's cue to show the command.
|
|
57
|
+
const bridge = pairedBridge(askingCaller(), signedInAs())
|
|
58
|
+
// 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
|
|
57
59
|
if (bridge === undefined)
|
|
58
60
|
throw refuse(
|
|
59
61
|
409,
|
|
60
|
-
'No Claude Code is paired with this browser. claudeCode.bridgeCommand() is the command that pairs one.',
|
|
62
|
+
'No Claude Code is paired with this browser as the one signed in. claudeCode.bridgeCommand() is the command that pairs one.',
|
|
61
63
|
)
|
|
62
64
|
const turn = openTurn(bridge)
|
|
63
65
|
bridge.frames.push({
|
|
@@ -130,11 +132,20 @@ function askingCaller(): string {
|
|
|
130
132
|
* The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
|
|
131
133
|
* `tools` it is given are written after its `--`, so the visitor running it grants them (44.29, 44.32).
|
|
132
134
|
*/
|
|
133
|
-
function bridgeCommand(
|
|
135
|
+
function bridgeCommand(options: PairingOptions = {}): {
|
|
134
136
|
command: string
|
|
135
137
|
expires: number
|
|
136
138
|
} {
|
|
137
|
-
|
|
139
|
+
return commandFor(askingCaller(), signedInAs(), options)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The command pairing a bridge with `caller` signed in as `signedIn` (44.33). */
|
|
143
|
+
function commandFor(
|
|
144
|
+
caller: string,
|
|
145
|
+
signedIn: string,
|
|
146
|
+
{ tools }: PairingOptions,
|
|
147
|
+
): { command: string; expires: number } {
|
|
148
|
+
const { code, expires } = mintCode(caller, signedIn)
|
|
138
149
|
const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
|
|
139
150
|
const grant = tools?.length ? ` -- --tools ${tools.join(',')}` : ''
|
|
140
151
|
return {
|
|
@@ -146,29 +157,50 @@ function bridgeCommand({ tools }: PairingOptions = {}): {
|
|
|
146
157
|
/**
|
|
147
158
|
* This browser's pairing as it changes: unpaired with the command to run, then paired the moment a
|
|
148
159
|
* bridge connects, and unpaired again if it goes. 44.20.
|
|
160
|
+
*
|
|
161
|
+
* 44.34 — IT FOLLOWS WHOEVER SIGNS IN AT THE BROWSER, though its own request carries the cookies it
|
|
162
|
+
* opened with: a sign-in or a sign-out made since is the pairing it watches from then on, and the
|
|
163
|
+
* command it offers is that sign-in's.
|
|
149
164
|
*/
|
|
150
165
|
async function* bridge(
|
|
151
166
|
options: PairingOptions = {},
|
|
152
167
|
): AsyncGenerator<BridgeStatus> {
|
|
153
|
-
|
|
168
|
+
let caller = askingCaller()
|
|
169
|
+
let signedIn = signedInAs()
|
|
154
170
|
const signal = request().signal
|
|
155
171
|
// A change landing while the loop was not waiting — paused at its `yield` — stays queued, so it
|
|
156
172
|
// looks again; a look that finds nothing new yields nothing.
|
|
157
173
|
const changes = new Inbox<undefined>()
|
|
158
174
|
const waiting = changes[Symbol.asyncIterator]()
|
|
159
175
|
const rouse = (): void => changes.push(undefined)
|
|
160
|
-
|
|
176
|
+
let watchingAs = ''
|
|
177
|
+
let unwatch = (): void => {}
|
|
178
|
+
let unfollow = (): void => {}
|
|
179
|
+
const follow = (now: string, next: string): void => {
|
|
180
|
+
caller = now
|
|
181
|
+
signedIn = next
|
|
182
|
+
rouse()
|
|
183
|
+
}
|
|
161
184
|
signal.addEventListener('abort', rouse)
|
|
162
185
|
let pairing: { command: string; expires: number } | undefined
|
|
163
186
|
let said = ''
|
|
164
187
|
try {
|
|
165
188
|
while (!signal.aborted) {
|
|
166
|
-
const
|
|
189
|
+
const watching = `${caller} ${signedIn}`
|
|
190
|
+
if (watching !== watchingAs) {
|
|
191
|
+
unwatch()
|
|
192
|
+
unfollow()
|
|
193
|
+
unwatch = watchPairing(caller, signedIn, rouse)
|
|
194
|
+
unfollow = followSignIn(caller, follow)
|
|
195
|
+
watchingAs = watching
|
|
196
|
+
pairing = undefined
|
|
197
|
+
}
|
|
198
|
+
const paired = pairedBridge(caller, signedIn) !== undefined
|
|
167
199
|
if (
|
|
168
200
|
!paired &&
|
|
169
201
|
(pairing === undefined || pairing.expires <= Date.now())
|
|
170
202
|
)
|
|
171
|
-
pairing =
|
|
203
|
+
pairing = commandFor(caller, signedIn, options)
|
|
172
204
|
const status: BridgeStatus = {
|
|
173
205
|
paired,
|
|
174
206
|
command: paired ? undefined : pairing?.command,
|
|
@@ -188,6 +220,7 @@ async function* bridge(
|
|
|
188
220
|
} finally {
|
|
189
221
|
signal.removeEventListener('abort', rouse)
|
|
190
222
|
unwatch()
|
|
223
|
+
unfollow()
|
|
191
224
|
}
|
|
192
225
|
}
|
|
193
226
|
|
package/src/server/principal.ts
CHANGED
|
@@ -18,10 +18,11 @@ import {
|
|
|
18
18
|
import { refuse } from '#shared/refuse.ts'
|
|
19
19
|
import { declare } from '#shared/rpc.ts'
|
|
20
20
|
import { mountedOwn } from './answerRpc.ts'
|
|
21
|
-
import {
|
|
21
|
+
import { signedInAt } from './bridgeRelay.ts'
|
|
22
|
+
import { callerOf, callerOrMint, mintCaller } from './callerOf.ts'
|
|
22
23
|
import { config, positiveSetting } from './config.ts'
|
|
23
24
|
import { HOOKS, inOrder } from './hooks.ts'
|
|
24
|
-
import { within } from './requestScope.ts'
|
|
25
|
+
import { type RequestScope, type Sealed, within } from './requestScope.ts'
|
|
25
26
|
|
|
26
27
|
// 26.18.
|
|
27
28
|
const PRINCIPAL_COOKIE = 'abide-principal'
|
|
@@ -84,9 +85,7 @@ function seal(
|
|
|
84
85
|
*
|
|
85
86
|
* 26.13 — A BAD SIGNATURE, A LAPSED SEAL AND A MALFORMED COOKIE ARE ONE ANSWER: nothing.
|
|
86
87
|
*/
|
|
87
|
-
function unseal(
|
|
88
|
-
sealed: string | null,
|
|
89
|
-
): { claims: Record<string, unknown>; expires: number } | undefined {
|
|
88
|
+
function unseal(sealed: string | null): Sealed | undefined {
|
|
90
89
|
if (sealed === null) return undefined
|
|
91
90
|
const [header, payload, signed, ...rest] = sealed.split('.')
|
|
92
91
|
// ONE HEADER IS READ, abide's own: a token naming another `alg` — `none` above all — is no seal.
|
|
@@ -182,8 +181,8 @@ async function documentOf(
|
|
|
182
181
|
function resolvePrincipal(): Promise<Principal> {
|
|
183
182
|
const scope = within('principal')
|
|
184
183
|
scope.principal ??= (async () => {
|
|
185
|
-
const opened =
|
|
186
|
-
if (opened ===
|
|
184
|
+
const opened = openedSeal(scope)
|
|
185
|
+
if (opened === null) return ANONYMOUS
|
|
187
186
|
let expires = opened.expires
|
|
188
187
|
const life = ttl()
|
|
189
188
|
if (expires - Date.now() < life / 2 && scope.applied === false) {
|
|
@@ -206,7 +205,38 @@ export function presumePrincipal(claims: Record<string, unknown>): void {
|
|
|
206
205
|
throw new Error(
|
|
207
206
|
'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
|
|
208
207
|
)
|
|
209
|
-
|
|
208
|
+
const expires = Date.now() + ttl()
|
|
209
|
+
scope.principal = documentOf(claims, expires)
|
|
210
|
+
scope.opened = { claims: asSealed(claims), expires }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What this request's principal cookie opened to, read and verified once a request. */
|
|
214
|
+
function openedSeal(scope: RequestScope): Sealed | null {
|
|
215
|
+
if (scope.opened === undefined)
|
|
216
|
+
scope.opened = unseal(cookies().get(PRINCIPAL_COOKIE)) ?? null
|
|
217
|
+
return scope.opened
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `claims` as a later request opens them from their seal: through JSON, and `exp` and `iat` aside. */
|
|
221
|
+
function asSealed(claims: Record<string, unknown>): Record<string, unknown> {
|
|
222
|
+
const { exp: _, iat: __, ...sealed } = JSON.parse(JSON.stringify(claims))
|
|
223
|
+
return sealed
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Who `opened` signs in, as one string: its claims as JSON, or `null`'s. 44.33. */
|
|
227
|
+
function identityOf(opened: Sealed | null): string {
|
|
228
|
+
return JSON.stringify(opened === null ? null : opened.claims)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Who this request is signed in as, as one string.
|
|
233
|
+
*
|
|
234
|
+
* 44.33 — THE CLAIMS, NOT THE RESOLVED DOCUMENT: what `onPrincipal` reads off them can change
|
|
235
|
+
* between requests, and a seal sealed again for another life (26.15) holds the same claims. Read
|
|
236
|
+
* synchronously, off the cookie, so the pairing command stays a plain call.
|
|
237
|
+
*/
|
|
238
|
+
export function signedInAs(): string {
|
|
239
|
+
return identityOf(openedSeal(within('claudeCode')))
|
|
210
240
|
}
|
|
211
241
|
|
|
212
242
|
/**
|
|
@@ -233,6 +263,11 @@ async function set(claims: Record<string, unknown>): Promise<void> {
|
|
|
233
263
|
scope.response.headers.set(PRINCIPAL_HEADER, 'set')
|
|
234
264
|
const resolved = documentOf(claims, expires)
|
|
235
265
|
scope.principal = resolved
|
|
266
|
+
scope.opened = { claims: asSealed(claims), expires }
|
|
267
|
+
// 44.34 — a status stream opened before this sign-in follows it.
|
|
268
|
+
const caller = callerOf()
|
|
269
|
+
if (caller !== undefined)
|
|
270
|
+
signedInAt(caller, caller, identityOf(scope.opened))
|
|
236
271
|
principalMembers().document.set(await resolved)
|
|
237
272
|
PRINCIPAL_LOG.debug('set')
|
|
238
273
|
}
|
|
@@ -247,8 +282,12 @@ function clear(): void {
|
|
|
247
282
|
const scope = within('principal.clear')
|
|
248
283
|
cookies().delete(PRINCIPAL_COOKIE)
|
|
249
284
|
scope.response.headers.set(PRINCIPAL_HEADER, 'clear')
|
|
285
|
+
const signedOut = callerOf()
|
|
250
286
|
const minted = mintCaller(scope)
|
|
251
287
|
scope.principal = Promise.resolve(ANONYMOUS)
|
|
288
|
+
scope.opened = null
|
|
289
|
+
// 44.34 — and one opened before this sign-out follows it to the caller it rotated to.
|
|
290
|
+
if (signedOut !== undefined) signedInAt(signedOut, minted, identityOf(null))
|
|
252
291
|
principalMembers().document.set(ANONYMOUS)
|
|
253
292
|
principalMembers().caller.set(minted)
|
|
254
293
|
PRINCIPAL_LOG.debug('clear')
|
|
@@ -22,6 +22,9 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
|
22
22
|
/** The three framings a stream is answered in. 17.6. */
|
|
23
23
|
export type Framing = Framed | 'bytes'
|
|
24
24
|
|
|
25
|
+
/** A principal cookie's claims, `exp` and `iat` aside, and when it lapses. 26.11. */
|
|
26
|
+
export type Sealed = { claims: Record<string, unknown>; expires: number }
|
|
27
|
+
|
|
25
28
|
/** What one request, or one `App.run`, carries across every `await` its work makes. */
|
|
26
29
|
export type RequestScope = Scope & {
|
|
27
30
|
/**
|
|
@@ -38,6 +41,8 @@ export type RequestScope = Scope & {
|
|
|
38
41
|
caller: string | undefined
|
|
39
42
|
/** This request's one resolve of the principal. 26.14. */
|
|
40
43
|
principal: Promise<Principal> | undefined
|
|
44
|
+
/** What the principal cookie opened to once read, or `null` where it held no seal. */
|
|
45
|
+
opened: Sealed | null | undefined
|
|
41
46
|
/** 16.23 — whether the received request's idle timeout is off until the answer is sent. */
|
|
42
47
|
unreaped: boolean
|
|
43
48
|
/** This response's nonce, minted by the `csp` rung or on the first ask. 29.3. */
|
|
@@ -168,6 +173,7 @@ export function scopeFor(
|
|
|
168
173
|
unreaped: false,
|
|
169
174
|
caller: undefined,
|
|
170
175
|
principal: undefined,
|
|
176
|
+
opened: undefined,
|
|
171
177
|
applied: false,
|
|
172
178
|
request: given,
|
|
173
179
|
route: undefined,
|