@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
## 0.12.101
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- e92e30b: Print a CLI failure as its message, not as a JS stack trace.
|
|
6
|
+
|
|
7
|
+
Every error that reached the top of `executeCLI` was logged with `console.error('Error:', error)`, which node renders as the full stack — and prefixed it a second time, so a refusal read `Error: Error: Persona 'guest' missing guest…` above ten frames of pikku internals. A `PikkuFetchError` was worse: node inspects an error's own properties, so the whole `Response` came out with it, headers and body stream included, to say `502`.
|
|
8
|
+
|
|
9
|
+
An expected failure — a `PikkuError`, or anything carrying `expected: true` — now prints its message alone, and a fetch failure prints `502 Bad Gateway from <url>` without touching the response. Anything else keeps its stack, because a `TypeError` with its frames removed is undiagnosable. `--verbose`/`-v`, or `PIKKU_DEBUG=1` where the flag cannot be typed, adds the stack back to an expected failure.
|
|
10
|
+
|
|
11
|
+
The refusals behind the examples — a persona whose roles have drifted, a sign-in the stage rejected — are raised as `PikkuError` so they are classed as deliberate.
|
|
12
|
+
|
|
13
|
+
- 781797c: Drive a persona's agent turn over the SSE route. The plain `POST /rpc/agent/:name` buffers the entire run before sending a byte, so any run longer than undici's 300s headers timeout failed with `UND_ERR_HEADERS_TIMEOUT` — which is most conversational agents.
|
|
14
|
+
- ccab6ed: Verify a persona's roles against the app's own `getMyScopes` RPC before better-auth's `user.role`. In an app that authorizes on scopes that column is a projection — kept in step for better-auth's own admin endpoints, and absent entirely from an app that declares no such field — so a persona holding exactly what it should was refused for "roles drifted". Configurable via `rolesRpc`; `false` reads better-auth only.
|
|
15
|
+
- 4d0a548: Provision the declared personas from the fabric plugin instead of the server lifecycle.
|
|
16
|
+
|
|
17
|
+
`provisionPersonas` was documented as a call an app makes from `pikkuServerLifecycle`'s `afterStart`. That hook is invoked by `pikku serve` and `pikku dev` and by nothing else — no deploy runtime calls it — so on any stage deployed to Workers or a serverless target the provisioning never ran, and every persona signed in holding no roles.
|
|
18
|
+
|
|
19
|
+
`pikkuFabric` now takes `personas`. The operator endpoint resolves the address the caller wants to act as; a miss provisions the declaration and looks again. On a stage that already holds the persona that is one query, and the pass only runs when there is genuinely something absent to create.
|
|
20
|
+
|
|
21
|
+
Sign-in no longer creates accounts of its own. `OperatorSignInOptions.createMissing` and `PIKKU_PERSONA_CREATE_MISSING` are gone, and an address no declaration claims stays a 404 however many times it is asked for. `provisionPersonas` is no longer exported — the plugin is the only caller.
|
|
22
|
+
|
|
23
|
+
`pikku persona sync <environment>` is unchanged: it still reports who an environment will provision and why anyone was skipped, and still writes nothing.
|
|
24
|
+
|
|
25
|
+
## 0.12.100
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- a0ed1e8: Derive a persona's session and operator paths from the mount its sign-in path names.
|
|
30
|
+
|
|
31
|
+
Both `sessionRoles()` and the Fabric operator handshake asked for a fixed
|
|
32
|
+
`/auth/…` no matter where auth was mounted. An app serving better-auth under
|
|
33
|
+
`/api/auth` while keeping its RPCs at the root cannot put the mount in
|
|
34
|
+
`apiUrl`, so it moves `signInPath` — and the other two stayed behind.
|
|
35
|
+
|
|
36
|
+
For the session read that meant a 404, which returns `null`, which means "this
|
|
37
|
+
stage does not report roles": every `pikku persona run` on such an app warned
|
|
38
|
+
"running unverified" and lost the one thing that tells a permissions finding
|
|
39
|
+
from seed drift. For the operator handshake it was worse — `HttpPersona`
|
|
40
|
+
reused the _actor_ path verbatim, so an operator token was posted to the actor
|
|
41
|
+
endpoint and came back as a validation error about a missing email and secret,
|
|
42
|
+
which reads like a broken persona rather than a wrong URL. The browser provider
|
|
43
|
+
had the same fixed default.
|
|
44
|
+
|
|
45
|
+
All three now follow `signInPath`, and `environments[].sessionPath` in
|
|
46
|
+
pikku.config.json overrides the session read for a stage that reports it
|
|
47
|
+
elsewhere.
|
|
48
|
+
|
|
1
49
|
## 0.12.99
|
|
2
50
|
|
|
3
51
|
### Patch Changes
|
|
@@ -40,10 +40,26 @@ export interface HttpPersonasConfig {
|
|
|
40
40
|
* {@link OperatorSignInOptions.signInPath} overrides it.
|
|
41
41
|
*/
|
|
42
42
|
signInPath?: string;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Where the session (and its roles) is read back. Defaults to `get-session`
|
|
45
|
+
* under the same auth mount as {@link HttpPersonasConfig.signInPath}, so an
|
|
46
|
+
* app that moved auth under `/api` moves this with it and does not have to
|
|
47
|
+
* say so twice.
|
|
48
|
+
*/
|
|
44
49
|
sessionPath?: string;
|
|
45
50
|
/** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
|
|
46
51
|
rpcPath?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Exposed RPC that reports the CALLER's own roles, as `{ roles: string[] }`.
|
|
54
|
+
* Default `getMyScopes`. Pass `false` to skip it and read better-auth only.
|
|
55
|
+
*
|
|
56
|
+
* Asked before better-auth's `user.role`, because in an app that authorizes
|
|
57
|
+
* on scopes that column is a projection rather than the model — it exists so
|
|
58
|
+
* better-auth's own admin endpoints have something to read, is written by
|
|
59
|
+
* whatever keeps it in step, and is absent entirely from an app that declares
|
|
60
|
+
* no such field. A persona verified against it is verified against a copy.
|
|
61
|
+
*/
|
|
62
|
+
rolesRpc?: string | false;
|
|
47
63
|
/**
|
|
48
64
|
* Default model a persona thinks with when `converse(...)` is called without
|
|
49
65
|
* an explicit `model`. Its own turns/approvals/evaluation run in-process via
|
|
@@ -81,17 +97,36 @@ export declare class HttpPersona implements ScenarioPersona {
|
|
|
81
97
|
/**
|
|
82
98
|
* The roles the stage says this session holds.
|
|
83
99
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* comma-separated list. A target that answers
|
|
87
|
-
*
|
|
88
|
-
*
|
|
100
|
+
* {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
|
|
101
|
+
* `get-session` — where its admin plugin puts `role` on the user, as a
|
|
102
|
+
* comma-separated list. A target that answers neither returns `null` rather
|
|
103
|
+
* than an empty list, because "this stage does not report roles" and "this
|
|
104
|
+
* person has none" call for opposite responses from the caller.
|
|
89
105
|
*/
|
|
90
106
|
sessionRoles(): Promise<string[] | null>;
|
|
91
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The caller's own roles, from the app's own RPC. `null` for every answer
|
|
109
|
+
* that is not a role list — an app without the RPC 404s here, which is a
|
|
110
|
+
* reason to go on and ask better-auth, not a reason to report "none".
|
|
111
|
+
*/
|
|
112
|
+
private rolesFromRpc;
|
|
113
|
+
private rolesFromSession;
|
|
114
|
+
/**
|
|
115
|
+
* Start/continue the target agent's run over HTTP as this persona.
|
|
116
|
+
*
|
|
117
|
+
* The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
|
|
118
|
+
* run before it sends a single byte, so a run longer than the client's
|
|
119
|
+
* headers timeout — 300s in undici, which is what Node and Bun both use —
|
|
120
|
+
* fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
|
|
121
|
+
* stage that is down. An agent that talks for several minutes, which is the
|
|
122
|
+
* normal case for anything conversational, cannot be driven that way at all.
|
|
123
|
+
* The stream sends its first event immediately and the run's length stops
|
|
124
|
+
* mattering.
|
|
125
|
+
*/
|
|
92
126
|
private agentRun;
|
|
93
127
|
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
94
128
|
private agentApprove;
|
|
129
|
+
private sendAgent;
|
|
95
130
|
private postAgent;
|
|
96
131
|
private postRpc;
|
|
97
132
|
/** Drop the session, so the next call signs in again before it goes out. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readScenarioHttpResponse } from './personas-service.js';
|
|
2
2
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js';
|
|
3
3
|
import { createCookieJar, } from '../wirings/workflow/scenario-cookie-jar.js';
|
|
4
|
-
import { ActorSignIn, OperatorSignIn, } from './persona-sign-in.js';
|
|
4
|
+
import { ActorSignIn, OperatorSignIn, authMount, } from './persona-sign-in.js';
|
|
5
5
|
import { getSingletonServices } from '../pikku-state.js';
|
|
6
6
|
import { AIProviderNotConfiguredError } from '../errors/errors.js';
|
|
7
7
|
/**
|
|
@@ -34,7 +34,10 @@ export class HttpPersona {
|
|
|
34
34
|
if (config.operator) {
|
|
35
35
|
this.signIn = new OperatorSignIn(config.apiUrl, {
|
|
36
36
|
...config.operator,
|
|
37
|
-
signInPath: config.operator.signInPath ??
|
|
37
|
+
signInPath: config.operator.signInPath ??
|
|
38
|
+
(authMount(config.signInPath)
|
|
39
|
+
? `${authMount(config.signInPath)}/sign-in/fabric`
|
|
40
|
+
: undefined),
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
else if (config.secret) {
|
|
@@ -105,17 +108,48 @@ export class HttpPersona {
|
|
|
105
108
|
/**
|
|
106
109
|
* The roles the stage says this session holds.
|
|
107
110
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* comma-separated list. A target that answers
|
|
111
|
-
*
|
|
112
|
-
*
|
|
111
|
+
* {@link HttpPersonasConfig.rolesRpc} first, then better-auth's
|
|
112
|
+
* `get-session` — where its admin plugin puts `role` on the user, as a
|
|
113
|
+
* comma-separated list. A target that answers neither returns `null` rather
|
|
114
|
+
* than an empty list, because "this stage does not report roles" and "this
|
|
115
|
+
* person has none" call for opposite responses from the caller.
|
|
113
116
|
*/
|
|
114
117
|
async sessionRoles() {
|
|
115
118
|
if (!this.signedIn) {
|
|
116
119
|
await this.login();
|
|
117
120
|
}
|
|
118
|
-
const
|
|
121
|
+
const fromRpc = await this.rolesFromRpc();
|
|
122
|
+
if (fromRpc)
|
|
123
|
+
return fromRpc;
|
|
124
|
+
return await this.rolesFromSession();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The caller's own roles, from the app's own RPC. `null` for every answer
|
|
128
|
+
* that is not a role list — an app without the RPC 404s here, which is a
|
|
129
|
+
* reason to go on and ask better-auth, not a reason to report "none".
|
|
130
|
+
*/
|
|
131
|
+
async rolesFromRpc() {
|
|
132
|
+
const rpcName = this.config.rolesRpc ?? 'getMyScopes';
|
|
133
|
+
if (rpcName === false)
|
|
134
|
+
return null;
|
|
135
|
+
let res;
|
|
136
|
+
try {
|
|
137
|
+
res = await this.postRpc(rpcName, {});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
return null;
|
|
144
|
+
const { body } = await readScenarioHttpResponse(res);
|
|
145
|
+
const roles = body?.roles ?? body?.data?.roles;
|
|
146
|
+
if (!Array.isArray(roles))
|
|
147
|
+
return null;
|
|
148
|
+
return roles.filter((name) => typeof name === 'string');
|
|
149
|
+
}
|
|
150
|
+
async rolesFromSession() {
|
|
151
|
+
const mount = authMount(this.config.operator?.signInPath ?? this.config.signInPath);
|
|
152
|
+
const sessionPath = this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`;
|
|
119
153
|
const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
|
|
120
154
|
headers: this.signIn.headers(),
|
|
121
155
|
});
|
|
@@ -148,14 +182,25 @@ export class HttpPersona {
|
|
|
148
182
|
// stage reporting "none", not a stage that cannot report.
|
|
149
183
|
return user ? [] : null;
|
|
150
184
|
}
|
|
151
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* Start/continue the target agent's run over HTTP as this persona.
|
|
187
|
+
*
|
|
188
|
+
* The SSE route, not the plain one. `POST /rpc/agent/:name` buffers the whole
|
|
189
|
+
* run before it sends a single byte, so a run longer than the client's
|
|
190
|
+
* headers timeout — 300s in undici, which is what Node and Bun both use —
|
|
191
|
+
* fails with `UND_ERR_HEADERS_TIMEOUT` and no way to tell it apart from a
|
|
192
|
+
* stage that is down. An agent that talks for several minutes, which is the
|
|
193
|
+
* normal case for anything conversational, cannot be driven that way at all.
|
|
194
|
+
* The stream sends its first event immediately and the run's length stops
|
|
195
|
+
* mattering.
|
|
196
|
+
*/
|
|
152
197
|
async agentRun(agentName, message, threadId, resourceId) {
|
|
153
|
-
const
|
|
198
|
+
const res = await this.sendAgent(`agent/${agentName}/stream`, {
|
|
154
199
|
message,
|
|
155
200
|
threadId,
|
|
156
201
|
resourceId,
|
|
157
202
|
});
|
|
158
|
-
return
|
|
203
|
+
return await collectAgentStream(res);
|
|
159
204
|
}
|
|
160
205
|
/** Answer the target agent's pending approvals over HTTP and continue. */
|
|
161
206
|
async agentApprove(agentName, runId, decisions) {
|
|
@@ -166,7 +211,7 @@ export class HttpPersona {
|
|
|
166
211
|
return normalizeAgentReply(raw);
|
|
167
212
|
}
|
|
168
213
|
// knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
|
|
169
|
-
async
|
|
214
|
+
async sendAgent(subPath, body) {
|
|
170
215
|
const rpcPath = this.config.rpcPath ?? '/rpc';
|
|
171
216
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
|
|
172
217
|
const send = () => this.jar.fetch(url, {
|
|
@@ -187,6 +232,10 @@ export class HttpPersona {
|
|
|
187
232
|
const text = (await res.text().catch(() => '')).slice(0, 300);
|
|
188
233
|
throw new Error(`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`);
|
|
189
234
|
}
|
|
235
|
+
return res;
|
|
236
|
+
}
|
|
237
|
+
async postAgent(subPath, body) {
|
|
238
|
+
const res = await this.sendAgent(subPath, body);
|
|
190
239
|
if (res.status === 204)
|
|
191
240
|
return undefined;
|
|
192
241
|
const text = await res.text();
|
|
@@ -214,6 +263,80 @@ export class HttpPersona {
|
|
|
214
263
|
this.signedIn = true;
|
|
215
264
|
}
|
|
216
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Reduce the agent's SSE run into the same reply shape the plain route returns.
|
|
268
|
+
*
|
|
269
|
+
* `RUN_ERROR` is raised rather than returned: the plain route answers a failed
|
|
270
|
+
* run with a non-2xx, and a scenario that read an error as an empty transcript
|
|
271
|
+
* would score the agent on silence it never produced.
|
|
272
|
+
*/
|
|
273
|
+
async function collectAgentStream(res) {
|
|
274
|
+
const body = res.body;
|
|
275
|
+
if (!body) {
|
|
276
|
+
throw new Error('[scenario] the agent stream carried no body');
|
|
277
|
+
}
|
|
278
|
+
const decoder = new TextDecoder();
|
|
279
|
+
const reader = body.getReader();
|
|
280
|
+
let buffer = '';
|
|
281
|
+
let text = '';
|
|
282
|
+
let runId = '';
|
|
283
|
+
const pendingApprovals = [];
|
|
284
|
+
const consume = (line) => {
|
|
285
|
+
if (!line.startsWith('data:'))
|
|
286
|
+
return;
|
|
287
|
+
const payload = line.slice(5).trim();
|
|
288
|
+
if (!payload)
|
|
289
|
+
return;
|
|
290
|
+
let event;
|
|
291
|
+
try {
|
|
292
|
+
event = JSON.parse(payload);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (typeof event.runId === 'string' && event.runId)
|
|
298
|
+
runId = event.runId;
|
|
299
|
+
if (event.type === 'TEXT_MESSAGE_CONTENT' &&
|
|
300
|
+
typeof event.delta === 'string') {
|
|
301
|
+
text += event.delta;
|
|
302
|
+
}
|
|
303
|
+
else if (event.type === 'approval-request') {
|
|
304
|
+
pendingApprovals.push({
|
|
305
|
+
toolCallId: String(event.toolCallId),
|
|
306
|
+
toolName: String(event.toolName),
|
|
307
|
+
args: event.args,
|
|
308
|
+
reason: typeof event.reason === 'string' ? event.reason : undefined,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
else if (event.type === 'RUN_ERROR' || event.type === 'error') {
|
|
312
|
+
const message = event.message ?? event.errorText ?? 'the agent run failed';
|
|
313
|
+
throw new Error(`[scenario] agent run failed: ${String(message)}`);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
for (;;) {
|
|
318
|
+
const { done, value } = await reader.read();
|
|
319
|
+
if (done)
|
|
320
|
+
break;
|
|
321
|
+
buffer += decoder.decode(value, { stream: true });
|
|
322
|
+
const lines = buffer.split('\n');
|
|
323
|
+
buffer = lines.pop() ?? '';
|
|
324
|
+
for (const line of lines)
|
|
325
|
+
consume(line);
|
|
326
|
+
}
|
|
327
|
+
if (buffer)
|
|
328
|
+
consume(buffer);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await reader.cancel().catch(() => { });
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
text,
|
|
335
|
+
runId,
|
|
336
|
+
status: pendingApprovals.length > 0 ? 'suspended' : 'completed',
|
|
337
|
+
pendingApprovals: pendingApprovals.length > 0 ? pendingApprovals : undefined,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
217
340
|
/** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
|
|
218
341
|
function normalizeAgentReply(raw) {
|
|
219
342
|
const r = (raw ?? {});
|
|
@@ -53,22 +53,24 @@ export declare class ActorSignIn implements PersonaSignIn {
|
|
|
53
53
|
login(jar: ScenarioCookieJar, persona: ResolvedPersona): Promise<void>;
|
|
54
54
|
headers(): Record<string, string>;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The auth mount a configured sign-in path sits under, or `undefined` when it
|
|
58
|
+
* names nothing recognisable.
|
|
59
|
+
*
|
|
60
|
+
* better-auth serves sign-in, operator sign-in and `get-session` from one
|
|
61
|
+
* prefix, so an app that mounts it at `/api/auth` moves all three together and
|
|
62
|
+
* says so once through `signInPath`. Reading the other two from a hardcoded
|
|
63
|
+
* `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
|
|
64
|
+
* stage does not report roles", which silently turns off the check that tells a
|
|
65
|
+
* permissions finding from seed drift.
|
|
66
|
+
*/
|
|
67
|
+
export declare const authMount: (signInPath?: string) => string | undefined;
|
|
56
68
|
export interface OperatorSignInOptions {
|
|
57
69
|
/**
|
|
58
70
|
* The short-lived RS256 operator token, or a function that mints one. Prefer
|
|
59
71
|
* the function: tokens expire, and a long run re-logs-in after a 401.
|
|
60
72
|
*/
|
|
61
73
|
token: string | (() => string | Promise<string>);
|
|
62
|
-
/**
|
|
63
|
-
* Create the persona's user row when the target has no account for that
|
|
64
|
-
* address.
|
|
65
|
-
*
|
|
66
|
-
* Off by default, which is the whole point of the deployed path: a persona is
|
|
67
|
-
* meant to be a real account somebody provisioned, and a test run that
|
|
68
|
-
* silently writes users into a live database is a side effect nobody asked
|
|
69
|
-
* for. Turn it on for throwaway stages.
|
|
70
|
-
*/
|
|
71
|
-
createMissing?: boolean;
|
|
72
74
|
/** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
|
|
73
75
|
signInPath?: string;
|
|
74
76
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveActorSecret } from './persona-actor-secret.js';
|
|
2
|
+
import { PikkuError } from '../errors/error-handler.js';
|
|
2
3
|
/**
|
|
3
4
|
* The header `resolveImpersonatedSession` reads the target user id from.
|
|
4
5
|
*
|
|
@@ -7,9 +8,15 @@ import { deriveActorSecret } from './persona-actor-secret.js';
|
|
|
7
8
|
* it back. The two agree by protocol, the way an HTTP header always does.
|
|
8
9
|
*/
|
|
9
10
|
export const IMPERSONATE_USER_ID_HEADER = 'x-pikku-impersonate-user-id';
|
|
11
|
+
/**
|
|
12
|
+
* A sign-in the target refused. `PikkuError`, not `Error`, so the CLI prints
|
|
13
|
+
* this message alone: an expired token or a persona the stage has never seen is
|
|
14
|
+
* something to go and fix, and a stack trace through the fetch internals only
|
|
15
|
+
* buries the status and the body that say which one it is.
|
|
16
|
+
*/
|
|
10
17
|
const failed = async (what, personaId, res) => {
|
|
11
18
|
const body = (await res.text().catch(() => '')).slice(0, 300);
|
|
12
|
-
return new
|
|
19
|
+
return new PikkuError(`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`);
|
|
13
20
|
};
|
|
14
21
|
/**
|
|
15
22
|
* Sign a persona in through the Better Auth actor plugin — the local-development
|
|
@@ -60,6 +67,21 @@ export class ActorSignIn {
|
|
|
60
67
|
return {};
|
|
61
68
|
}
|
|
62
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The auth mount a configured sign-in path sits under, or `undefined` when it
|
|
72
|
+
* names nothing recognisable.
|
|
73
|
+
*
|
|
74
|
+
* better-auth serves sign-in, operator sign-in and `get-session` from one
|
|
75
|
+
* prefix, so an app that mounts it at `/api/auth` moves all three together and
|
|
76
|
+
* says so once through `signInPath`. Reading the other two from a hardcoded
|
|
77
|
+
* `/auth` on such an app 404s — and for `get-session` a 404 reads as "this
|
|
78
|
+
* stage does not report roles", which silently turns off the check that tells a
|
|
79
|
+
* permissions finding from seed drift.
|
|
80
|
+
*/
|
|
81
|
+
export const authMount = (signInPath) => {
|
|
82
|
+
const mount = signInPath ? signInPath.lastIndexOf('/sign-in/') : -1;
|
|
83
|
+
return !signInPath || mount === -1 ? undefined : signInPath.slice(0, mount);
|
|
84
|
+
};
|
|
63
85
|
/**
|
|
64
86
|
* Establish a Fabric operator session against `apiUrl` and resolve the target's
|
|
65
87
|
* own id for `persona`, which is what the impersonation header names.
|
|
@@ -78,12 +100,7 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
|
|
|
78
100
|
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
79
101
|
body: JSON.stringify({
|
|
80
102
|
token,
|
|
81
|
-
actAs: {
|
|
82
|
-
email: persona.email,
|
|
83
|
-
name: persona.name,
|
|
84
|
-
create: options.createMissing ?? false,
|
|
85
|
-
...(persona.roles[0] ? { role: persona.roles[0] } : {}),
|
|
86
|
-
},
|
|
103
|
+
actAs: { email: persona.email },
|
|
87
104
|
}),
|
|
88
105
|
});
|
|
89
106
|
if (!res.ok) {
|
|
@@ -91,12 +108,12 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
|
|
|
91
108
|
}
|
|
92
109
|
const setCookies = res.headers.getSetCookie?.() ?? [];
|
|
93
110
|
if (setCookies.length === 0) {
|
|
94
|
-
throw new
|
|
111
|
+
throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
|
|
95
112
|
}
|
|
96
113
|
const body = (await res.json().catch(() => null));
|
|
97
114
|
const userId = body?.actAs?.userId;
|
|
98
115
|
if (!userId) {
|
|
99
|
-
throw new
|
|
116
|
+
throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
|
|
100
117
|
'the target is running a @pikku/better-auth too old to resolve one');
|
|
101
118
|
}
|
|
102
119
|
return { setCookies, userId: String(userId) };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { NotFoundError } from '../../errors/errors.js';
|
|
2
|
-
import { isExpectedError } from '../../errors/error-handler.js';
|
|
3
2
|
import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
|
|
4
3
|
import { pikkuState } from '../../pikku-state.js';
|
|
5
4
|
import { unsupportedChannelRemote } from '../channel/channel-rpc.types.js';
|
|
6
5
|
import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
|
|
7
6
|
import { LocalVariablesService } from '../../services/local-variables.js';
|
|
8
7
|
import { generateCommandHelp, parseCLIArguments } from './command-parser.js';
|
|
8
|
+
import { formatCLIError, wantsStackTrace } from './format-cli-error.js';
|
|
9
9
|
/** The caller is expected to catch this and call `process.exit(exitCode)`. */
|
|
10
10
|
export class CLIError extends Error {
|
|
11
11
|
exitCode;
|
|
@@ -323,16 +323,7 @@ export async function executeCLI({ programName, args, createConfig, createSingle
|
|
|
323
323
|
if (error instanceof CLIError) {
|
|
324
324
|
throw error;
|
|
325
325
|
}
|
|
326
|
-
|
|
327
|
-
if (isExpectedError(error)) {
|
|
328
|
-
console.error(error.message);
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
console.error('Error:', error);
|
|
332
|
-
}
|
|
333
|
-
if (args.includes('--verbose') || args.includes('-v')) {
|
|
334
|
-
console.error('Stack trace:', error.stack);
|
|
335
|
-
}
|
|
326
|
+
console.error(formatCLIError(error, { verbose: wantsStackTrace(args) }));
|
|
336
327
|
throw new CLIError(error.message || String(error), 1);
|
|
337
328
|
}
|
|
338
329
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user asked to see the machinery. `--verbose`/`-v` is the flag the CLI
|
|
3
|
+
* already documents; `PIKKU_DEBUG` is for the times the flag cannot be typed —
|
|
4
|
+
* a command that does not declare the option, or a CLI run from a script.
|
|
5
|
+
*/
|
|
6
|
+
export declare const wantsStackTrace: (args: string[], env?: Record<string, string | undefined>) => boolean;
|
|
7
|
+
/**
|
|
8
|
+
* What the CLI prints when a command throws.
|
|
9
|
+
*
|
|
10
|
+
* A stack trace is an answer to "which line of pikku broke", and almost every
|
|
11
|
+
* failure a user actually hits is not that question: a missing role, an expired
|
|
12
|
+
* token, a gateway that is down. Those errors are written to be read, so the
|
|
13
|
+
* message alone is the whole output — an expected error is one deliberately
|
|
14
|
+
* raised as `PikkuError` (or carrying `expected: true`), and everything else
|
|
15
|
+
* keeps its stack, because an unexpected `TypeError` with its frames removed is
|
|
16
|
+
* a bug nobody can diagnose.
|
|
17
|
+
*
|
|
18
|
+
* A stack already begins with `Name: message`, so it is returned as-is: the
|
|
19
|
+
* `console.error('Error:', error)` this replaced produced the doubled
|
|
20
|
+
* `Error: Error: …` prefix that made even real traces look broken.
|
|
21
|
+
*/
|
|
22
|
+
export declare const formatCLIError: (error: unknown, { verbose }?: {
|
|
23
|
+
verbose?: boolean;
|
|
24
|
+
}) => string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isExpectedError } from '../../errors/error-handler.js';
|
|
2
|
+
const isFetchFailure = (error) => {
|
|
3
|
+
const candidate = error;
|
|
4
|
+
return (typeof candidate?.status === 'number' &&
|
|
5
|
+
typeof candidate?.statusText === 'string' &&
|
|
6
|
+
typeof candidate?.response === 'object' &&
|
|
7
|
+
candidate.response !== null);
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The user asked to see the machinery. `--verbose`/`-v` is the flag the CLI
|
|
11
|
+
* already documents; `PIKKU_DEBUG` is for the times the flag cannot be typed —
|
|
12
|
+
* a command that does not declare the option, or a CLI run from a script.
|
|
13
|
+
*/
|
|
14
|
+
export const wantsStackTrace = (args, env = process.env) => args.includes('--verbose') ||
|
|
15
|
+
args.includes('-v') ||
|
|
16
|
+
(env.PIKKU_DEBUG !== undefined &&
|
|
17
|
+
env.PIKKU_DEBUG !== '' &&
|
|
18
|
+
env.PIKKU_DEBUG !== '0');
|
|
19
|
+
/**
|
|
20
|
+
* What the CLI prints when a command throws.
|
|
21
|
+
*
|
|
22
|
+
* A stack trace is an answer to "which line of pikku broke", and almost every
|
|
23
|
+
* failure a user actually hits is not that question: a missing role, an expired
|
|
24
|
+
* token, a gateway that is down. Those errors are written to be read, so the
|
|
25
|
+
* message alone is the whole output — an expected error is one deliberately
|
|
26
|
+
* raised as `PikkuError` (or carrying `expected: true`), and everything else
|
|
27
|
+
* keeps its stack, because an unexpected `TypeError` with its frames removed is
|
|
28
|
+
* a bug nobody can diagnose.
|
|
29
|
+
*
|
|
30
|
+
* A stack already begins with `Name: message`, so it is returned as-is: the
|
|
31
|
+
* `console.error('Error:', error)` this replaced produced the doubled
|
|
32
|
+
* `Error: Error: …` prefix that made even real traces look broken.
|
|
33
|
+
*/
|
|
34
|
+
export const formatCLIError = (error, { verbose = false } = {}) => {
|
|
35
|
+
if (isFetchFailure(error)) {
|
|
36
|
+
return formatFetchFailure(error, verbose);
|
|
37
|
+
}
|
|
38
|
+
const stack = error?.stack;
|
|
39
|
+
const message = error?.message;
|
|
40
|
+
if (!isExpectedError(error)) {
|
|
41
|
+
return typeof stack === 'string' && stack ? stack : String(error);
|
|
42
|
+
}
|
|
43
|
+
const text = typeof message === 'string' && message ? message : String(error);
|
|
44
|
+
return verbose && typeof stack === 'string' && stack
|
|
45
|
+
? `${text}\n${stack}`
|
|
46
|
+
: text;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A failed HTTP call, as the line the user needs: which status, from which URL.
|
|
50
|
+
*
|
|
51
|
+
* Never the `Response` itself. Node inspects an error's own properties when it
|
|
52
|
+
* prints one, so a thrown fetch error used to dump the headers, the body stream
|
|
53
|
+
* and the redirect flags — pages of output whose only real content was the
|
|
54
|
+
* status code.
|
|
55
|
+
*/
|
|
56
|
+
const formatFetchFailure = (error, verbose) => {
|
|
57
|
+
const url = error.response?.url;
|
|
58
|
+
const where = url ? ` from ${url}` : '';
|
|
59
|
+
const summary = `${error.status} ${error.statusText}${where}`;
|
|
60
|
+
const message = error.message;
|
|
61
|
+
const text = message && message !== error.statusText
|
|
62
|
+
? `${message}\n ${summary}`
|
|
63
|
+
: summary;
|
|
64
|
+
const stack = error.stack;
|
|
65
|
+
return verbose && typeof stack === 'string' && stack
|
|
66
|
+
? `${text}\n${stack}`
|
|
67
|
+
: text;
|
|
68
|
+
};
|
|
@@ -18,6 +18,6 @@ export type { CorePersona, CorePersonas, PersonaAccountMeta, PersonaDefinitions,
|
|
|
18
18
|
* Lambda deploy would load outright.
|
|
19
19
|
*/
|
|
20
20
|
export { HttpPersona, createHttpPersonas, type HttpPersonasConfig, } from '../../services/http-personas.js';
|
|
21
|
-
export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type ActorSecretResolver, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, } from '../../services/persona-sign-in.js';
|
|
21
|
+
export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, type ActorSecretResolver, type PersonaSignIn, type OperatorSignInOptions, type OperatorSessionResult, authMount, } from '../../services/persona-sign-in.js';
|
|
22
22
|
export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
|
|
23
23
|
export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
|
|
@@ -14,6 +14,6 @@ export { APP_SCOPE_ROOT, appScopeId, buildAppScopeDefinition, } from './persona-
|
|
|
14
14
|
* Lambda deploy would load outright.
|
|
15
15
|
*/
|
|
16
16
|
export { HttpPersona, createHttpPersonas, } from '../../services/http-personas.js';
|
|
17
|
-
export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, } from '../../services/persona-sign-in.js';
|
|
17
|
+
export { ActorSignIn, OperatorSignIn, establishOperatorSession, IMPERSONATE_USER_ID_HEADER, authMount, } from '../../services/persona-sign-in.js';
|
|
18
18
|
export { ACTOR_ROOT_SECRET_MIN_LENGTH, ACTOR_SECRET_INFO, ACTOR_SECRET_NAME, actorSecretSubject, deriveActorSecret, verifyActorSecret, } from '../../services/persona-actor-secret.js';
|
|
19
19
|
export { postScenarioJson, readScenarioHttpResponse, } from '../../services/personas-service.js';
|
|
@@ -57,7 +57,6 @@ export declare const VIRTUAL_USER_VARIABLES: {
|
|
|
57
57
|
* fallback for a run nobody handed a token to, which is what a schedule is.
|
|
58
58
|
*/
|
|
59
59
|
readonly operatorToken: "FABRIC_OPERATOR_TOKEN";
|
|
60
|
-
readonly createMissing: "PIKKU_PERSONA_CREATE_MISSING";
|
|
61
60
|
};
|
|
62
61
|
/**
|
|
63
62
|
* Which door under the auth mount, given the credential in hand.
|
|
@@ -34,7 +34,6 @@ export const VIRTUAL_USER_VARIABLES = {
|
|
|
34
34
|
* fallback for a run nobody handed a token to, which is what a schedule is.
|
|
35
35
|
*/
|
|
36
36
|
operatorToken: 'FABRIC_OPERATOR_TOKEN',
|
|
37
|
-
createMissing: 'PIKKU_PERSONA_CREATE_MISSING',
|
|
38
37
|
};
|
|
39
38
|
/**
|
|
40
39
|
* Which door under the auth mount, given the credential in hand.
|
|
@@ -330,8 +329,6 @@ export const executeVirtualUserRun = async ({ runStore, metaService, agentRunner
|
|
|
330
329
|
throw new Error(`Neither an operator token nor ${VIRTUAL_USER_VARIABLES.secret} is available — there is nobody for the virtual user to be. ` +
|
|
331
330
|
`Hand a Fabric operator token in with the run against a deployed stage, or export ${VIRTUAL_USER_VARIABLES.secret} against a local \`pikku dev\` target.`);
|
|
332
331
|
}
|
|
333
|
-
const createMissing = String(await variables.get(VIRTUAL_USER_VARIABLES.createMissing)) ===
|
|
334
|
-
'true';
|
|
335
332
|
const model = await variables.get(VIRTUAL_USER_VARIABLES.model);
|
|
336
333
|
if (!model) {
|
|
337
334
|
throw new Error(`${VIRTUAL_USER_VARIABLES.model} is not set — no model to think with.`);
|
|
@@ -362,7 +359,6 @@ export const executeVirtualUserRun = async ({ runStore, metaService, agentRunner
|
|
|
362
359
|
? {
|
|
363
360
|
operator: {
|
|
364
361
|
token,
|
|
365
|
-
createMissing,
|
|
366
362
|
signInPath: signInPathFor(configuredSignInPath, 'fabric'),
|
|
367
363
|
},
|
|
368
364
|
}
|
package/package.json
CHANGED