@pikku/core 0.12.100 → 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 CHANGED
@@ -1,3 +1,27 @@
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
+
1
25
  ## 0.12.100
2
26
 
3
27
  ### Patch Changes
@@ -49,6 +49,17 @@ export interface HttpPersonasConfig {
49
49
  sessionPath?: string;
50
50
  /** Exposed-RPC path prefix under apiUrl. Default `/rpc`. */
51
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;
52
63
  /**
53
64
  * Default model a persona thinks with when `converse(...)` is called without
54
65
  * an explicit `model`. Its own turns/approvals/evaluation run in-process via
@@ -86,17 +97,36 @@ export declare class HttpPersona implements ScenarioPersona {
86
97
  /**
87
98
  * The roles the stage says this session holds.
88
99
  *
89
- * Read from better-auth's `get-session`, which is what most pikku apps are
90
- * running and where its admin plugin puts `role` on the user, as a
91
- * comma-separated list. A target that answers something else returns `null`
92
- * rather than an empty list, because "this stage does not report roles" and
93
- * "this person has none" call for opposite responses from the caller.
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.
94
105
  */
95
106
  sessionRoles(): Promise<string[] | null>;
96
- /** Start/continue the target agent's run over HTTP as this persona. */
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
+ */
97
126
  private agentRun;
98
127
  /** Answer the target agent's pending approvals over HTTP and continue. */
99
128
  private agentApprove;
129
+ private sendAgent;
100
130
  private postAgent;
101
131
  private postRpc;
102
132
  /** Drop the session, so the next call signs in again before it goes out. */
@@ -108,16 +108,46 @@ export class HttpPersona {
108
108
  /**
109
109
  * The roles the stage says this session holds.
110
110
  *
111
- * Read from better-auth's `get-session`, which is what most pikku apps are
112
- * running and where its admin plugin puts `role` on the user, as a
113
- * comma-separated list. A target that answers something else returns `null`
114
- * rather than an empty list, because "this stage does not report roles" and
115
- * "this person has none" call for opposite responses from the caller.
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.
116
116
  */
117
117
  async sessionRoles() {
118
118
  if (!this.signedIn) {
119
119
  await this.login();
120
120
  }
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() {
121
151
  const mount = authMount(this.config.operator?.signInPath ?? this.config.signInPath);
122
152
  const sessionPath = this.config.sessionPath ?? `${mount ?? '/auth'}/get-session`;
123
153
  const res = await this.jar.fetch(`${this.config.apiUrl}${sessionPath}`, {
@@ -152,14 +182,25 @@ export class HttpPersona {
152
182
  // stage reporting "none", not a stage that cannot report.
153
183
  return user ? [] : null;
154
184
  }
155
- /** Start/continue the target agent's run over HTTP as this persona. */
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
+ */
156
197
  async agentRun(agentName, message, threadId, resourceId) {
157
- const raw = await this.postAgent(`agent/${agentName}`, {
198
+ const res = await this.sendAgent(`agent/${agentName}/stream`, {
158
199
  message,
159
200
  threadId,
160
201
  resourceId,
161
202
  });
162
- return normalizeAgentReply(raw);
203
+ return await collectAgentStream(res);
163
204
  }
164
205
  /** Answer the target agent's pending approvals over HTTP and continue. */
165
206
  async agentApprove(agentName, runId, decisions) {
@@ -170,7 +211,7 @@ export class HttpPersona {
170
211
  return normalizeAgentReply(raw);
171
212
  }
172
213
  // knowledge: decisions/internals/scenario-agent-calls-sign-in-on-401-only.md
173
- async postAgent(subPath, body) {
214
+ async sendAgent(subPath, body) {
174
215
  const rpcPath = this.config.rpcPath ?? '/rpc';
175
216
  const url = `${this.config.apiUrl}${rpcPath}/${subPath}`;
176
217
  const send = () => this.jar.fetch(url, {
@@ -191,6 +232,10 @@ export class HttpPersona {
191
232
  const text = (await res.text().catch(() => '')).slice(0, 300);
192
233
  throw new Error(`[scenario] agent call '${subPath}' as '${this.name}' returned ${res.status}: ${text}`);
193
234
  }
235
+ return res;
236
+ }
237
+ async postAgent(subPath, body) {
238
+ const res = await this.sendAgent(subPath, body);
194
239
  if (res.status === 204)
195
240
  return undefined;
196
241
  const text = await res.text();
@@ -218,6 +263,80 @@ export class HttpPersona {
218
263
  this.signedIn = true;
219
264
  }
220
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
+ }
221
340
  /** Normalize an agentRun/agentApprove HTTP response into a TargetAgentReply. */
222
341
  function normalizeAgentReply(raw) {
223
342
  const r = (raw ?? {});
@@ -71,16 +71,6 @@ export interface OperatorSignInOptions {
71
71
  * the function: tokens expire, and a long run re-logs-in after a 401.
72
72
  */
73
73
  token: string | (() => string | Promise<string>);
74
- /**
75
- * Create the persona's user row when the target has no account for that
76
- * address.
77
- *
78
- * Off by default, which is the whole point of the deployed path: a persona is
79
- * meant to be a real account somebody provisioned, and a test run that
80
- * silently writes users into a live database is a side effect nobody asked
81
- * for. Turn it on for throwaway stages.
82
- */
83
- createMissing?: boolean;
84
74
  /** Fabric operator sign-in path under apiUrl. Default `/auth/sign-in/fabric`. */
85
75
  signInPath?: string;
86
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 Error(`[scenario] ${what} failed for '${personaId}' (${res.status}): ${body}`);
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
@@ -93,12 +100,7 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
93
100
  headers: { 'content-type': 'application/json', ...extraHeaders },
94
101
  body: JSON.stringify({
95
102
  token,
96
- actAs: {
97
- email: persona.email,
98
- name: persona.name,
99
- create: options.createMissing ?? false,
100
- ...(persona.roles[0] ? { role: persona.roles[0] } : {}),
101
- },
103
+ actAs: { email: persona.email },
102
104
  }),
103
105
  });
104
106
  if (!res.ok) {
@@ -106,12 +108,12 @@ export const establishOperatorSession = async (fetchImpl, apiUrl, persona, optio
106
108
  }
107
109
  const setCookies = res.headers.getSetCookie?.() ?? [];
108
110
  if (setCookies.length === 0) {
109
- throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
111
+ throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no session cookie`);
110
112
  }
111
113
  const body = (await res.json().catch(() => null));
112
114
  const userId = body?.actAs?.userId;
113
115
  if (!userId) {
114
- throw new Error(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
116
+ throw new PikkuError(`[scenario] operator sign-in for '${persona.id}' returned no user to act as — ` +
115
117
  'the target is running a @pikku/better-auth too old to resolve one');
116
118
  }
117
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
- // An expected PikkuError's message is written to be the whole output.
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
+ };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.100",
3
+ "version": "0.12.101",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -57,28 +57,37 @@ const startAgentTarget = async () => {
57
57
  json({ runId: body.runId, text: 'Created it.', status: 'completed' })
58
58
  return
59
59
  }
60
- if (req.url === '/api/rpc/agent/todoBot') {
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
- if (agentRuns === 1) {
63
- json({
64
- runId: 'run-1',
65
- text: 'Let me do that.',
66
- status: 'suspended',
67
- pendingApprovals: [
68
- {
69
- toolCallId: 'tc1',
70
- toolName: 'createTodo',
71
- args: { title: 'x' },
72
- },
73
- ],
74
- })
75
- return
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
- json({
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()
@@ -66,6 +66,16 @@ const startTarget = async () => {
66
66
  return
67
67
  }
68
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
+ }
69
79
  if (rpcName === 'html-error') {
70
80
  res
71
81
  .writeHead(500, { 'content-type': 'text/html' })
@@ -261,12 +271,68 @@ describe('HttpPersona', async () => {
261
271
  // mount in `apiUrl`, so it moves `signInPath` instead. The session read has
262
272
  // to follow it: a 404 there reads as 'this stage does not report roles' and
263
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.
264
277
  test('reads the session from the mount the sign-in path names', async () => {
265
278
  const actors = createHttpPersonas({
266
279
  apiUrl: target.origin,
267
280
  secret: ROOT,
268
281
  signInPath: '/api/auth/sign-in/actor',
269
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',
270
336
  personas: {
271
337
  manager: {
272
338
  id: 'manager',