@abide/abide 0.112.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 +20 -0
- package/docs/REGISTRY.md +10 -10
- package/docs/RULEBOOK.md +30 -3
- package/docs/abide.md +52 -44
- package/package.json +1 -1
- package/src/compiler/appLayout.ts +16 -1
- package/src/compiler/classifyTemplate.ts +5 -5
- package/src/compiler/emitClient.ts +45 -2
- package/src/server/bridgeRelay.ts +93 -31
- package/src/server/claudeCode.ts +42 -9
- package/src/server/commands.ts +2 -1
- package/src/server/config.ts +13 -1
- package/src/server/createApp.ts +18 -6
- package/src/server/preload.ts +5 -0
- package/src/server/principal.ts +60 -7
- package/src/server/requestScope.ts +6 -0
- package/src/shared/FOREIGN_ATTRIBUTES.ts +24 -0
- package/src/shared/INTEGRATION_POINTS.ts +20 -0
- package/src/shared/IN_PROCESS_ORIGIN.ts +5 -0
- package/src/shared/reactive/Keyed.ts +41 -14
- package/src/shared/reactive/Reactive.ts +34 -2
- package/src/shared/rpc.ts +2 -1
- package/src/ui/bindSpread.ts +4 -2
- package/src/ui/index.ts +2 -2
- package/src/ui/parseMarkup.ts +20 -25
- package/src/ui/setAttribute.ts +25 -1
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
} from '#shared/ANCHORS.ts'
|
|
8
8
|
import { anchorMarkup } from '#shared/anchorMarkup.ts'
|
|
9
9
|
import { CHILDREN } from '#shared/CHILDREN.ts'
|
|
10
|
+
import { FOREIGN_ATTRIBUTES } from '#shared/FOREIGN_ATTRIBUTES.ts'
|
|
11
|
+
import {
|
|
12
|
+
HTML_NAMESPACE,
|
|
13
|
+
INTEGRATION_POINTS,
|
|
14
|
+
} from '#shared/INTEGRATION_POINTS.ts'
|
|
10
15
|
import { RUNTIME_SPECIFIER, SHARED_SPECIFIER } from '#shared/SPECIFIERS.ts'
|
|
11
16
|
import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
|
|
12
17
|
import {
|
|
@@ -880,6 +885,9 @@ const SHARE_AT = '__shareAt'
|
|
|
880
885
|
const PARENT = '__parent'
|
|
881
886
|
// The document a built region makes its nodes with, read off `PARENT` once a build.
|
|
882
887
|
const DOCUMENT = '__document'
|
|
888
|
+
// The namespace a built region's top-level elements take, read off `PARENT` once a build.
|
|
889
|
+
const SPACE = '__space'
|
|
890
|
+
const HTML_SPACE = JSON.stringify(HTML_NAMESPACE)
|
|
883
891
|
|
|
884
892
|
// A run's anchors while it holds markup (32.30), as the JS string literals a built run parses with.
|
|
885
893
|
const RAW_OPEN_MARKUP = JSON.stringify(anchorMarkup(RAW_OPEN))
|
|
@@ -942,17 +950,29 @@ function emitMaker(
|
|
|
942
950
|
*
|
|
943
951
|
* A NESTED REGION IS AN EMPTY ANCHOR PAIR, filled by its own machinery on the mount's first run
|
|
944
952
|
* (`fillEmpty`), and a component or a slot is the same pair filled by the child.
|
|
953
|
+
*
|
|
954
|
+
* 32.70 — AN ELEMENT IS BUILT IN THE NAMESPACE THE PARSER WOULD GIVE IT, read off the parent it goes
|
|
955
|
+
* into (`namespaceUnder`) rather than decided here: a component's root does not know it is invoked
|
|
956
|
+
* inside an `<svg>`, and a `<g>` made with `createElement` is an HTML element that draws nothing.
|
|
957
|
+
* Read once a build, and again only below an `<annotation-xml>`, which reads its children as HTML by
|
|
958
|
+
* the encoding it declares; below any other integration point (`INTEGRATION_POINTS`) a child is
|
|
959
|
+
* HTML's, and everywhere else it takes its parent's. `createElementNS` with
|
|
960
|
+
* HTML's namespace costs what `createElement` does (Chromium, 2026-10-22), and a build that makes
|
|
961
|
+
* no top-level element reads none.
|
|
945
962
|
*/
|
|
946
963
|
function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
|
|
947
964
|
const lines: string[] = [
|
|
948
965
|
`const ${DOCUMENT} = ${PARENT}.ownerDocument`,
|
|
949
966
|
'const __built = []',
|
|
950
967
|
]
|
|
968
|
+
let readsSpace = false
|
|
951
969
|
if (anchored)
|
|
952
970
|
lines.push(
|
|
953
971
|
`__built.push(${DOCUMENT}.createComment(${JSON.stringify(ROW_OPEN)}))`,
|
|
954
972
|
)
|
|
955
973
|
const render = buildRender(lowering)
|
|
974
|
+
// The expression each built element's namespace was, for its children to take.
|
|
975
|
+
const spaceOf = new Map<PlacedNode, string>()
|
|
956
976
|
const into = (at: PlacedNode, node: string): string =>
|
|
957
977
|
at.within === null
|
|
958
978
|
? `__built.push(${node})`
|
|
@@ -975,8 +995,19 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
|
|
|
975
995
|
for (const line of builtRun(at, lowering, into)) lines.push(line)
|
|
976
996
|
continue
|
|
977
997
|
}
|
|
998
|
+
const within = at.within
|
|
999
|
+
const space =
|
|
1000
|
+
within === null
|
|
1001
|
+
? SPACE
|
|
1002
|
+
: INTEGRATION_POINTS.has(within.tag)
|
|
1003
|
+
? HTML_SPACE
|
|
1004
|
+
: within.tag === 'annotation-xml'
|
|
1005
|
+
? `${lowering.use('namespaceUnder')}(${within.name})`
|
|
1006
|
+
: (spaceOf.get(within) ?? SPACE)
|
|
1007
|
+
if (space === SPACE) readsSpace = true
|
|
1008
|
+
spaceOf.set(at, space)
|
|
978
1009
|
lines.push(
|
|
979
|
-
`const ${at.name} = ${DOCUMENT}.
|
|
1010
|
+
`const ${at.name} = ${DOCUMENT}.createElementNS(${space}, ${JSON.stringify(at.tag)})`,
|
|
980
1011
|
)
|
|
981
1012
|
for (const one of classifyAttributes(
|
|
982
1013
|
at.holder as ElementNode,
|
|
@@ -984,7 +1015,13 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
|
|
|
984
1015
|
)) {
|
|
985
1016
|
const value = builtAttribute(one, lowering, render)
|
|
986
1017
|
if (value === null) continue
|
|
987
|
-
const
|
|
1018
|
+
const name =
|
|
1019
|
+
one.kind === 'merged' ? one.group?.name : one.attribute.name
|
|
1020
|
+
// 32.70 — one the parser would put in a namespace is written in it (`addAttribute`).
|
|
1021
|
+
const set =
|
|
1022
|
+
name !== undefined && FOREIGN_ATTRIBUTES.has(name)
|
|
1023
|
+
? `${lowering.use('addAttribute')}(${at.name}, ${JSON.stringify(name)}, `
|
|
1024
|
+
: `${at.name}.setAttribute(${JSON.stringify(name)}, `
|
|
988
1025
|
// 32.37 — a whole-value attribute may be absent, which the server wrote as nothing.
|
|
989
1026
|
lines.push(
|
|
990
1027
|
one.kind === 'dynamic'
|
|
@@ -994,6 +1031,12 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
|
|
|
994
1031
|
}
|
|
995
1032
|
lines.push(into(at, at.name))
|
|
996
1033
|
}
|
|
1034
|
+
if (readsSpace)
|
|
1035
|
+
lines.splice(
|
|
1036
|
+
1,
|
|
1037
|
+
0,
|
|
1038
|
+
`const ${SPACE} = ${lowering.use('namespaceUnder')}(${PARENT})`,
|
|
1039
|
+
)
|
|
997
1040
|
lines.push('return __built')
|
|
998
1041
|
return lines.join('\n ')
|
|
999
1042
|
}
|
|
@@ -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/commands.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
7
7
|
import { parseArgs } from 'node:util' // bun exposes no argv parser of its own yet
|
|
8
8
|
import { MCP_ADDRESS, OPENAPI_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
9
|
+
import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
|
|
9
10
|
import { banner } from './banner.ts'
|
|
10
11
|
import { type App, type AppHandle, enterApp } from './createApp.ts'
|
|
11
12
|
import { stopOnSignalOrFault } from './faultPolicy.ts'
|
|
@@ -494,7 +495,7 @@ async function surfaceAt(
|
|
|
494
495
|
const booted = await booting(host, app, given)
|
|
495
496
|
if (typeof booted === 'number') return booted
|
|
496
497
|
return (init) =>
|
|
497
|
-
booted.fetch(new Request(
|
|
498
|
+
booted.fetch(new Request(`${IN_PROCESS_ORIGIN}${address}`, init))
|
|
498
499
|
}
|
|
499
500
|
|
|
500
501
|
/**
|
package/src/server/config.ts
CHANGED
|
@@ -49,6 +49,16 @@ export interface Config {
|
|
|
49
49
|
APP_DATA_DIR: string
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// What a preload derives beside 27.3's three — the test preload's principal secret (38.120). Declared
|
|
53
|
+
// above `config`, which resolves as it is built — and so is invalidated by an addition.
|
|
54
|
+
const ADDED: Record<string, string> = {}
|
|
55
|
+
|
|
56
|
+
/** Adds `values` to the derived layer, so every `onConfig` default and the env still win (27.2). */
|
|
57
|
+
export function addDerived(values: Record<string, string>): void {
|
|
58
|
+
Object.assign(ADDED, values)
|
|
59
|
+
config.invalidate()
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/** The resolved configuration: abide's derived values, then `onConfig`'s defaults, then the env. */
|
|
53
63
|
export const config = reactive(resolve, { global: true })
|
|
54
64
|
|
|
@@ -100,7 +110,8 @@ function coerce(text: string, standing: unknown): unknown {
|
|
|
100
110
|
}
|
|
101
111
|
|
|
102
112
|
/**
|
|
103
|
-
* The app's name, version and data directory, off its own `package.json` and the platform
|
|
113
|
+
* The app's name, version and data directory, off its own `package.json` and the platform, and
|
|
114
|
+
* what `addDerived` added.
|
|
104
115
|
*
|
|
105
116
|
* 27.3 — a layer under the other two, so a config or an environment that carries one wins.
|
|
106
117
|
*/
|
|
@@ -108,6 +119,7 @@ function derived(): Record<string, string> {
|
|
|
108
119
|
const manifest = APP_PACKAGE.current
|
|
109
120
|
const name = manifest.name ?? 'app'
|
|
110
121
|
return {
|
|
122
|
+
...ADDED,
|
|
111
123
|
APP_NAME: name,
|
|
112
124
|
APP_VERSION: manifest.version ?? '0.0.0',
|
|
113
125
|
// 36.4 — A PATH, and never created: an app that wants the directory makes it.
|
package/src/server/createApp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' // bun exposes no sync existence check of i
|
|
|
7
7
|
import { resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
8
8
|
import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
|
|
9
9
|
import { framingOf } from '#shared/framing.ts'
|
|
10
|
+
import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
|
|
10
11
|
import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
|
|
11
12
|
import { namedLogger } from '#shared/log.ts'
|
|
12
13
|
import { compose } from '#shared/Middleware.ts'
|
|
@@ -32,6 +33,7 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
|
|
|
32
33
|
import { installServer } from './installServer.ts'
|
|
33
34
|
import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
|
|
34
35
|
import { plainAnswer } from './plainAnswer.ts'
|
|
36
|
+
import { presumePrincipal } from './principal.ts'
|
|
35
37
|
import { realOrAsGiven } from './realOrAsGiven.ts'
|
|
36
38
|
import {
|
|
37
39
|
type AnsweringApp,
|
|
@@ -60,7 +62,7 @@ export interface App {
|
|
|
60
62
|
fetch(request: Request): Promise<Response>
|
|
61
63
|
run<Result>(
|
|
62
64
|
fn: () => Result | Promise<Result>,
|
|
63
|
-
options?: { request?: Request },
|
|
65
|
+
options?: { request?: Request; principal?: Record<string, unknown> },
|
|
64
66
|
): Promise<Result>
|
|
65
67
|
listen(options?: {
|
|
66
68
|
port?: number
|
|
@@ -289,11 +291,21 @@ export async function bootEntered(
|
|
|
289
291
|
let stopping = false
|
|
290
292
|
let bound: Bun.Server<unknown> | undefined
|
|
291
293
|
|
|
292
|
-
const run = async
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
const run: App['run'] = async (fn, options) => {
|
|
295
|
+
const claims = options?.principal
|
|
296
|
+
// 42.19 — a principal is a request's, so one given alone is given a request to be.
|
|
297
|
+
const request =
|
|
298
|
+
options?.request ??
|
|
299
|
+
(claims === undefined ? undefined : new Request(IN_PROCESS_ORIGIN))
|
|
300
|
+
return inReleasedScope(
|
|
301
|
+
request,
|
|
302
|
+
async () => {
|
|
303
|
+
if (claims !== undefined) presumePrincipal(claims)
|
|
304
|
+
return await fn()
|
|
305
|
+
},
|
|
306
|
+
answering,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
297
309
|
|
|
298
310
|
const fetch = async (given: Request): Promise<Response> => {
|
|
299
311
|
if (stopping) return plainAnswer('the app is stopping', 503)
|
package/src/server/preload.ts
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
// one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
|
|
6
6
|
// boots the app afterwards reuses it rather than building twice.
|
|
7
7
|
|
|
8
|
+
import { addDerived } from './config.ts'
|
|
8
9
|
import { appRoot, servedBuild } from './createApp.ts'
|
|
9
10
|
|
|
11
|
+
// 38.120 — a sign-in seals under test where the app declared no secret: derived, so the app's
|
|
12
|
+
// `onConfig` default and the env both still win (27.2), where an env value would beat the default.
|
|
13
|
+
addDerived({ ABIDE_PRINCIPAL_SECRET: 'abide-test-principal-secret' })
|
|
14
|
+
|
|
10
15
|
const root = appRoot(undefined)
|
|
11
16
|
// A run from a directory that holds no app preloads nothing — nor from a workspace's root, whose
|
|
12
17
|
// packages' `.abide` files are each their own app's rather than one app laid out across them.
|
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) {
|
|
@@ -195,6 +194,51 @@ function resolvePrincipal(): Promise<Principal> {
|
|
|
195
194
|
return scope.principal
|
|
196
195
|
}
|
|
197
196
|
|
|
197
|
+
/**
|
|
198
|
+
* This request's principal, resolved from `claims` as a sealed cookie's are, with no cookie written.
|
|
199
|
+
*
|
|
200
|
+
* 42.19 — `App.run`'s `principal`, which is why it throws where `principal.set` does.
|
|
201
|
+
*/
|
|
202
|
+
export function presumePrincipal(claims: Record<string, unknown>): void {
|
|
203
|
+
const scope = within('App.run principal')
|
|
204
|
+
if (!isRecord(claims))
|
|
205
|
+
throw new Error(
|
|
206
|
+
'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
|
|
207
|
+
)
|
|
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')))
|
|
240
|
+
}
|
|
241
|
+
|
|
198
242
|
/**
|
|
199
243
|
* Signs this caller in with `claims`, sealing them into the cookie.
|
|
200
244
|
*
|
|
@@ -219,6 +263,11 @@ async function set(claims: Record<string, unknown>): Promise<void> {
|
|
|
219
263
|
scope.response.headers.set(PRINCIPAL_HEADER, 'set')
|
|
220
264
|
const resolved = documentOf(claims, expires)
|
|
221
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))
|
|
222
271
|
principalMembers().document.set(await resolved)
|
|
223
272
|
PRINCIPAL_LOG.debug('set')
|
|
224
273
|
}
|
|
@@ -233,8 +282,12 @@ function clear(): void {
|
|
|
233
282
|
const scope = within('principal.clear')
|
|
234
283
|
cookies().delete(PRINCIPAL_COOKIE)
|
|
235
284
|
scope.response.headers.set(PRINCIPAL_HEADER, 'clear')
|
|
285
|
+
const signedOut = callerOf()
|
|
236
286
|
const minted = mintCaller(scope)
|
|
237
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))
|
|
238
291
|
principalMembers().document.set(ANONYMOUS)
|
|
239
292
|
principalMembers().caller.set(minted)
|
|
240
293
|
PRINCIPAL_LOG.debug('clear')
|