canopy-client 0.1.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/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "canopy-client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Framework-free TypeScript client for canopy-web: delegated-token cache, REST, and the session WebSocket.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dimagi-internal/canopy-web.git",
9
+ "directory": "frontend/packages/canopy-client"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./bridge": "./src/bridge.ts"
14
+ },
15
+ "publishConfig": {
16
+ "registry": "https://registry.npmjs.org",
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "src"
21
+ ]
22
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The host bridge contract: how a product lends an agent its page.
3
+ *
4
+ * Defined here, at the framework-free layer, because the v2 spec (§8) requires
5
+ * ONE definition with two transports. A React host mounting `canopy-ui/chat`
6
+ * calls these functions directly; the iframe widget reaches the identical
7
+ * functions over `postMessage`. If the contract lived in the widget, a native
8
+ * host would have to reimplement it and the two would drift — which is the
9
+ * mistake `RunnerAssignment` and `runner_tenant_slugs` were both cleanups of.
10
+ *
11
+ * **Why a snapshot and not a live feed.** `provideContext` is pulled when a
12
+ * session opens, not subscribed to. A continuously-synced readable channel
13
+ * (CopilotKit's `useCopilotReadable` is the reference shape) is real future
14
+ * work; it is not needed to prove that an agent can reason about the page, and
15
+ * every tick of it is a message the host has to be trusted to get right.
16
+ *
17
+ * **What the ACL story is.** The host resolves context and runs actions *in the
18
+ * user's own session*, so an agent sees and does exactly what that user could —
19
+ * never more. That is why v1 takes this route rather than giving the agent its
20
+ * own credential for the host (v2 spec §8, option (b)): there is nothing here
21
+ * that can exceed the user, so there is nothing to leak.
22
+ *
23
+ * **The limit that follows.** Both halves need the page to be open. Close the
24
+ * tab and the agent cannot read or act; there is no server-side path. Action is
25
+ * scoped to the life of the visit, and a host should not describe it otherwise.
26
+ */
27
+
28
+ /** Arbitrary JSON the host chooses to expose. Deliberately opaque: canopy never
29
+ * interprets it, the same way `Session.metadata` is an opaque bag. */
30
+ export type HostContext = Record<string, unknown>
31
+
32
+ /** Pulled when a session opens. Synchronous or not — a host may need to read
33
+ * something async, and the caller always awaits. */
34
+ export type ContextProvider = () => HostContext | Promise<HostContext>
35
+
36
+ /**
37
+ * One thing an agent may ask the page to do.
38
+ *
39
+ * Returning a value is how the agent learns whether it worked; THROWING is how
40
+ * a host refuses. A refusal must be an error and not a `false`, because a
41
+ * silently-ignored action is indistinguishable to the agent from a successful
42
+ * one, and it will carry on as though the change landed.
43
+ */
44
+ export type HostAction = (args: Record<string, unknown>) => unknown | Promise<unknown>
45
+
46
+ export interface HostBridge {
47
+ /** Register what the agent may read. Replaces any previous provider — a page
48
+ * has one current state, not an accumulating list of them. */
49
+ provideContext(provider: ContextProvider): void
50
+ /** Register one named action. Re-registering a name replaces it, so a
51
+ * re-rendering host component can call this freely without stacking
52
+ * handlers. */
53
+ registerAction(name: string, action: HostAction): void
54
+ /** Stop offering an action — e.g. the user navigated away from the thing it
55
+ * acted on, and running it now would mutate something off-screen. */
56
+ unregisterAction(name: string): void
57
+ /** Read the current snapshot. `{}` when the host registered no provider,
58
+ * rather than throwing: a host that lends no context is a legitimate host. */
59
+ readContext(): Promise<HostContext>
60
+ /** Names currently offered, so the agent can be told what it may call. */
61
+ actionNames(): string[]
62
+ /** Run one. Rejects with `UnknownActionError` if it was never registered or
63
+ * has since been withdrawn — never a silent no-op. */
64
+ runAction(name: string, args?: Record<string, unknown>): Promise<unknown>
65
+ }
66
+
67
+ export class UnknownActionError extends Error {
68
+ // See CanopyRestError in rest.ts: `erasableSyntaxOnly` forbids constructor
69
+ // parameter properties.
70
+ readonly actionName: string
71
+
72
+ constructor(name: string) {
73
+ super(
74
+ `no host action named ${JSON.stringify(name)} is registered. ` +
75
+ 'It may have been withdrawn because the page moved on.',
76
+ )
77
+ // `actionName`, not `name`: Error already defines `name` (it is the class
78
+ // name used when stringifying an error), so the original field was
79
+ // shadowing it and would have made this print as `no host action named…`
80
+ // instead of `UnknownActionError`.
81
+ this.actionName = name
82
+ }
83
+ }
84
+
85
+ export function createHostBridge(): HostBridge {
86
+ let provider: ContextProvider | null = null
87
+ const actions = new Map<string, HostAction>()
88
+
89
+ return {
90
+ provideContext(next) {
91
+ provider = next
92
+ },
93
+ registerAction(name, action) {
94
+ actions.set(name, action)
95
+ },
96
+ unregisterAction(name) {
97
+ actions.delete(name)
98
+ },
99
+ async readContext() {
100
+ if (!provider) return {}
101
+ return provider()
102
+ },
103
+ actionNames() {
104
+ // Sorted so a host that registers in a different order across renders
105
+ // does not produce a different tool list for the agent each time.
106
+ return [...actions.keys()].sort()
107
+ },
108
+ async runAction(name, args = {}) {
109
+ const action = actions.get(name)
110
+ if (!action) throw new UnknownActionError(name)
111
+ return action(args)
112
+ },
113
+ }
114
+ }
@@ -0,0 +1,317 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import {
4
+ CanopyRestError,
5
+ buildSessionWsUrl,
6
+ createCanopyClient,
7
+ createHostBridge,
8
+ createTokenStore,
9
+ UnknownActionError,
10
+ } from './index'
11
+
12
+ const TOKEN_TTL = () => new Date(Date.now() + 60 * 60 * 1000).toISOString()
13
+
14
+ describe('token store', () => {
15
+ it('caches, so N callers in a tick do not mint N tokens server-side', async () => {
16
+ const fetchToken = vi.fn().mockResolvedValue({ token: 't1', expiresAt: TOKEN_TTL() })
17
+ const store = createTokenStore(fetchToken)
18
+
19
+ const all = await Promise.all([store.get(), store.get(), store.get()])
20
+
21
+ expect(all).toEqual(['t1', 't1', 't1'])
22
+ expect(fetchToken).toHaveBeenCalledTimes(1)
23
+ })
24
+
25
+ it('force bypasses the cache — the 401 path must not trust our own bookkeeping', async () => {
26
+ const fetchToken = vi
27
+ .fn()
28
+ .mockResolvedValueOnce({ token: 'stale', expiresAt: TOKEN_TTL() })
29
+ .mockResolvedValueOnce({ token: 'fresh', expiresAt: TOKEN_TTL() })
30
+ const store = createTokenStore(fetchToken)
31
+
32
+ expect(await store.get()).toBe('stale')
33
+ expect(await store.get(true)).toBe('fresh')
34
+ expect(fetchToken).toHaveBeenCalledTimes(2)
35
+ })
36
+
37
+ it('refetches a token already inside the refresh skew', async () => {
38
+ // 2 minutes out, against a 5-minute skew: treated as due, not valid.
39
+ const soon = new Date(Date.now() + 2 * 60 * 1000).toISOString()
40
+ const fetchToken = vi.fn().mockResolvedValue({ token: 't', expiresAt: soon })
41
+ const store = createTokenStore(fetchToken)
42
+
43
+ await store.get()
44
+ await store.get()
45
+
46
+ expect(fetchToken).toHaveBeenCalledTimes(2)
47
+ })
48
+
49
+ it('treats an unparseable expiry as already expired rather than caching NaN', async () => {
50
+ const fetchToken = vi.fn().mockResolvedValue({ token: 't', expiresAt: 'not a date' })
51
+ const store = createTokenStore(fetchToken)
52
+
53
+ await store.get()
54
+ await store.get()
55
+
56
+ expect(fetchToken).toHaveBeenCalledTimes(2)
57
+ })
58
+
59
+ it('does not poison later calls with a rejected in-flight promise', async () => {
60
+ const fetchToken = vi
61
+ .fn()
62
+ .mockRejectedValueOnce(new Error('network blip'))
63
+ .mockResolvedValueOnce({ token: 'recovered', expiresAt: TOKEN_TTL() })
64
+ const store = createTokenStore(fetchToken)
65
+
66
+ await expect(store.get()).rejects.toThrow('network blip')
67
+ expect(await store.get()).toBe('recovered')
68
+ })
69
+
70
+ it('keeps two stores independent — a page may mount two widgets', async () => {
71
+ // ace-web held this state at module scope, which is fine for exactly one
72
+ // client per page and wrong for two panels or two agents.
73
+ const a = createTokenStore(vi.fn().mockResolvedValue({ token: 'a', expiresAt: TOKEN_TTL() }))
74
+ const b = createTokenStore(vi.fn().mockResolvedValue({ token: 'b', expiresAt: TOKEN_TTL() }))
75
+
76
+ expect(await a.get()).toBe('a')
77
+ expect(await b.get()).toBe('b')
78
+ expect(a.peek()).toBe('a')
79
+ })
80
+
81
+ it('peek is null before the first mint', () => {
82
+ const store = createTokenStore(vi.fn())
83
+ expect(store.peek()).toBeNull()
84
+ })
85
+ })
86
+
87
+ describe('session websocket url', () => {
88
+ it('borrows scheme and host when the base is a bare path', () => {
89
+ const url = buildSessionWsUrl('/canopy', 'sess-1', 'tok', {
90
+ protocol: 'https:',
91
+ host: 'labs.connect.dimagi.com',
92
+ })
93
+ expect(url).toBe('wss://labs.connect.dimagi.com/canopy/ws/canopy-sessions/sess-1/?token=tok')
94
+ })
95
+
96
+ it('keeps its own host when the base is absolute — the widget case', () => {
97
+ const url = buildSessionWsUrl('https://canopy.example.com/canopy', 'sess-1', 'tok')
98
+ expect(url).toBe('wss://canopy.example.com/canopy/ws/canopy-sessions/sess-1/?token=tok')
99
+ })
100
+
101
+ it('downgrades to ws:// for a plain-http base, so dev works', () => {
102
+ const url = buildSessionWsUrl('http://localhost:8000', 'sess-1', 'tok')
103
+ expect(url).toBe('ws://localhost:8000/ws/canopy-sessions/sess-1/?token=tok')
104
+ })
105
+
106
+ it('url-encodes the session id and the token', () => {
107
+ const url = buildSessionWsUrl('http://h', 'a/b', 'to ken')
108
+ expect(url).toContain('/ws/canopy-sessions/a%2Fb/')
109
+ expect(url).toContain('token=to%20ken')
110
+ })
111
+
112
+ it('omits the query entirely when there is no token', () => {
113
+ expect(buildSessionWsUrl('http://h', 's', null)).toBe('ws://h/ws/canopy-sessions/s/')
114
+ })
115
+ })
116
+
117
+ describe('rest', () => {
118
+ function harness(responses: Array<{ status: number; body?: unknown }>) {
119
+ const calls: Array<{ url: string; init: RequestInit }> = []
120
+ const queue = [...responses]
121
+ const fetchImpl = vi.fn(async (url: string, init: RequestInit = {}) => {
122
+ calls.push({ url: String(url), init })
123
+ const next = queue.shift() ?? { status: 200, body: [] }
124
+ return {
125
+ ok: next.status >= 200 && next.status < 300,
126
+ status: next.status,
127
+ json: async () => next.body,
128
+ } as Response
129
+ }) as unknown as typeof fetch
130
+
131
+ const client = createCanopyClient({
132
+ baseUrl: 'https://canopy.example.com',
133
+ fetchToken: vi.fn().mockResolvedValue({ token: 'tok', expiresAt: TOKEN_TTL() }),
134
+ originKey: 'connect-labs:opp-42',
135
+ source: 'connect-labs',
136
+ fetchImpl,
137
+ })
138
+ return { client, calls }
139
+ }
140
+
141
+ it('sends the bearer on every request', async () => {
142
+ const { client, calls } = harness([{ status: 200, body: [] }])
143
+ await client.rest.listAgents()
144
+ expect((calls[0].init.headers as Record<string, string>).Authorization).toBe('Bearer tok')
145
+ })
146
+
147
+ it('scopes the session list by this product, not by everything the user has', async () => {
148
+ const { client, calls } = harness([{ status: 200, body: [] }])
149
+ await client.rest.listSessions({ state: 'active' })
150
+ expect(calls[0].url).toContain('origin_key=connect-labs%3Aopp-42')
151
+ expect(calls[0].url).toContain('source=connect-labs')
152
+ expect(calls[0].url).toContain('state=active')
153
+ })
154
+
155
+ it('retries a 401 exactly once, with a forced re-mint', async () => {
156
+ const { client, calls } = harness([
157
+ { status: 401 },
158
+ { status: 200, body: [{ id: 's1', title: 't', last_activity_at: 'x' }] },
159
+ ])
160
+ const rows = await client.rest.listSessions()
161
+ expect(calls).toHaveLength(2)
162
+ expect(rows[0].id).toBe('s1')
163
+ })
164
+
165
+ it('does not retry a second 401 — it raises instead of looping', async () => {
166
+ const { client, calls } = harness([{ status: 401 }, { status: 401 }])
167
+ await expect(client.rest.listSessions()).rejects.toBeInstanceOf(CanopyRestError)
168
+ expect(calls).toHaveLength(2)
169
+ })
170
+
171
+ it('maps last_activity_at onto updatedAt — SessionOut has no updated_at', async () => {
172
+ const { client } = harness([
173
+ {
174
+ status: 200,
175
+ body: [
176
+ {
177
+ id: 's1',
178
+ title: 'chat',
179
+ agent_slug: 'labs-helper',
180
+ last_activity_at: '2026-09-12T00:00:00Z',
181
+ runner_name: 'jj-mbp',
182
+ runner_online: true,
183
+ },
184
+ ],
185
+ },
186
+ ])
187
+ const [row] = await client.rest.listSessions()
188
+ expect(row.updatedAt).toBe('2026-09-12T00:00:00Z')
189
+ expect(row.agentSlug).toBe('labs-helper')
190
+ expect(row.runnerOnline).toBe(true)
191
+ })
192
+
193
+ it('reports runnerOnline as null for an unbound session, not false', async () => {
194
+ // null means "nothing to be offline"; false would make the placement banner
195
+ // claim a runner had gone away when there never was one.
196
+ const { client } = harness([{ status: 200, body: [{ id: 's', title: '', last_activity_at: '' }] }])
197
+ const [row] = await client.rest.listSessions()
198
+ expect(row.runnerOnline).toBeNull()
199
+ })
200
+
201
+ it('threads has_more_before through the detail read', async () => {
202
+ const { client } = harness([
203
+ {
204
+ status: 200,
205
+ body: { id: 's', title: '', last_activity_at: '', has_more_before: true, oldest_loaded_turn_index: 64 },
206
+ },
207
+ ])
208
+ const detail = await client.rest.getSession('s')
209
+ expect(detail.hasMoreBefore).toBe(true)
210
+ expect(detail.oldestLoadedTurnIndex).toBe(64)
211
+ })
212
+
213
+ it('the agent picker takes no app parameter — the token decides', async () => {
214
+ const { client, calls } = harness([{ status: 200, body: [] }])
215
+ await client.rest.listAgents()
216
+ expect(calls[0].url).toBe('https://canopy.example.com/api/embed/agents')
217
+ expect(calls[0].url).not.toContain('app=')
218
+ })
219
+ })
220
+
221
+ describe('socket url from the client', () => {
222
+ it('is null until a token exists, rather than a url that will be rejected', async () => {
223
+ const client = createCanopyClient({
224
+ baseUrl: 'https://canopy.example.com',
225
+ fetchToken: vi.fn().mockResolvedValue({ token: 'tok', expiresAt: TOKEN_TTL() }),
226
+ fetchImpl: vi.fn() as unknown as typeof fetch,
227
+ })
228
+ expect(client.sessionSocketUrl('s1')).toBeNull()
229
+
230
+ await client.rest.listAgents().catch(() => undefined) // mints a token
231
+ expect(client.sessionSocketUrl('s1')).toContain('token=tok')
232
+ })
233
+ })
234
+
235
+ describe('host bridge', () => {
236
+ it('returns an empty snapshot when the host lends no context', async () => {
237
+ expect(await createHostBridge().readContext()).toEqual({})
238
+ })
239
+
240
+ it('reads the CURRENT page state each time, not the state at registration', async () => {
241
+ // The whole point: a workflow's props change as the user works, and the
242
+ // agent must see what is on screen when the session opens.
243
+ const bridge = createHostBridge()
244
+ let step = 1
245
+ bridge.provideContext(() => ({ step }))
246
+ expect(await bridge.readContext()).toEqual({ step: 1 })
247
+ step = 2
248
+ expect(await bridge.readContext()).toEqual({ step: 2 })
249
+ })
250
+
251
+ it('replaces the provider rather than accumulating providers', async () => {
252
+ const bridge = createHostBridge()
253
+ bridge.provideContext(() => ({ which: 'first' }))
254
+ bridge.provideContext(() => ({ which: 'second' }))
255
+ expect(await bridge.readContext()).toEqual({ which: 'second' })
256
+ })
257
+
258
+ it('awaits an async provider', async () => {
259
+ const bridge = createHostBridge()
260
+ bridge.provideContext(async () => ({ loaded: true }))
261
+ expect(await bridge.readContext()).toEqual({ loaded: true })
262
+ })
263
+
264
+ it('runs a registered action and returns its result to the agent', async () => {
265
+ const bridge = createHostBridge()
266
+ const onUpdateState = vi.fn().mockResolvedValue({ saved: true })
267
+ bridge.registerAction('updateState', onUpdateState)
268
+
269
+ const result = await bridge.runAction('updateState', { status: 'reviewed' })
270
+
271
+ expect(onUpdateState).toHaveBeenCalledWith({ status: 'reviewed' })
272
+ expect(result).toEqual({ saved: true })
273
+ })
274
+
275
+ it('rejects an unknown action instead of silently doing nothing', async () => {
276
+ // A no-op is indistinguishable to the agent from success, and it will carry
277
+ // on as though the page changed.
278
+ await expect(createHostBridge().runAction('nope')).rejects.toBeInstanceOf(UnknownActionError)
279
+ })
280
+
281
+ it('rejects an action that was withdrawn when the page moved on', async () => {
282
+ const bridge = createHostBridge()
283
+ bridge.registerAction('updateState', vi.fn())
284
+ bridge.unregisterAction('updateState')
285
+ await expect(bridge.runAction('updateState')).rejects.toBeInstanceOf(UnknownActionError)
286
+ expect(bridge.actionNames()).toEqual([])
287
+ })
288
+
289
+ it('re-registering a name replaces the handler, so a re-render cannot stack them', async () => {
290
+ const bridge = createHostBridge()
291
+ const stale = vi.fn()
292
+ const fresh = vi.fn()
293
+ bridge.registerAction('act', stale)
294
+ bridge.registerAction('act', fresh)
295
+
296
+ await bridge.runAction('act')
297
+
298
+ expect(stale).not.toHaveBeenCalled()
299
+ expect(fresh).toHaveBeenCalledOnce()
300
+ expect(bridge.actionNames()).toEqual(['act'])
301
+ })
302
+
303
+ it('lists action names in a stable order across registration orders', () => {
304
+ const a = createHostBridge()
305
+ a.registerAction('zed', vi.fn())
306
+ a.registerAction('alpha', vi.fn())
307
+ expect(a.actionNames()).toEqual(['alpha', 'zed'])
308
+ })
309
+
310
+ it('propagates a host refusal as an error, not a falsy return', async () => {
311
+ const bridge = createHostBridge()
312
+ bridge.registerAction('act', () => {
313
+ throw new Error('not allowed on a completed run')
314
+ })
315
+ await expect(bridge.runAction('act')).rejects.toThrow('not allowed on a completed run')
316
+ })
317
+ })
@@ -0,0 +1,49 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ /**
8
+ * The package's reason for existing is that it depends on nothing.
9
+ *
10
+ * canopy-ui requires React 19; connect-labs is React 18, and mostly Django
11
+ * templates with alpine and htmx besides. If this package ever grows an import
12
+ * of a framework — or of anything at all — the hosts it was built for can no
13
+ * longer use it, and the failure would show up as a peer-dependency conflict
14
+ * in someone else's repo rather than as a test here.
15
+ */
16
+
17
+ const SRC = dirname(fileURLToPath(import.meta.url))
18
+
19
+ function shippedFiles(): string[] {
20
+ return readdirSync(SRC).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
21
+ }
22
+
23
+ describe('the package ships with no dependencies', () => {
24
+ it('declares none in package.json', () => {
25
+ const pkg = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8'))
26
+ expect(pkg.dependencies).toBeUndefined()
27
+ expect(pkg.peerDependencies).toBeUndefined()
28
+ expect(pkg.devDependencies).toBeUndefined()
29
+ })
30
+
31
+ it('imports nothing but its own modules', () => {
32
+ // Matches `from "x"` / `from 'x'` where x is not relative.
33
+ const bare = /from\s+['"]([^.'"][^'"]*)['"]/g
34
+ const offenders: string[] = []
35
+
36
+ for (const file of shippedFiles()) {
37
+ const source = readFileSync(join(SRC, file), 'utf8')
38
+ for (const [, spec] of source.matchAll(bare)) {
39
+ offenders.push(`${file} imports ${spec}`)
40
+ }
41
+ }
42
+
43
+ expect(offenders).toEqual([])
44
+ })
45
+
46
+ it('covers every shipped module, so a new file cannot slip past the check', () => {
47
+ expect(shippedFiles().sort()).toEqual(['bridge.ts', 'index.ts', 'rest.ts', 'token.ts', 'ws.ts'])
48
+ })
49
+ })
package/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `canopy-client` — layer 1 of the embedded-agent SDK (v2 spec §1).
3
+ *
4
+ * The transport half of talking to canopy-web, with **no framework and no
5
+ * dependencies at all**. That emptiness is the feature: canopy-ui requires
6
+ * React 19, and the hosts that most want an agent cannot always have it —
7
+ * connect-labs is React 18, Django templates, alpine and htmx. The iframe
8
+ * widget bundles its own React; a native React host brings its own; a Django
9
+ * page brings none. All three use this.
10
+ *
11
+ * Extracted from ace-web's `frontend/src/canopy/{token,api,ws}.ts`, which was
12
+ * already framework-free (zero React imports across ~390 lines) but trapped
13
+ * inside one host and coupled to that host's endpoints. ace-web still has its
14
+ * own copy; adopting this is a follow-up in that repo, and until it does the
15
+ * duplication is real.
16
+ *
17
+ * **Why the name is unscoped.** It was `@canopy/client`, which cannot be
18
+ * published: the `@canopy` npm scope belongs to somebody else (it holds one
19
+ * package, `atos-theme`, which is not ours). That made the whole layer-1 plan
20
+ * a dead end — ace-web could never `npm install` it, so the duplication above
21
+ * could never actually be paid down. `canopy-ui` had already hit this and
22
+ * settled it: it is deliberately unscoped, "no org scope — portable, not tied
23
+ * to any account", after starting life as `@canopy/workbench`. This follows
24
+ * that, rather than re-litigating it a third time.
25
+ *
26
+ * What stays OUT, and why:
27
+ *
28
+ * - **Token minting.** The `AppCredential` is a secret, so only a host backend
29
+ * can exchange it. The client takes a `fetchToken` callback.
30
+ * - **Session creation.** The host stamps `origin_key` server-side from a
31
+ * membership-checked path; a client that could set its own would be able to
32
+ * claim another tenant's scope. So creation is the host's endpoint, and this
33
+ * package only ever reads and sends.
34
+ */
35
+
36
+ export { createTokenStore } from './token'
37
+ export type { CanopyToken, FetchToken, TokenStore } from './token'
38
+
39
+ export { buildSessionWsUrl } from './ws'
40
+ export type { WsLocation } from './ws'
41
+
42
+ export { createRest, CanopyRestError, RUNNER_STATUS_ONLINE } from './rest'
43
+ export type { CanopyRest, CanopySessionDetail, CanopySessionSummary, RestConfig } from './rest'
44
+
45
+ export { createHostBridge, UnknownActionError } from './bridge'
46
+ export type { ContextProvider, HostAction, HostBridge, HostContext } from './bridge'
47
+
48
+ import { createRest, type CanopyRest } from './rest'
49
+ import { createTokenStore, type FetchToken } from './token'
50
+ import { buildSessionWsUrl } from './ws'
51
+
52
+ export interface CanopyClientConfig {
53
+ /** Browser-facing canopy base: a same-origin path prefix (`/canopy`) or an
54
+ * absolute URL (the normal case for an embedded widget). */
55
+ baseUrl: string
56
+ /** How this host mints a delegated token for the signed-in user. */
57
+ fetchToken: FetchToken
58
+ /** `metadata.origin_key` to FILTER the session list by — this product's
59
+ * scope, as the host stamped it server-side. */
60
+ originKey?: string
61
+ /** `metadata.source` to filter by, e.g. `connect-labs`. */
62
+ source?: string
63
+ fetchImpl?: typeof fetch
64
+ }
65
+
66
+ export interface CanopyClient {
67
+ rest: CanopyRest
68
+ /** The session socket URL, with the cached token already on it. Returns
69
+ * `null` when no token has been minted yet — the caller should not open a
70
+ * socket in that state, and getting `null` is easier to handle correctly
71
+ * than a URL that will be rejected. */
72
+ sessionSocketUrl(sessionId: string): string | null
73
+ /** Force the next request to re-mint. For a host that knows the user's
74
+ * identity changed (a sign-out, an account switch). */
75
+ invalidateToken(): void
76
+ }
77
+
78
+ export function createCanopyClient(config: CanopyClientConfig): CanopyClient {
79
+ const tokens = createTokenStore(config.fetchToken)
80
+ const rest = createRest({
81
+ baseUrl: config.baseUrl,
82
+ tokens,
83
+ originKey: config.originKey,
84
+ source: config.source,
85
+ fetchImpl: config.fetchImpl,
86
+ })
87
+
88
+ return {
89
+ rest,
90
+ sessionSocketUrl(sessionId) {
91
+ const token = tokens.peek()
92
+ if (!token) return null
93
+ return buildSessionWsUrl(config.baseUrl, sessionId, token)
94
+ },
95
+ invalidateToken() {
96
+ tokens.clear()
97
+ },
98
+ }
99
+ }
package/src/rest.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Browser → canopy-web REST, with the bearer and the 401 retry in one place.
3
+ *
4
+ * Ported from ace-web's `frontend/src/canopy/api.ts`, minus everything that was
5
+ * about ace-web: the `source: "ace-web"` literal, `aceOriginKey()`, and
6
+ * `createCanopySession`, which called ace-web's OWN endpoint rather than
7
+ * canopy's (session create stays host-side on purpose — the host stamps
8
+ * `origin_key` server-side from a membership-checked path, so a caller cannot
9
+ * claim another tenant's scope). Those become configuration and a host
10
+ * callback respectively.
11
+ *
12
+ * Field mappings against canopy's real schemas are preserved from ace-web,
13
+ * including the ones that were wrong once and got fixed there:
14
+ *
15
+ * - `SessionOut` has no `updated_at`; it is `last_activity_at`.
16
+ * - `Runner.live_status` values are LOWERCASE (`online`, `stale`, …). An
17
+ * earlier ace-web draft compared against `"ONLINE"` — the Python constant's
18
+ * NAME, not its value — which made every runner look offline and mis-fired
19
+ * the placement banner on every chat.
20
+ * - `runner_online` is read off the session, not cross-referenced against the
21
+ * runner fleet: `GET /api/harness/runners/` is scoped to runners the caller
22
+ * personally PAIRED, so a delegated user sees an empty fleet there and could
23
+ * never otherwise distinguish a stalled chat from a slow one.
24
+ */
25
+
26
+ import type { TokenStore } from './token'
27
+
28
+ export interface CanopySessionSummary {
29
+ id: string
30
+ title: string
31
+ agentSlug: string | null
32
+ updatedAt: string
33
+ runnerName: string | null
34
+ /** `true`/`false` when the session has a runner binding, `null` when it has
35
+ * none — there is nothing to be offline. */
36
+ runnerOnline: boolean | null
37
+ }
38
+
39
+ export interface CanopySessionDetail extends CanopySessionSummary {
40
+ hasMoreBefore: boolean
41
+ oldestLoadedTurnIndex: number | null
42
+ }
43
+
44
+ /** `Runner.live_status`'s wire value for a reachable runner. Referenced as a
45
+ * constant rather than repeated, because the literal is what went wrong once. */
46
+ export const RUNNER_STATUS_ONLINE = 'online'
47
+
48
+ export interface RestConfig {
49
+ /** canopy's browser-facing base — a path prefix (`/canopy`) or an absolute URL. */
50
+ baseUrl: string
51
+ tokens: TokenStore
52
+ /** Stamped by the HOST server-side and used here only to FILTER the list to
53
+ * this product's sessions. Never sent on a create — a client that could set
54
+ * its own `origin_key` could read another tenant's chats. */
55
+ originKey?: string
56
+ /** `metadata.source` filter, e.g. `connect-labs`. */
57
+ source?: string
58
+ /** Injectable for tests and for a non-browser host. */
59
+ fetchImpl?: typeof fetch
60
+ }
61
+
62
+ export class CanopyRestError extends Error {
63
+ // Written out rather than declared as constructor parameter properties: the
64
+ // app's tsconfig sets `erasableSyntaxOnly`, which forbids them. Nothing under
65
+ // src/ imported this package until the embed entry did, so the whole package
66
+ // was type-checked for the first time then — and did not compile.
67
+ readonly status: number
68
+ readonly path: string
69
+
70
+ constructor(status: number, path: string) {
71
+ super(`canopy request failed (${status}): ${path}`)
72
+ this.status = status
73
+ this.path = path
74
+ }
75
+ }
76
+
77
+ export function createRest(config: RestConfig) {
78
+ const doFetch = config.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
79
+
80
+ async function raw(path: string, init: RequestInit = {}): Promise<Response> {
81
+ const send = (bearer: string) =>
82
+ doFetch(`${config.baseUrl}${path}`, {
83
+ ...init,
84
+ headers: {
85
+ ...(init.body ? { 'Content-Type': 'application/json' } : {}),
86
+ ...init.headers,
87
+ Authorization: `Bearer ${bearer}`,
88
+ },
89
+ })
90
+
91
+ let response = await send(await config.tokens.get())
92
+ if (response.status === 401) {
93
+ // Exactly one retry, with a FORCED refresh — canopy has rejected the
94
+ // token, so our own expiry bookkeeping is not the authority here (it may
95
+ // have been revoked early, or the clocks may disagree).
96
+ response = await send(await config.tokens.get(true))
97
+ }
98
+ return response
99
+ }
100
+
101
+ async function json<T>(path: string, init?: RequestInit): Promise<T> {
102
+ const response = await raw(path, init)
103
+ if (!response.ok) throw new CanopyRestError(response.status, path)
104
+ if (response.status === 204) return undefined as T
105
+ return (await response.json()) as T
106
+ }
107
+
108
+ function mapSummary(r: Record<string, unknown>): CanopySessionSummary {
109
+ return {
110
+ id: r.id as string,
111
+ title: r.title as string,
112
+ agentSlug: (r.agent_slug as string | null | undefined) ?? null,
113
+ updatedAt: (r.last_activity_at as string | undefined) ?? (r.updated_at as string),
114
+ runnerName: (r.runner_name as string | null | undefined) ?? null,
115
+ runnerOnline: (r.runner_online as boolean | null | undefined) ?? null,
116
+ }
117
+ }
118
+
119
+ return {
120
+ raw,
121
+ json,
122
+
123
+ /** Agents this embedding app may offer this user — the picker's source.
124
+ * The app is resolved from the bearer token server-side, so there is
125
+ * deliberately no parameter here to get wrong. */
126
+ async listAgents(): Promise<
127
+ { slug: string; name: string; description: string; avatar_url: string; workspace: string }[]
128
+ > {
129
+ return json('/api/embed/agents')
130
+ },
131
+
132
+ async listSessions(
133
+ filters: { state?: string; agentSlug?: string } = {},
134
+ ): Promise<CanopySessionSummary[]> {
135
+ const params = new URLSearchParams()
136
+ if (config.source) params.set('source', config.source)
137
+ // Scopes the list to this product. Omitted when the host has no scope to
138
+ // apply, in which case canopy's own per-user filtering is the only limit.
139
+ if (config.originKey) params.set('origin_key', config.originKey)
140
+ if (filters.state) params.set('state', filters.state)
141
+ if (filters.agentSlug) params.set('agent_slug', filters.agentSlug)
142
+ const qs = params.toString()
143
+ const rows = await json<Record<string, unknown>[]>(
144
+ `/api/canopy-sessions/${qs ? `?${qs}` : ''}`,
145
+ )
146
+ return rows.map(mapSummary)
147
+ },
148
+
149
+ /** Single-session detail. NOT filtered by `state` or capped by a page limit,
150
+ * so it is the right call for "does THIS session have a bound runner" and
151
+ * "is there more history" — an archived or page-201st session vanishes from
152
+ * the list but is still directly gettable. */
153
+ async getSession(id: string): Promise<CanopySessionDetail> {
154
+ const r = await json<Record<string, unknown>>(
155
+ `/api/canopy-sessions/${encodeURIComponent(id)}`,
156
+ )
157
+ return {
158
+ ...mapSummary(r),
159
+ hasMoreBefore: Boolean(r.has_more_before),
160
+ oldestLoadedTurnIndex: (r.oldest_loaded_turn_index as number | null | undefined) ?? null,
161
+ }
162
+ },
163
+
164
+ async send(id: string, text: string, clientId: string, origin?: string): Promise<unknown> {
165
+ return json(`/api/canopy-sessions/${encodeURIComponent(id)}/send`, {
166
+ method: 'POST',
167
+ body: JSON.stringify({ text, client_id: clientId, ...(origin ? { origin } : {}) }),
168
+ })
169
+ },
170
+
171
+ async fetchOlder(
172
+ id: string,
173
+ before: number,
174
+ ): Promise<{ messages: unknown[]; has_more_before: boolean }> {
175
+ return json(
176
+ `/api/canopy-sessions/${encodeURIComponent(id)}/messages?before=${encodeURIComponent(
177
+ String(before),
178
+ )}`,
179
+ )
180
+ },
181
+
182
+ // The viewer-liveness pair (`RunnerBinding.stream_desired`): attaching asks
183
+ // the bound runner to stream this session live, detaching lets it stop once
184
+ // the last viewer leaves. Best-effort by design — a caller fires these on
185
+ // mount/unmount and must never block rendering on the result.
186
+ async attach(id: string): Promise<void> {
187
+ await raw(`/api/canopy-sessions/${encodeURIComponent(id)}/attach`, { method: 'POST' })
188
+ },
189
+ async detach(id: string): Promise<void> {
190
+ await raw(`/api/canopy-sessions/${encodeURIComponent(id)}/detach`, { method: 'POST' })
191
+ },
192
+ }
193
+ }
194
+
195
+ export type CanopyRest = ReturnType<typeof createRest>
package/src/token.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The delegated-token cache.
3
+ *
4
+ * Ported from ace-web's `frontend/src/canopy/token.ts`, with one dependency
5
+ * inverted and one global removed.
6
+ *
7
+ * **Inverted:** ace-web's version called ace-web's own `POST /api/canopy/token`
8
+ * through ace-web's generated API client. That is the one part of the flow that
9
+ * *must* stay host-specific — the `AppCredential` is a secret, so only the host
10
+ * backend can mint — so the client takes a `fetchToken` callback and knows
11
+ * nothing about how the host exchanges. That inversion is the whole reason this
12
+ * package can serve a Django host, a React SPA, and an iframe widget at once.
13
+ *
14
+ * **De-globalised:** ace-web held `cached` and `inflight` at module scope, which
15
+ * is fine for exactly one client per page. A widget can be mounted twice (two
16
+ * panels, or a host embedding two agents), and tests then leak state into each
17
+ * other. `createTokenStore` closes over its own state instead.
18
+ */
19
+
20
+ /** What a host's token endpoint must return. `expiresAt` is opaque to the host
21
+ * contract but must be parseable by `Date` — canopy sends ISO-8601. */
22
+ export interface CanopyToken {
23
+ token: string
24
+ expiresAt: string
25
+ }
26
+
27
+ export type FetchToken = () => Promise<CanopyToken>
28
+
29
+ export interface TokenStore {
30
+ /** Cached token, refetching when it is near expiry. `force` bypasses the
31
+ * cache outright — used by the 401 retry, which must not trust our own
32
+ * expiry bookkeeping when canopy has already rejected the token. */
33
+ get(force?: boolean): Promise<string>
34
+ /** Sync read for callers that cannot await — the WS URL builder, which has to
35
+ * put the token in a query string. `null` before the first mint. */
36
+ peek(): string | null
37
+ /** Drop the cached token. For a host that knows the user signed out. */
38
+ clear(): void
39
+ }
40
+
41
+ /** Refetch this long before real expiry, so a request kicked off just under the
42
+ * wire does not race expiry mid-flight. */
43
+ const REFRESH_SKEW_MS = 5 * 60 * 1000
44
+
45
+ /** A non-parseable `expiresAt` is treated as already-expired rather than cached
46
+ * with `NaN`. `now < NaN - skew` is always false, so this was already forcing a
47
+ * refetch every call in ace-web; making it explicit means the behaviour is
48
+ * intended rather than a coincidence of NaN comparison. */
49
+ function expiresAtMs(expiresAt: string): number {
50
+ const ms = new Date(expiresAt).getTime()
51
+ return Number.isNaN(ms) ? 0 : ms
52
+ }
53
+
54
+ export function createTokenStore(fetchToken: FetchToken): TokenStore {
55
+ let cached: { token: string; expiresAtMs: number } | null = null
56
+ // In-flight dedup. Without it, several components mounting in the same tick
57
+ // each call get() before any has a cached result, firing N concurrent mints —
58
+ // and N new DelegatedToken rows server-side. Every caller in that tick awaits
59
+ // the one request already underway.
60
+ let inflight: Promise<string> | null = null
61
+
62
+ return {
63
+ get(force = false) {
64
+ if (!force && cached && Date.now() < cached.expiresAtMs - REFRESH_SKEW_MS) {
65
+ return Promise.resolve(cached.token)
66
+ }
67
+ if (!inflight) {
68
+ inflight = fetchToken()
69
+ .then(({ token, expiresAt }) => {
70
+ cached = { token, expiresAtMs: expiresAtMs(expiresAt) }
71
+ return token
72
+ })
73
+ .finally(() => {
74
+ // Cleared even on rejection, so a transient failure does not poison
75
+ // every later call with the same stale rejected promise.
76
+ inflight = null
77
+ })
78
+ }
79
+ return inflight
80
+ },
81
+ peek() {
82
+ return cached ? cached.token : null
83
+ },
84
+ clear() {
85
+ cached = null
86
+ },
87
+ }
88
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The session WebSocket URL.
3
+ *
4
+ * Ported from ace-web's `frontend/src/canopy/ws.ts`. The only change is that the
5
+ * token is passed in rather than read from a module global, so this is a pure
6
+ * function and testable without a token store.
7
+ *
8
+ * `base` is one of two shapes:
9
+ * - a same-origin PATH (`/canopy` — a vite proxy in dev, or a shared ALB path
10
+ * prefix in prod) with no scheme or host of its own;
11
+ * - an absolute `http(s)://host[/path]` URL (canopy on a different host, which
12
+ * is the normal case for an embedded widget).
13
+ *
14
+ * `WebSocket` needs an absolute `ws(s)://` URL either way, so a bare path
15
+ * borrows the current location's scheme and host; an absolute base keeps its own
16
+ * and only has its scheme swapped.
17
+ *
18
+ * The token rides as `?token=` because a WebSocket handshake cannot carry an
19
+ * `Authorization` header. canopy's `channels_auth` accepts DelegatedTokens on
20
+ * that query parameter for exactly this reason.
21
+ */
22
+
23
+ export interface WsLocation {
24
+ protocol: string
25
+ host: string
26
+ }
27
+
28
+ /** `location` is injectable so this works in a worker, in a test, and in an
29
+ * iframe — anywhere `window` may be absent or not the one you mean. */
30
+ export function buildSessionWsUrl(
31
+ base: string,
32
+ sessionId: string,
33
+ token: string | null,
34
+ location?: WsLocation,
35
+ ): string {
36
+ const isAbsolute = /^https?:\/\//i.test(base)
37
+
38
+ let origin: string
39
+ let pathPrefix: string
40
+
41
+ if (isAbsolute) {
42
+ const url = new URL(base)
43
+ origin = `${url.protocol === 'https:' ? 'wss:' : 'ws:'}//${url.host}`
44
+ pathPrefix = url.pathname.replace(/\/$/, '')
45
+ } else {
46
+ const loc =
47
+ location ??
48
+ (typeof window !== 'undefined'
49
+ ? { protocol: window.location.protocol, host: window.location.host }
50
+ : { protocol: 'http:', host: 'localhost' })
51
+ origin = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`
52
+ pathPrefix = base.replace(/\/$/, '')
53
+ }
54
+
55
+ // No token means no session has been minted yet, in which case the caller
56
+ // should not be opening a socket. Left as a tokenless URL rather than thrown,
57
+ // matching ace-web: the connect will fail loudly at the server instead of
58
+ // turning a race into an exception in a render path.
59
+ const query = token ? `?token=${encodeURIComponent(token)}` : ''
60
+ return `${origin}${pathPrefix}/ws/canopy-sessions/${encodeURIComponent(sessionId)}/${query}`
61
+ }