@pikku/core 0.12.99 → 0.12.101
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 +48 -0
- package/dist/services/http-personas.d.ts +42 -7
- package/dist/services/http-personas.js +135 -12
- package/dist/services/persona-sign-in.d.ts +12 -10
- package/dist/services/persona-sign-in.js +26 -9
- package/dist/wirings/cli/cli-runner.js +2 -11
- package/dist/wirings/cli/format-cli-error.d.ts +24 -0
- package/dist/wirings/cli/format-cli-error.js +68 -0
- package/dist/wirings/persona/index.d.ts +1 -1
- package/dist/wirings/persona/index.js +1 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +0 -1
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +0 -4
- package/package.json +1 -1
- package/src/public-surface.json +1 -0
- package/src/services/http-personas-converse.test.ts +29 -20
- package/src/services/http-personas.test.ts +145 -0
- package/src/services/http-personas.ts +155 -12
- package/src/services/persona-sign-in.test.ts +22 -28
- package/src/services/persona-sign-in.ts +27 -19
- package/src/wirings/cli/cli-runner.test.ts +104 -0
- package/src/wirings/cli/cli-runner.ts +2 -11
- package/src/wirings/cli/format-cli-error.test.ts +91 -0
- package/src/wirings/cli/format-cli-error.ts +96 -0
- package/src/wirings/persona/index.ts +1 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +0 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -57,28 +57,37 @@ const startAgentTarget = async () => {
|
|
|
57
57
|
json({ runId: body.runId, text: 'Created it.', status: 'completed' })
|
|
58
58
|
return
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// The SSE route, because that is the one a persona's turn goes to — the
|
|
61
|
+
// plain route buffers the whole run and dies on undici's headers timeout.
|
|
62
|
+
if (req.url === '/api/rpc/agent/todoBot/stream') {
|
|
61
63
|
agentRuns++
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
64
|
+
const runId = `run-${agentRuns}`
|
|
65
|
+
const events: unknown[] =
|
|
66
|
+
agentRuns === 1
|
|
67
|
+
? [
|
|
68
|
+
{ type: 'RUN_STARTED', runId },
|
|
69
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'Let me ' },
|
|
70
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'do that.' },
|
|
71
|
+
{
|
|
72
|
+
type: 'approval-request',
|
|
73
|
+
runId,
|
|
74
|
+
toolCallId: 'tc1',
|
|
75
|
+
toolName: 'createTodo',
|
|
76
|
+
args: { title: 'x' },
|
|
77
|
+
},
|
|
78
|
+
{ type: 'done' },
|
|
79
|
+
]
|
|
80
|
+
: [
|
|
81
|
+
{ type: 'RUN_STARTED', runId },
|
|
82
|
+
{ type: 'TEXT_MESSAGE_CONTENT', delta: 'All set.' },
|
|
83
|
+
{ type: 'RUN_FINISHED', runId },
|
|
84
|
+
{ type: 'done' },
|
|
85
|
+
]
|
|
86
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
87
|
+
for (const event of events) {
|
|
88
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
|
76
89
|
}
|
|
77
|
-
|
|
78
|
-
runId: `run-${agentRuns}`,
|
|
79
|
-
text: 'All set.',
|
|
80
|
-
status: 'completed',
|
|
81
|
-
})
|
|
90
|
+
res.end()
|
|
82
91
|
return
|
|
83
92
|
}
|
|
84
93
|
res.writeHead(404).end()
|
|
@@ -35,6 +35,29 @@ const startTarget = async () => {
|
|
|
35
35
|
res.writeHead(200).end(JSON.stringify({ ok: true, email: body.email }))
|
|
36
36
|
return
|
|
37
37
|
}
|
|
38
|
+
if (req.url === '/api/auth/sign-in/fabric') {
|
|
39
|
+
if (body.token !== 'operator-token') {
|
|
40
|
+
res.writeHead(401).end(JSON.stringify({ message: 'bad operator' }))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
logins++
|
|
44
|
+
res.setHeader('set-cookie', [`session=s${logins}; Path=/; HttpOnly`])
|
|
45
|
+
res
|
|
46
|
+
.writeHead(200)
|
|
47
|
+
.end(JSON.stringify({ actAs: { userId: `u-${body.actAs.email}` } }))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (req.url === '/api/auth/get-session') {
|
|
51
|
+
const cookie = req.headers.cookie ?? ''
|
|
52
|
+
if (!cookie.includes('session=')) {
|
|
53
|
+
res.writeHead(200).end('null')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
res
|
|
57
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
58
|
+
.end(JSON.stringify({ user: { role: 'admin,support' } }))
|
|
59
|
+
return
|
|
60
|
+
}
|
|
38
61
|
if (req.url?.startsWith('/api/rpc/')) {
|
|
39
62
|
const cookie = req.headers.cookie ?? ''
|
|
40
63
|
if (!cookie.includes('session=') || expireNext) {
|
|
@@ -43,6 +66,16 @@ const startTarget = async () => {
|
|
|
43
66
|
return
|
|
44
67
|
}
|
|
45
68
|
const rpcName = req.url.slice('/api/rpc/'.length)
|
|
69
|
+
if (rpcName === 'getMyScopes') {
|
|
70
|
+
res
|
|
71
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
72
|
+
.end(JSON.stringify({ scopes: ['stays:read'], roles: ['guest'] }))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (rpcName === 'noSuchRpc') {
|
|
76
|
+
res.writeHead(404).end()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
46
79
|
if (rpcName === 'html-error') {
|
|
47
80
|
res
|
|
48
81
|
.writeHead(500, { 'content-type': 'text/html' })
|
|
@@ -63,6 +96,7 @@ const startTarget = async () => {
|
|
|
63
96
|
echoed: body.data,
|
|
64
97
|
cookie,
|
|
65
98
|
userHeader: req.headers['x-user-id'] ?? null,
|
|
99
|
+
impersonated: req.headers['x-pikku-impersonate-user-id'] ?? null,
|
|
66
100
|
})
|
|
67
101
|
)
|
|
68
102
|
return
|
|
@@ -74,6 +108,7 @@ const startTarget = async () => {
|
|
|
74
108
|
const { port } = server.address() as { port: number }
|
|
75
109
|
return {
|
|
76
110
|
server,
|
|
111
|
+
origin: `http://127.0.0.1:${port}`,
|
|
77
112
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
78
113
|
loginCount: () => logins,
|
|
79
114
|
expireSession: () => {
|
|
@@ -231,4 +266,114 @@ describe('HttpPersona', async () => {
|
|
|
231
266
|
/persona sign-in failed for 'customer' \(401\).*bad actor secret/
|
|
232
267
|
)
|
|
233
268
|
})
|
|
269
|
+
|
|
270
|
+
// An app whose auth is under `/api` but whose RPCs are not cannot put the
|
|
271
|
+
// mount in `apiUrl`, so it moves `signInPath` instead. The session read has
|
|
272
|
+
// to follow it: a 404 there reads as 'this stage does not report roles' and
|
|
273
|
+
// silently turns the role check off.
|
|
274
|
+
//
|
|
275
|
+
// `rolesRpc: false` because this is about the better-auth path specifically —
|
|
276
|
+
// leaving the RPC in would answer first and the mount would go untested.
|
|
277
|
+
test('reads the session from the mount the sign-in path names', async () => {
|
|
278
|
+
const actors = createHttpPersonas({
|
|
279
|
+
apiUrl: target.origin,
|
|
280
|
+
secret: ROOT,
|
|
281
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
282
|
+
rpcPath: '/api/rpc',
|
|
283
|
+
rolesRpc: false,
|
|
284
|
+
personas: {
|
|
285
|
+
manager: {
|
|
286
|
+
id: 'manager',
|
|
287
|
+
name: 'Manager',
|
|
288
|
+
email: 'manager@personas.invalid',
|
|
289
|
+
roles: ['admin'],
|
|
290
|
+
goals: [],
|
|
291
|
+
tags: [],
|
|
292
|
+
runnable: true,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Scopes are the model in an app that authorizes on them; better-auth's
|
|
301
|
+
// `user.role` is a projection kept in step for its own admin endpoints, and
|
|
302
|
+
// is absent entirely from an app that declares no such field. Reading the
|
|
303
|
+
// projection is how a guest holding exactly what it should got refused for
|
|
304
|
+
// "roles drifted".
|
|
305
|
+
test('reads roles from the app RPC in preference to better-auth', async () => {
|
|
306
|
+
const actors = createHttpPersonas({
|
|
307
|
+
apiUrl: target.origin,
|
|
308
|
+
secret: ROOT,
|
|
309
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
310
|
+
rpcPath: '/api/rpc',
|
|
311
|
+
personas: {
|
|
312
|
+
manager: {
|
|
313
|
+
id: 'manager',
|
|
314
|
+
name: 'Manager',
|
|
315
|
+
email: 'manager@personas.invalid',
|
|
316
|
+
roles: ['guest'],
|
|
317
|
+
goals: [],
|
|
318
|
+
tags: [],
|
|
319
|
+
runnable: true,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
assert.deepEqual(await actors.manager!.sessionRoles(), ['guest'])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// An app without the RPC is not an app reporting "no roles" — going quiet
|
|
328
|
+
// there would turn every persona into a mismatch against an empty list.
|
|
329
|
+
test('falls back to better-auth when the roles RPC is absent', async () => {
|
|
330
|
+
const actors = createHttpPersonas({
|
|
331
|
+
apiUrl: target.origin,
|
|
332
|
+
secret: ROOT,
|
|
333
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
334
|
+
rpcPath: '/api/rpc',
|
|
335
|
+
rolesRpc: 'noSuchRpc',
|
|
336
|
+
personas: {
|
|
337
|
+
manager: {
|
|
338
|
+
id: 'manager',
|
|
339
|
+
name: 'Manager',
|
|
340
|
+
email: 'manager@personas.invalid',
|
|
341
|
+
roles: ['admin'],
|
|
342
|
+
goals: [],
|
|
343
|
+
tags: [],
|
|
344
|
+
runnable: true,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
assert.deepEqual(await actors.manager!.sessionRoles(), ['admin', 'support'])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// The operator handshake sits under the same mount and must be derived from
|
|
353
|
+
// it, not inherited verbatim: posting an operator token to the ACTOR path is
|
|
354
|
+
// a validation error about a missing email, which reads like a broken
|
|
355
|
+
// persona rather than a wrong URL.
|
|
356
|
+
test('derives the operator sign-in path from the same auth mount', async () => {
|
|
357
|
+
const actors = createHttpPersonas({
|
|
358
|
+
apiUrl: target.origin,
|
|
359
|
+
signInPath: '/api/auth/sign-in/actor',
|
|
360
|
+
rpcPath: '/api/rpc',
|
|
361
|
+
operator: { token: 'operator-token' },
|
|
362
|
+
personas: {
|
|
363
|
+
manager: {
|
|
364
|
+
id: 'manager',
|
|
365
|
+
name: 'Manager',
|
|
366
|
+
email: 'manager@personas.invalid',
|
|
367
|
+
roles: ['admin'],
|
|
368
|
+
goals: [],
|
|
369
|
+
tags: [],
|
|
370
|
+
runnable: true,
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const result = (await actors.manager!.invoke('ping', {})) as any
|
|
376
|
+
assert.equal(result.rpcName, 'ping')
|
|
377
|
+
assert.equal(result.impersonated, 'u-manager@personas.invalid')
|
|
378
|
+
})
|
|
234
379
|
})
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ConverseOptions,
|
|
11
11
|
ActorFlowVerdict,
|
|
12
12
|
TargetAgentReply,
|
|
13
|
+
TargetPendingApproval,
|
|
13
14
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
14
15
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
15
16
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
OperatorSignIn,
|
|
23
24
|
type OperatorSignInOptions,
|
|
24
25
|
type PersonaSignIn,
|
|
26
|
+
authMount,
|
|
25
27
|
} from './persona-sign-in.js'
|
|
26
28
|
import { getSingletonServices } from '../pikku-state.js'
|
|
27
29
|
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
@@ -65,10 +67,26 @@ export interface HttpPersonasConfig {
|
|
|
65
67
|
* {@link OperatorSignInOptions.signInPath} overrides it.
|
|
66
68
|
*/
|
|
67
69
|
signInPath?: string
|
|
68
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Where the session (and its roles) is read back. Defaults to `get-session`
|
|
72
|
+
* under the same auth mount as {@link HttpPersonasConfig.signInPath}, so an
|
|
73
|
+
* app that moved auth under `/api` moves this with it and does not have to
|
|
74
|
+
* say so twice.
|
|
75
|
+
*/
|
|
69
76
|
sessionPath?: string
|
|
70
77
|
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
71
78
|
rpcPath?: string
|
|
79
|
+
/**
|
|
80
|
+
* Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
|
|
81
|
+
* Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
|
|
82
|
+
*
|
|
83
|
+
* Asked before better-auth's `user.role`, because in an app that authorizes
|
|
84
|
+
* on scopes that column is a projection rather than the model — it exists so
|
|
85
|
+
* better-auth's own admin endpoints have something to read, is written by
|
|
86
|
+
* whatever keeps it in step, and is absent entirely from an app that declares
|
|
87
|
+
* no such field. A persona verified against it is verified against a copy.
|
|
88
|
+
*/
|
|
89
|
+
rolesRpc?: string | false
|
|
72
90
|
/**
|
|
73
91
|
* Default model a persona thinks with when `converse(...)` is called without
|
|
74
92
|
* an explicit `model`. Its own turns/approvals/evaluation run in-process via
|
|
@@ -106,7 +124,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
106
124
|
if (config.operator) {
|
|
107
125
|
this.signIn = new OperatorSignIn(config.apiUrl, {
|
|
108
126
|
...config.operator,
|
|
109
|
-
signInPath:
|
|
127
|
+
signInPath:
|
|
128
|
+
config.operator.signInPath ??
|
|
129
|
+
(authMount(config.signInPath)
|
|
130
|
+
? `${authMount(config.signInPath)}/sign-in/fabric`
|
|
131
|
+
: undefined),
|
|
110
132
|
})
|
|
111
133
|
} else if (config.secret) {
|
|
112
134
|
this.signIn = new ActorSignIn(
|
|
@@ -197,17 +219,51 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
197
219
|
/**
|
|
198
220
|
* The roles the stage says this session holds.
|
|
199
221
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* comma-separated list. A target that answers
|
|
203
|
-
*
|
|
204
|
-
*
|
|
222
|
+
* {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
|
|
223
|
+
* `get-session` — where its admin plugin puts `role` on the user, as a
|
|
224
|
+
* comma-separated list. A target that answers neither returns `null` rather
|
|
225
|
+
* than an empty list, because "this stage does not report roles" and "this
|
|
226
|
+
* person has none" call for opposite responses from the caller.
|
|
205
227
|
*/
|
|
206
228
|
async sessionRoles(): Promise<string[] | null> {
|
|
207
229
|
if (!this.signedIn) {
|
|
208
230
|
await this.login()
|
|
209
231
|
}
|
|
210
|
-
const
|
|
232
|
+
const fromRpc = await this.rolesFromRpc()
|
|
233
|
+
if (fromRpc) return fromRpc
|
|
234
|
+
return await this.rolesFromSession()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The caller's own roles, from the app's own RPC. `null` for every answer
|
|
239
|
+
* that is not a role list — an app without the RPC 404s here, which is a
|
|
240
|
+
* reason to go on and ask better-auth, not a reason to report "none".
|
|
241
|
+
*/
|
|
242
|
+
private async rolesFromRpc(): Promise<string[] | null> {
|
|
243
|
+
const rpcName = this.config.rolesRpc ?? 'getMyScopes'
|
|
244
|
+
if (rpcName === false) return null
|
|
245
|
+
let res: Response
|
|
246
|
+
try {
|
|
247
|
+
res = await this.postRpc(rpcName, {})
|
|
248
|
+
} catch {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
if (!res.ok) return null
|
|
252
|
+
const { body } = await readScenarioHttpResponse<{
|
|
253
|
+
roles?: unknown
|
|
254
|
+
data?: { roles?: unknown }
|
|
255
|
+
}>(res)
|
|
256
|
+
const roles = body?.roles ?? body?.data?.roles
|
|
257
|
+
if (!Array.isArray(roles)) return null
|
|
258
|
+
return roles.filter((name): name is string => typeof name === 'string')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async rolesFromSession(): Promise<string[] | null> {
|
|
262
|
+
const mount = authMount(
|
|
263
|
+
this.config.operator?.signInPath ?? this.config.signInPath
|
|
264
|
+
)
|
|
265
|
+
const sessionPath =
|
|
266
|
+
this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`
|
|
211
267
|
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
|
|
212
268
|
headers: this.signIn.headers(),
|
|
213
269
|
})
|
|
@@ -240,19 +296,30 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
240
296
|
return user ? [] : null
|
|
241
297
|
}
|
|
242
298
|
|
|
243
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* Start/continue the target agent's run over HTTP as this persona.
|
|
301
|
+
*
|
|
302
|
+
* The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
|
|
303
|
+
* run before it sends a single byte, so a run longer than the client's
|
|
304
|
+
* headers timeout — 300s in undici, which is what Node and Bun both use —
|
|
305
|
+
* fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
|
|
306
|
+
* stage that is down. An agent that talks for several minutes, which is the
|
|
307
|
+
* normal case for anything conversational, cannot be driven that way at all.
|
|
308
|
+
* The stream sends its first event immediately and the run's length stops
|
|
309
|
+
* mattering.
|
|
310
|
+
*/
|
|
244
311
|
private async agentRun(
|
|
245
312
|
agentName: string,
|
|
246
313
|
message: string,
|
|
247
314
|
threadId: string,
|
|
248
315
|
resourceId: string
|
|
249
316
|
): Promise<TargetAgentReply> {
|
|
250
|
-
const
|
|
317
|
+
const res = await this.sendAgent(`agent/${agentName}/stream`, {
|
|
251
318
|
message,
|
|
252
319
|
threadId,
|
|
253
320
|
resourceId,
|
|
254
321
|
})
|
|
255
|
-
return
|
|
322
|
+
return await collectAgentStream(res)
|
|
256
323
|
}
|
|
257
324
|
|
|
258
325
|
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
@@ -269,7 +336,7 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
269
336
|
}
|
|
270
337
|
|
|
271
338
|
// knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
|
|
272
|
-
private async
|
|
339
|
+
private async sendAgent(subPath: string, body: unknown): Promise<Response> {
|
|
273
340
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
274
341
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
275
342
|
const send = () =>
|
|
@@ -294,6 +361,11 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
294
361
|
`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`
|
|
295
362
|
)
|
|
296
363
|
}
|
|
364
|
+
return res
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
368
|
+
const res = await this.sendAgent(subPath, body)
|
|
297
369
|
if (res.status === 204) return undefined
|
|
298
370
|
const text = await res.text()
|
|
299
371
|
return text ? JSON.parse(text) : undefined
|
|
@@ -328,6 +400,77 @@ export class HttpPersona implements ScenarioPersona {
|
|
|
328
400
|
}
|
|
329
401
|
}
|
|
330
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Reduce the agent's SSE run into the same reply shape the plain route returns.
|
|
405
|
+
*
|
|
406
|
+
* `RUN_ERROR` is raised rather than returned: the plain route answers a failed
|
|
407
|
+
* run with a non-2xx, and a scenario that read an error as an empty transcript
|
|
408
|
+
* would score the agent on silence it never produced.
|
|
409
|
+
*/
|
|
410
|
+
async function collectAgentStream(res: Response): Promise<TargetAgentReply> {
|
|
411
|
+
const body = res.body
|
|
412
|
+
if (!body) {
|
|
413
|
+
throw new Error('[scenario] the agent stream carried no body')
|
|
414
|
+
}
|
|
415
|
+
const decoder = new TextDecoder()
|
|
416
|
+
const reader = body.getReader()
|
|
417
|
+
let buffer = ''
|
|
418
|
+
let text = ''
|
|
419
|
+
let runId = ''
|
|
420
|
+
const pendingApprovals: TargetPendingApproval[] = []
|
|
421
|
+
|
|
422
|
+
const consume = (line: string) => {
|
|
423
|
+
if (!line.startsWith('data:')) return
|
|
424
|
+
const payload = line.slice(5).trim()
|
|
425
|
+
if (!payload) return
|
|
426
|
+
let event: Record<string, unknown>
|
|
427
|
+
try {
|
|
428
|
+
event = JSON.parse(payload)
|
|
429
|
+
} catch {
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (typeof event.runId === 'string' && event.runId) runId = event.runId
|
|
433
|
+
if (
|
|
434
|
+
event.type === 'TEXT_MESSAGE_CONTENT' &&
|
|
435
|
+
typeof event.delta === 'string'
|
|
436
|
+
) {
|
|
437
|
+
text += event.delta
|
|
438
|
+
} else if (event.type === 'approval-request') {
|
|
439
|
+
pendingApprovals.push({
|
|
440
|
+
toolCallId: String(event.toolCallId),
|
|
441
|
+
toolName: String(event.toolName),
|
|
442
|
+
args: event.args,
|
|
443
|
+
reason: typeof event.reason === 'string' ? event.reason : undefined,
|
|
444
|
+
})
|
|
445
|
+
} else if (event.type === 'RUN_ERROR' || event.type === 'error') {
|
|
446
|
+
const message = event.message ?? event.errorText ?? 'the agent run failed'
|
|
447
|
+
throw new Error(`[scenario] agent run failed: ${String(message)}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
for (;;) {
|
|
453
|
+
const { done, value } = await reader.read()
|
|
454
|
+
if (done) break
|
|
455
|
+
buffer += decoder.decode(value, { stream: true })
|
|
456
|
+
const lines = buffer.split('\n')
|
|
457
|
+
buffer = lines.pop() ?? ''
|
|
458
|
+
for (const line of lines) consume(line)
|
|
459
|
+
}
|
|
460
|
+
if (buffer) consume(buffer)
|
|
461
|
+
} finally {
|
|
462
|
+
await reader.cancel().catch(() => {})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
text,
|
|
467
|
+
runId,
|
|
468
|
+
status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
|
|
469
|
+
pendingApprovals:
|
|
470
|
+
pendingApprovals.length > 0 ? pendingApprovals : undefined,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
331
474
|
/** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
|
|
332
475
|
function normalizeAgentReply(raw: unknown): TargetAgentReply {
|
|
333
476
|
const r = (raw ?? {}) as Record<string, unknown>
|
|
@@ -14,7 +14,7 @@ const OPERATOR_TOKEN = 'operator.jwt.token'
|
|
|
14
14
|
*/
|
|
15
15
|
const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
16
16
|
const users = [...seeded]
|
|
17
|
-
|
|
17
|
+
const sentBodies: unknown[] = []
|
|
18
18
|
const server: Server = createServer((req, res) => {
|
|
19
19
|
const chunks: Buffer[] = []
|
|
20
20
|
req.on('data', (c) => chunks.push(c))
|
|
@@ -31,21 +31,15 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
31
31
|
}
|
|
32
32
|
let actAs: { userId: string } | undefined
|
|
33
33
|
if (body.actAs) {
|
|
34
|
-
|
|
34
|
+
sentBodies.push(body.actAs)
|
|
35
|
+
const user = users.find((u) => u.email === body.actAs.email)
|
|
35
36
|
if (!user) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
)
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
created++
|
|
47
|
-
user = { id: `made-${created}`, email: body.actAs.email }
|
|
48
|
-
users.push(user)
|
|
37
|
+
res.writeHead(404).end(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
message: `No account on this stage for ${body.actAs.email}`,
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
return
|
|
49
43
|
}
|
|
50
44
|
actAs = { userId: user.id }
|
|
51
45
|
}
|
|
@@ -74,8 +68,8 @@ const startStage = async (seeded: Array<{ id: string; email: string }>) => {
|
|
|
74
68
|
return {
|
|
75
69
|
apiUrl: `http://127.0.0.1:${port}/api`,
|
|
76
70
|
server,
|
|
77
|
-
get
|
|
78
|
-
return
|
|
71
|
+
get sentBodies() {
|
|
72
|
+
return sentBodies
|
|
79
73
|
},
|
|
80
74
|
}
|
|
81
75
|
}
|
|
@@ -112,7 +106,6 @@ describe('operator persona sign-in', () => {
|
|
|
112
106
|
}
|
|
113
107
|
assert.equal(result.actingAs, 'user-7')
|
|
114
108
|
assert.match(result.cookie, /session=operator/)
|
|
115
|
-
assert.equal(stage.createdCount, 0)
|
|
116
109
|
})
|
|
117
110
|
|
|
118
111
|
// An app that mounts auth somewhere other than the root moves both sign-in
|
|
@@ -151,24 +144,25 @@ describe('operator persona sign-in', () => {
|
|
|
151
144
|
() => personas.customer!.invoke('whoami', {}),
|
|
152
145
|
/operator sign-in failed for 'customer' \(404\)/
|
|
153
146
|
)
|
|
154
|
-
assert.equal(stage.createdCount, 0)
|
|
155
147
|
})
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
// Provisioning moved into the stage, so the handshake carries an address and
|
|
150
|
+
// nothing else. A `create` flag or a role list here would be the caller
|
|
151
|
+
// deciding what the stage holds, which is the arrangement this replaced.
|
|
152
|
+
test('asks only to act as an address', async () => {
|
|
153
|
+
const stage = await startStage([
|
|
154
|
+
{ id: 'user-3', email: 'customer@personas.invalid' },
|
|
155
|
+
])
|
|
159
156
|
servers.push(stage.server)
|
|
160
157
|
|
|
161
158
|
const personas = createHttpPersonas({
|
|
162
159
|
apiUrl: stage.apiUrl,
|
|
163
|
-
operator: { token: OPERATOR_TOKEN
|
|
164
|
-
personas: { customer: persona('
|
|
160
|
+
operator: { token: OPERATOR_TOKEN },
|
|
161
|
+
personas: { customer: persona('customer@personas.invalid') },
|
|
165
162
|
})
|
|
166
163
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
assert.equal(result.actingAs, 'made-1')
|
|
171
|
-
assert.equal(stage.createdCount, 1)
|
|
164
|
+
await personas.customer!.invoke('whoami', {})
|
|
165
|
+
assert.deepEqual(stage.sentBodies, [{ email: 'customer@personas.invalid' }])
|
|
172
166
|
})
|
|
173
167
|
|
|
174
168
|
test('mints the operator session from a token factory', async () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveActorSecret } from './persona-actor-secret.js'
|
|
2
|
+
import { PikkuError } from '../errors/error-handler.js'
|
|
2
3
|
import type { ResolvedPersona } from './personas-service.js'
|
|
3
4
|
import type { ScenarioCookieJar } from '../wirings/workflow/scenario-cookie-jar.js'
|
|
4
5
|
|
|
@@ -30,13 +31,19 @@ export interface PersonaSignIn {
|
|
|
30
31
|
headers(): Record<string, string>
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
|
|
36
|
+
* this message alone: an expired token or a persona the stage has never seen is
|
|
37
|
+
* something to go and fix, and a stack trace through the fetch internals only
|
|
38
|
+
* buries the status and the body that say which one it is.
|
|
39
|
+
*/
|
|
33
40
|
const failed = async (
|
|
34
41
|
what: string,
|
|
35
42
|
personaId: string,
|
|
36
43
|
res: Response
|
|
37
44
|
): Promise<Error> => {
|
|
38
45
|
const body = (await res.text().catch(() => '')).slice(0, 300)
|
|
39
|
-
return new
|
|
46
|
+
return new PikkuError(
|
|
40
47
|
`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`
|
|
41
48
|
)
|
|
42
49
|
}
|
|
@@ -101,22 +108,28 @@ export class ActorSignIn implements PersonaSignIn {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The auth mount a configured sign-in path sits under, or `undefined` when it
|
|
113
|
+
* names nothing recognisable.
|
|
114
|
+
*
|
|
115
|
+
* better-auth serves sign-in, operator sign-in and `get-session` from one
|
|
116
|
+
* prefix, so an app that mounts it at `/api/auth` moves all three together and
|
|
117
|
+
* says so once through `signInPath`. Reading the other two from a hardcoded
|
|
118
|
+
* `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
|
|
119
|
+
* stage does not report roles", which silently turns off the check that tells a
|
|
120
|
+
* permissions finding from seed drift.
|
|
121
|
+
*/
|
|
122
|
+
export const authMount = (signInPath?: string): string | undefined => {
|
|
123
|
+
const mount = signInPath ? signInPath.lastIndexOf('/sign-in/') : -1
|
|
124
|
+
return !signInPath || mount === -1 ? undefined : signInPath.slice(0, mount)
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
export interface OperatorSignInOptions {
|
|
105
128
|
/**
|
|
106
129
|
* The short-lived RS256 operator token, or a function that mints one. Prefer
|
|
107
130
|
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
108
131
|
*/
|
|
109
132
|
token: string | (() => string | Promise<string>)
|
|
110
|
-
/**
|
|
111
|
-
* Create the persona's user row when the target has no account for that
|
|
112
|
-
* address.
|
|
113
|
-
*
|
|
114
|
-
* Off by default, which is the whole point of the deployed path: a persona is
|
|
115
|
-
* meant to be a real account somebody provisioned, and a test run that
|
|
116
|
-
* silently writes users into a live database is a side effect nobody asked
|
|
117
|
-
* for. Turn it on for throwaway stages.
|
|
118
|
-
*/
|
|
119
|
-
createMissing?: boolean
|
|
120
133
|
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
121
134
|
signInPath?: string
|
|
122
135
|
}
|
|
@@ -155,12 +168,7 @@ export const establishOperatorSession = async (
|
|
|
155
168
|
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
156
169
|
body: JSON.stringify({
|
|
157
170
|
token,
|
|
158
|
-
actAs: {
|
|
159
|
-
email: persona.email,
|
|
160
|
-
name: persona.name,
|
|
161
|
-
create: options.createMissing ?? false,
|
|
162
|
-
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
163
|
-
},
|
|
171
|
+
actAs: { email: persona.email },
|
|
164
172
|
}),
|
|
165
173
|
})
|
|
166
174
|
if (!res.ok) {
|
|
@@ -168,7 +176,7 @@ export const establishOperatorSession = async (
|
|
|
168
176
|
}
|
|
169
177
|
const setCookies = res.headers.getSetCookie?.() ?? []
|
|
170
178
|
if (setCookies.length === 0) {
|
|
171
|
-
throw new
|
|
179
|
+
throw new PikkuError(
|
|
172
180
|
`[scenario] operator sign-in for '${persona.id}' returned no session cookie`
|
|
173
181
|
)
|
|
174
182
|
}
|
|
@@ -178,7 +186,7 @@ export const establishOperatorSession = async (
|
|
|
178
186
|
} | null
|
|
179
187
|
const userId = body?.actAs?.userId
|
|
180
188
|
if (!userId) {
|
|
181
|
-
throw new
|
|
189
|
+
throw new PikkuError(
|
|
182
190
|
`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
|
|
183
191
|
'the target is running a @pikku/better-auth too old to resolve one'
|
|
184
192
|
)
|