@pikku/core 0.12.8 → 0.12.9

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/env.d.ts +1 -0
  3. package/dist/env.js +1 -0
  4. package/dist/errors/errors.d.ts +14 -0
  5. package/dist/errors/errors.js +27 -0
  6. package/dist/handle-error.js +2 -1
  7. package/dist/middleware/auth-bearer.js +10 -1
  8. package/dist/middleware/auth-cookie.js +34 -25
  9. package/dist/middleware/timeout.js +11 -5
  10. package/dist/pikku-state.js +1 -1
  11. package/dist/services/local-content.d.ts +7 -4
  12. package/dist/services/local-content.js +42 -12
  13. package/dist/services/local-secrets.js +2 -2
  14. package/dist/testing/service-tests.js +1 -1
  15. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  16. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  17. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  18. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  19. package/dist/wirings/http/http-runner.js +4 -2
  20. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  21. package/dist/wirings/workflow/graph/graph-runner.d.ts +7 -0
  22. package/dist/wirings/workflow/graph/graph-runner.js +72 -7
  23. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
  24. package/dist/wirings/workflow/pikku-workflow-service.js +59 -16
  25. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  26. package/package.json +1 -1
  27. package/src/env.ts +1 -0
  28. package/src/errors/errors.ts +35 -0
  29. package/src/handle-error.ts +2 -1
  30. package/src/middleware/auth-bearer.ts +10 -1
  31. package/src/middleware/auth-cookie.ts +11 -4
  32. package/src/middleware/timeout.ts +14 -7
  33. package/src/pikku-state.ts +1 -1
  34. package/src/services/local-content.ts +61 -13
  35. package/src/services/local-secrets.test.ts +2 -2
  36. package/src/services/local-secrets.ts +2 -2
  37. package/src/testing/service-tests.ts +1 -1
  38. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  39. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  40. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  41. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  42. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  43. package/src/wirings/http/http-runner.ts +4 -2
  44. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  45. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  46. package/src/wirings/workflow/graph/graph-runner.ts +84 -7
  47. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  48. package/src/wirings/workflow/pikku-workflow-service.ts +95 -25
  49. package/src/wirings/workflow/workflow.types.ts +4 -0
  50. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  ## 0.12.4
2
2
 
3
+ ## 0.12.9
4
+
5
+ ### Patch Changes
6
+
7
+ - e412b4d: Replace raw Error throws in AI agent runner/stream/prepare with typed PikkuError subclasses. `AIProviderNotConfiguredError` (503) replaces "AIAgentRunnerService not available" with a user-friendly message. `AIProviderAuthError` (401) available for API key validation errors.
8
+ - 53dc8c8: Fix toWebRequest to respect x-forwarded-proto and x-forwarded-host headers behind reverse proxies. Previously always used http:// which broke OAuth callback URLs behind TLS-terminating proxies like Fly.io.
9
+ - 0a1cc51: Add secure defaults for cookie authentication: httpOnly, secure, sameSite 'lax', and path '/'. User-provided options override these defaults.
10
+ - 0a1cc51: Prevent internal error details from leaking to clients. Stack traces via exposeErrors are now blocked in production. SSE and WebSocket error handlers use registered error responses instead of raw error messages. Secret key names and route paths are no longer included in error messages.
11
+ - 0a1cc51: Cap form-urlencoded parameters at 256 to prevent abuse via unbounded parameter parsing.
12
+ - 0a1cc51: Add path traversal protection to LocalContent file operations. Asset keys are now validated to stay within the configured upload directory.
13
+ - 0a1cc51: Use private Symbol for global pikku state key to prevent external code from accessing framework internals via Symbol.for().
14
+ - 0a1cc51: Filter out **proto**, constructor, and prototype keys during request data merging to prevent prototype pollution.
15
+ - 0a1cc51: Improve LocalContent URL signing with proper signedAt/expiresAt parameters. When an optional JWTService is provided, URLs include a cryptographic signature for verification.
16
+ - 0a1cc51: Fix timeout middleware to use Promise.race instead of throwing inside setTimeout, which caused uncatchable exceptions that crashed the process.
17
+ - 0a1cc51: Use constant-time comparison for static bearer token authentication to prevent timing side-channel attacks.
18
+ - 8b9b2e9: Fix child workflow completion in queued execution mode. When a sub-workflow completes, the parent step is now marked as succeeded and the parent orchestrator resumes automatically via `onChildWorkflowCompleted`. Adds `parentStepId` to `WorkflowRunWire` to track the parent step without querying. Retains advisory locks in PgKyselyWorkflowService for concurrency safety. Fixes pgboss `registerQueues` to accept an optional logger parameter.
19
+ - 8b9b2e9: Add debug-level logging to workflow service for step scheduling, execution, and orchestration to aid troubleshooting.
20
+ - b973d44: Add `inline` property to workflow function definitions. When `inline: true` is set on a workflow, it always executes inline without dispatching to the queue service, even when a queue service is available. This is useful for workflows that should run synchronously within the parent process (e.g. scaffolding/setup steps that produce local files).
21
+
22
+ The flag flows from the function definition through the inspector, into the serialized workflow graph, and is checked at runtime by the workflow service.
23
+
24
+ - 8b9b2e9: Strip undefined values from workflow step data before dispatching to the queue service, preventing postgres UNDEFINED_VALUE errors.
25
+ - 8b9b2e9: Support sub-workflow invocation in graph-based workflow steps. When a step's rpcName refers to a registered workflow instead of an RPC function, `executeGraphStep` now starts it as a child workflow and polls for completion. Respects the `inline` meta flag on the sub-workflow.
26
+
3
27
  ## 0.12.8
4
28
 
5
29
  ### Patch Changes
package/dist/env.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const isProduction: () => boolean;
package/dist/env.js ADDED
@@ -0,0 +1 @@
1
+ export const isProduction = () => process.env.NODE_ENV === 'production';
@@ -204,3 +204,17 @@ export declare class MaxComputeTimeReachedError extends PikkuError {
204
204
  */
205
205
  export declare class MissingSchemaError extends PikkuError {
206
206
  }
207
+ /**
208
+ * An AI provider has not been configured. Set up an AI provider (e.g. OpenAI, Anthropic) to use agent features.
209
+ * @group Error
210
+ */
211
+ export declare class AIProviderNotConfiguredError extends PikkuError {
212
+ constructor();
213
+ }
214
+ /**
215
+ * The AI provider API key is missing or invalid.
216
+ * @group Error
217
+ */
218
+ export declare class AIProviderAuthError extends PikkuError {
219
+ constructor(message?: string);
220
+ }
@@ -347,3 +347,30 @@ addError(MissingSchemaError, {
347
347
  status: 500,
348
348
  message: 'A required schema was not found. Ensure schema generation has been run.',
349
349
  });
350
+ /**
351
+ * An AI provider has not been configured. Set up an AI provider (e.g. OpenAI, Anthropic) to use agent features.
352
+ * @group Error
353
+ */
354
+ export class AIProviderNotConfiguredError extends PikkuError {
355
+ constructor() {
356
+ super('No AI provider configured. Please set up an AI provider (e.g. OpenAI, Anthropic) and provide a valid API key to use this agent.');
357
+ }
358
+ }
359
+ addError(AIProviderNotConfiguredError, {
360
+ status: 503,
361
+ message: 'No AI provider configured. Please set up an AI provider (e.g. OpenAI, Anthropic) and provide a valid API key to use this agent.',
362
+ });
363
+ /**
364
+ * The AI provider API key is missing or invalid.
365
+ * @group Error
366
+ */
367
+ export class AIProviderAuthError extends PikkuError {
368
+ constructor(message) {
369
+ super(message ||
370
+ 'AI provider API key is missing or invalid. Please check your API key configuration.');
371
+ }
372
+ }
373
+ addError(AIProviderAuthError, {
374
+ status: 401,
375
+ message: 'AI provider API key is missing or invalid. Please check your API key configuration.',
376
+ });
@@ -1,3 +1,4 @@
1
+ import { isProduction } from './env.js';
1
2
  import { getErrorResponse } from './errors/error-handler.js';
2
3
  import { NotFoundError } from './errors/errors.js';
3
4
  /**
@@ -41,7 +42,7 @@ export const handleHTTPError = (e, http, trackerId, logger, logWarningsForStatus
41
42
  if (trackerId) {
42
43
  logger.warn(`Error id: ${trackerId}`);
43
44
  const errorBody = { errorId: trackerId };
44
- if (exposeErrors && e instanceof Error) {
45
+ if (exposeErrors && !isProduction() && e instanceof Error) {
45
46
  errorBody.message = e.message;
46
47
  errorBody.stack = e.stack;
47
48
  }
@@ -1,5 +1,14 @@
1
1
  import { InvalidSessionError } from '../errors/errors.js';
2
2
  import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js';
3
+ const constantTimeEqual = (a, b) => {
4
+ if (a.length !== b.length)
5
+ return false;
6
+ let result = 0;
7
+ for (let i = 0; i < a.length; i++) {
8
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
9
+ }
10
+ return result === 0;
11
+ };
3
12
  /**
4
13
  * Bearer token middleware that extracts and validates tokens from the Authorization header.
5
14
  *
@@ -41,7 +50,7 @@ export const authBearer = pikkuMiddlewareFactory(({ token } = {}) => pikkuMiddle
41
50
  throw new InvalidSessionError();
42
51
  }
43
52
  let userSession = null;
44
- if (token && bearerToken === token.value) {
53
+ if (token && constantTimeEqual(bearerToken, token.value)) {
45
54
  userSession = token.userSession;
46
55
  }
47
56
  else if (jwtService && !token) {
@@ -28,31 +28,40 @@ import { getRelativeTimeOffsetFromNow } from '../time-utils.js';
28
28
  * ])
29
29
  * ```
30
30
  */
31
- export const authCookie = pikkuMiddlewareFactory(({ name, options, expiresIn }) => pikkuMiddleware(async ({ jwt: jwtService, logger }, { http, setSession, getSession, session, hasSessionChanged }, next) => {
32
- if (!http?.request || !setSession || session) {
33
- return next();
34
- }
35
- const cookieValue = http.request.cookie(name);
36
- if (cookieValue && jwtService) {
37
- const userSession = await jwtService.decode(cookieValue);
38
- if (userSession) {
39
- setSession?.(userSession);
31
+ export const authCookie = pikkuMiddlewareFactory(({ name, options, expiresIn }) => {
32
+ const mergedOptions = {
33
+ httpOnly: true,
34
+ secure: true,
35
+ sameSite: 'lax',
36
+ path: '/',
37
+ ...options,
38
+ };
39
+ return pikkuMiddleware(async ({ jwt: jwtService, logger }, { http, setSession, getSession, session, hasSessionChanged }, next) => {
40
+ if (!http?.request || !setSession || session) {
41
+ return next();
40
42
  }
41
- }
42
- await next();
43
- if (!http?.response) {
44
- return;
45
- }
46
- if (hasSessionChanged?.()) {
47
- const currentSession = await getSession?.();
48
- if (jwtService) {
49
- http.response.cookie(name, await jwtService.encode(expiresIn, currentSession), {
50
- ...options,
51
- expires: getRelativeTimeOffsetFromNow(expiresIn),
52
- });
43
+ const cookieValue = http.request.cookie(name);
44
+ if (cookieValue && jwtService) {
45
+ const userSession = await jwtService.decode(cookieValue);
46
+ if (userSession) {
47
+ setSession?.(userSession);
48
+ }
53
49
  }
54
- else {
55
- logger.warn('No JWT service available, unable to set cookie');
50
+ await next();
51
+ if (!http?.response) {
52
+ return;
56
53
  }
57
- }
58
- }));
54
+ if (hasSessionChanged?.()) {
55
+ const currentSession = await getSession?.();
56
+ if (jwtService) {
57
+ http.response.cookie(name, await jwtService.encode(expiresIn, currentSession), {
58
+ ...mergedOptions,
59
+ expires: getRelativeTimeOffsetFromNow(expiresIn),
60
+ });
61
+ }
62
+ else {
63
+ logger.warn('No JWT service available, unable to set cookie');
64
+ }
65
+ }
66
+ });
67
+ });
@@ -1,9 +1,15 @@
1
1
  import { RequestTimeoutError } from '../errors/errors.js';
2
2
  import { pikkuMiddleware, pikkuMiddlewareFactory } from '../types/core.types.js';
3
3
  export const timeout = () => pikkuMiddlewareFactory(({ timeout }) => pikkuMiddleware(async (_services, _wire, next) => {
4
- const timeoutId = setTimeout(() => {
5
- throw new RequestTimeoutError();
6
- }, timeout);
7
- await next();
8
- clearTimeout(timeoutId);
4
+ let timeoutId;
5
+ await Promise.race([
6
+ next(),
7
+ new Promise((_resolve, reject) => {
8
+ timeoutId = setTimeout(() => reject(new RequestTimeoutError()), timeout);
9
+ }),
10
+ ]).finally(() => {
11
+ if (timeoutId !== undefined) {
12
+ clearTimeout(timeoutId);
13
+ }
14
+ });
9
15
  }));
@@ -1,4 +1,4 @@
1
- const PIKKU_STATE_KEY = Symbol.for('@pikku/core/state');
1
+ const PIKKU_STATE_KEY = Symbol('@pikku/core/state');
2
2
  export const getAllPackageStates = () => {
3
3
  if (!globalThis[PIKKU_STATE_KEY]) {
4
4
  ;
@@ -1,4 +1,4 @@
1
- import type { ContentService, Logger } from '@pikku/core/services';
1
+ import type { ContentService, JWTService, Logger } from '@pikku/core/services';
2
2
  export interface LocalContentConfig {
3
3
  localFileUploadPath: string;
4
4
  uploadUrlPrefix: string;
@@ -9,10 +9,13 @@ export interface LocalContentConfig {
9
9
  export declare class LocalContent implements ContentService {
10
10
  private config;
11
11
  private logger;
12
- constructor(config: LocalContentConfig, logger: Logger);
12
+ private jwt?;
13
+ constructor(config: LocalContentConfig, logger: Logger, jwt?: JWTService | undefined);
14
+ private safePath;
13
15
  init(): Promise<void>;
14
- signURL(url: string): Promise<string>;
15
- signContentKey(assetKey: string): Promise<string>;
16
+ private signParams;
17
+ signURL(url: string, dateLessThan: Date, dateGreaterThan?: Date): Promise<string>;
18
+ signContentKey(assetKey: string, dateLessThan: Date, dateGreaterThan?: Date): Promise<string>;
16
19
  getUploadURL(assetKey: string): Promise<{
17
20
  uploadUrl: string;
18
21
  assetKey: string;
@@ -1,21 +1,51 @@
1
1
  import { createReadStream, createWriteStream, promises } from 'fs';
2
2
  import { mkdir, readFile } from 'fs/promises';
3
+ import { resolve, normalize } from 'path';
3
4
  import { pipeline } from 'stream/promises';
4
5
  export class LocalContent {
5
6
  config;
6
7
  logger;
7
- constructor(config, logger) {
8
+ jwt;
9
+ constructor(config, logger, jwt) {
8
10
  this.config = config;
9
11
  this.logger = logger;
12
+ this.jwt = jwt;
13
+ }
14
+ safePath(assetKey) {
15
+ const base = resolve(this.config.localFileUploadPath);
16
+ const target = resolve(base, normalize(assetKey));
17
+ if (!target.startsWith(base + '/') && target !== base) {
18
+ throw new Error('Invalid asset key');
19
+ }
20
+ return target;
10
21
  }
11
22
  async init() { }
12
- async signURL(url) {
13
- return `${url}?signed=true`;
23
+ async signParams(dateLessThan, dateGreaterThan) {
24
+ const signedAt = Date.now();
25
+ const expiresAt = dateLessThan.getTime();
26
+ const params = new URLSearchParams({
27
+ signedAt: String(signedAt),
28
+ expiresAt: String(expiresAt),
29
+ });
30
+ if (dateGreaterThan) {
31
+ params.set('notBefore', String(dateGreaterThan.getTime()));
32
+ }
33
+ if (this.jwt) {
34
+ const expiresInSeconds = Math.max(1, Math.floor((expiresAt - signedAt) / 1000));
35
+ const signature = await this.jwt.encode({ value: expiresInSeconds, unit: 'second' }, { signedAt, expiresAt });
36
+ params.set('signature', signature);
37
+ }
38
+ return params.toString();
39
+ }
40
+ async signURL(url, dateLessThan, dateGreaterThan) {
41
+ const params = await this.signParams(dateLessThan, dateGreaterThan);
42
+ return `${url}?${params}`;
14
43
  }
15
- async signContentKey(assetKey) {
16
- return this.config.server
17
- ? `${this.config.server}${this.config.assetUrlPrefix}/${assetKey}?signed=true`
18
- : `${this.config.assetUrlPrefix}/${assetKey}?signed=true`;
44
+ async signContentKey(assetKey, dateLessThan, dateGreaterThan) {
45
+ const base = this.config.server
46
+ ? `${this.config.server}${this.config.assetUrlPrefix}/${assetKey}`
47
+ : `${this.config.assetUrlPrefix}/${assetKey}`;
48
+ return this.signURL(base, dateLessThan, dateGreaterThan);
19
49
  }
20
50
  async getUploadURL(assetKey) {
21
51
  this.logger.debug(`Going to upload with key: ${assetKey}`);
@@ -26,7 +56,7 @@ export class LocalContent {
26
56
  }
27
57
  async writeFile(assetKey, stream) {
28
58
  this.logger.debug(`Writing file: ${assetKey}`);
29
- const path = `${this.config.localFileUploadPath}/${assetKey}`;
59
+ const path = this.safePath(assetKey);
30
60
  try {
31
61
  await this.createDirectoryForFile(path);
32
62
  const fileStream = createWriteStream(path);
@@ -43,7 +73,7 @@ export class LocalContent {
43
73
  async copyFile(assetKey, fromAbsolutePath) {
44
74
  this.logger.debug(`Writing file: ${assetKey}`);
45
75
  try {
46
- const path = `${this.config.localFileUploadPath}/${assetKey}`;
76
+ const path = this.safePath(assetKey);
47
77
  await this.createDirectoryForFile(path);
48
78
  await promises.copyFile(fromAbsolutePath, path);
49
79
  }
@@ -55,7 +85,7 @@ export class LocalContent {
55
85
  }
56
86
  async readFile(assetKey) {
57
87
  this.logger.debug(`Getting key: ${assetKey}`);
58
- const filePath = `${this.config.localFileUploadPath}/${assetKey}`;
88
+ const filePath = this.safePath(assetKey);
59
89
  try {
60
90
  const stream = createReadStream(filePath);
61
91
  // Handle early stream errors (like file not found, permission denied, etc.)
@@ -70,14 +100,14 @@ export class LocalContent {
70
100
  }
71
101
  }
72
102
  async readFileAsBuffer(assetKey) {
73
- const filePath = `${this.config.localFileUploadPath}/${assetKey}`;
103
+ const filePath = this.safePath(assetKey);
74
104
  this.logger.debug(`Reading file as buffer: ${assetKey}`);
75
105
  return readFile(filePath);
76
106
  }
77
107
  async deleteFile(assetKey) {
78
108
  this.logger.debug(`deleting key: ${assetKey}`);
79
109
  try {
80
- await promises.unlink(`${this.config.localFileUploadPath}/${assetKey}`);
110
+ await promises.unlink(this.safePath(assetKey));
81
111
  return true;
82
112
  }
83
113
  catch (e) {
@@ -30,7 +30,7 @@ export class LocalSecretService {
30
30
  if (value) {
31
31
  return JSON.parse(value);
32
32
  }
33
- throw new Error(`Secret Not Found: ${key}`);
33
+ throw new Error('Requested secret not found');
34
34
  }
35
35
  /**
36
36
  * Retrieves a secret by key.
@@ -50,7 +50,7 @@ export class LocalSecretService {
50
50
  if (value) {
51
51
  return value;
52
52
  }
53
- throw new Error(`Secret Not Found: ${key}`);
53
+ throw new Error('Requested secret not found');
54
54
  }
55
55
  /**
56
56
  * Stores a JSON value as a secret in local storage.
@@ -523,7 +523,7 @@ export function defineServiceTests(config) {
523
523
  test('getSecret throws for missing key', async () => {
524
524
  const service = await factory({ key: kek });
525
525
  await assert.rejects(() => service.getSecret('nonexistent'), {
526
- message: 'Secret not found: nonexistent',
526
+ message: 'Requested secret not found',
527
527
  });
528
528
  });
529
529
  test('setSecretJSON upserts existing key', async () => {
@@ -1,4 +1,5 @@
1
1
  import { PikkuError } from '../../errors/error-handler.js';
2
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
2
3
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
3
4
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
4
5
  import { randomUUID } from 'crypto';
@@ -354,7 +355,7 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
354
355
  const { agent, packageName, resolvedName } = resolveAgent(agentName);
355
356
  const agentRunner = singletonServices.aiAgentRunner;
356
357
  if (!agentRunner) {
357
- throw new Error('AIAgentRunnerService not available in singletonServices');
358
+ throw new AIProviderNotConfiguredError();
358
359
  }
359
360
  if (agent.dynamicWorkflows && singletonServices.workflowService) {
360
361
  const persisted = await singletonServices.workflowService.getAIGeneratedWorkflows(resolvedName);
@@ -3,6 +3,7 @@ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, } from
3
3
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js';
4
4
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
5
5
  import { resolveModelConfig } from './ai-agent-model-config.js';
6
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
6
7
  import { randomUUID } from 'crypto';
7
8
  export async function runAIAgent(agentName, input, params, agentSessionMap) {
8
9
  const sessionMap = agentSessionMap ?? new Map();
@@ -262,7 +263,7 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
262
263
  const memoryConfig = agent.memory;
263
264
  const agentRunner = singletonServices.aiAgentRunner;
264
265
  if (!agentRunner) {
265
- throw new Error('AIAgentRunnerService not available');
266
+ throw new AIProviderNotConfiguredError();
266
267
  }
267
268
  const approvedIds = new Set(approvals.filter((a) => a.approved).map((a) => a.toolCallId));
268
269
  const rejectedIds = new Set(approvals.filter((a) => !a.approved).map((a) => a.toolCallId));
@@ -1,4 +1,5 @@
1
1
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
2
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
2
3
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
3
4
  import { randomUUID } from 'crypto';
4
5
  import { parseWorkingMemory, deepMergeWorkingMemory, resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
@@ -471,7 +472,7 @@ export async function resumeAIAgent(input, channel, params, options) {
471
472
  const memoryConfig = agent.memory;
472
473
  const agentRunner = singletonServices.aiAgentRunner;
473
474
  if (!agentRunner) {
474
- throw new Error('AIAgentRunnerService not available in singletonServices');
475
+ throw new AIProviderNotConfiguredError();
475
476
  }
476
477
  if (!input.approved) {
477
478
  if (pending.type === 'agent-call') {
@@ -3,6 +3,8 @@ import { createHTTPWire } from '../../http/http-runner.js';
3
3
  import { closeWireServices } from '../../../utils.js';
4
4
  import { processMessageHandlers } from '../channel-handler.js';
5
5
  import { PikkuLocalChannelHandler } from './local-channel-handler.js';
6
+ import { isProduction } from '../../../env.js';
7
+ import { getErrorResponse } from '../../../errors/error-handler.js';
6
8
  import { handleHTTPError } from '../../../handle-error.js';
7
9
  import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../../services/user-session-service.js';
8
10
  import { runChannelLifecycleWithMiddleware } from '../channel-common.js';
@@ -75,9 +77,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
75
77
  }
76
78
  catch (e) {
77
79
  singletonServices.logger.error(`Error handling onConnect: ${e}`);
80
+ const errorResponse = getErrorResponse(e);
78
81
  channel.send({
79
- error: e.message || 'Unknown error',
80
- errorName: e.constructor?.name,
82
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
83
+ ...(!isProduction() && { errorName: e.constructor?.name }),
81
84
  });
82
85
  }
83
86
  }
@@ -97,9 +100,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
97
100
  }
98
101
  catch (e) {
99
102
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`);
103
+ const errorResponse = getErrorResponse(e);
100
104
  channel.send({
101
- error: e.message || 'Unknown error',
102
- errorName: e.constructor?.name,
105
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
106
+ ...(!isProduction() && { errorName: e.constructor?.name }),
103
107
  });
104
108
  }
105
109
  }
@@ -113,9 +117,10 @@ export const runLocalChannel = async ({ channelId, request, response, route, ski
113
117
  }
114
118
  catch (e) {
115
119
  singletonServices.logger.error(e);
120
+ const errorResponse = getErrorResponse(e);
116
121
  channel.send({
117
- error: e.message || 'Unknown error',
118
- errorName: e.constructor?.name,
122
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
123
+ ...(!isProduction() && { errorName: e.constructor?.name }),
119
124
  });
120
125
  setTimeout(() => channel.close(), 200);
121
126
  }
@@ -2,6 +2,7 @@ import { NotFoundError, PikkuMissingMetaError } from '../../errors/errors.js';
2
2
  import { closeWireServices, createWeakUID, isSerializable, } from '../../utils.js';
3
3
  import { getSingletonServices, getCreateWireServices, } from '../../pikku-state.js';
4
4
  import { PikkuSessionService } from '../../services/user-session-service.js';
5
+ import { getErrorResponse } from '../../errors/error-handler.js';
5
6
  import { handleHTTPError } from '../../handle-error.js';
6
7
  import { pikkuState } from '../../pikku-state.js';
7
8
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
@@ -379,7 +380,7 @@ export const fetchData = async (request, response, { skipUserSession = false, re
379
380
  apiRoute,
380
381
  apiType,
381
382
  });
382
- throw new NotFoundError(`Route not found: ${apiRoute}`);
383
+ throw new NotFoundError();
383
384
  }
384
385
  // Execute the matched route along with its middleware and session management
385
386
  ;
@@ -396,9 +397,10 @@ export const fetchData = async (request, response, { skipUserSession = false, re
396
397
  // For SSE routes, send error through the stream since the response is already in stream mode
397
398
  singletonServices.logger.error(e instanceof Error ? e.message : e);
398
399
  try {
400
+ const errorResponse = getErrorResponse(e);
399
401
  response.arrayBuffer(JSON.stringify({
400
402
  type: 'error',
401
- errorText: e instanceof Error ? e.message : String(e),
403
+ errorText: errorResponse?.message ?? 'Internal server error',
402
404
  }));
403
405
  response.arrayBuffer(JSON.stringify({ type: 'done' }));
404
406
  }
@@ -89,6 +89,9 @@ export class PikkuFetchHTTPRequest {
89
89
  const merged = {};
90
90
  for (const part of parts) {
91
91
  for (const [key, value] of Object.entries(part)) {
92
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
93
+ continue;
94
+ }
92
95
  if (key in merged && !valuesAreEquivalent(merged[key], value)) {
93
96
  throw new UnprocessableContentError(`Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`);
94
97
  }
@@ -124,7 +127,14 @@ export class PikkuFetchHTTPRequest {
124
127
  }
125
128
  else if (contentType === 'application/x-www-form-urlencoded') {
126
129
  const text = await this.request.text();
127
- body = Object.fromEntries(new URLSearchParams(text));
130
+ const params = new URLSearchParams(text);
131
+ let count = 0;
132
+ for (const _ of params) {
133
+ if (++count > 256) {
134
+ throw new UnprocessableContentError('Too many form parameters');
135
+ }
136
+ }
137
+ body = Object.fromEntries(params);
128
138
  }
129
139
  else {
130
140
  throw new UnprocessableContentError(`Unsupported content type ${contentType}`);
@@ -1,5 +1,12 @@
1
1
  import type { PikkuWorkflowService } from '../pikku-workflow-service.js';
2
2
  import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js';
3
+ export declare class ChildWorkflowStartedException extends Error {
4
+ parentRunId: string;
5
+ stepId: string;
6
+ childRunId: string;
7
+ name: string;
8
+ constructor(parentRunId: string, stepId: string, childRunId: string);
9
+ }
3
10
  export declare function continueGraph(workflowService: PikkuWorkflowService, runId: string, graphName: string, overrideMeta?: WorkflowRuntimeMeta): Promise<void>;
4
11
  export declare function executeGraphStep(workflowService: PikkuWorkflowService, rpcService: any, runId: string, stepId: string, nodeId: string, rpcName: string, data: any, graphName: string): Promise<any>;
5
12
  export declare function onGraphNodeComplete(workflowService: PikkuWorkflowService, runId: string, graphName: string): Promise<void>;
@@ -1,5 +1,17 @@
1
- import { pikkuState } from '../../../pikku-state.js';
1
+ import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
2
2
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
3
+ export class ChildWorkflowStartedException extends Error {
4
+ parentRunId;
5
+ stepId;
6
+ childRunId;
7
+ name = 'ChildWorkflowStartedException';
8
+ constructor(parentRunId, stepId, childRunId) {
9
+ super(`Child workflow started: ${childRunId}`);
10
+ this.parentRunId = parentRunId;
11
+ this.stepId = stepId;
12
+ this.childRunId = childRunId;
13
+ }
14
+ }
3
15
  function buildTemplateRegex(nodeId) {
4
16
  if (!nodeId.includes('${'))
5
17
  return null;
@@ -321,15 +333,46 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
321
333
  getState: () => workflowService.getRunState(runId),
322
334
  };
323
335
  try {
324
- const result = await rpcService.rpcWithWire(rpcName, data, {
325
- graph: graphWire,
326
- });
336
+ let result;
337
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
338
+ if (subWorkflowMeta) {
339
+ const childWire = {
340
+ type: 'workflow',
341
+ id: rpcName,
342
+ parentRunId: runId,
343
+ parentStepId: stepId,
344
+ };
345
+ const shouldInline = subWorkflowMeta.inline || !getSingletonServices()?.queueService;
346
+ const { runId: childRunId } = await workflowService.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
347
+ await workflowService.setStepChildRunId(stepId, childRunId);
348
+ if (shouldInline) {
349
+ const childRun = await workflowService.getRun(childRunId);
350
+ if (childRun?.status === 'failed') {
351
+ throw new Error(childRun.error?.message || 'Sub-workflow failed');
352
+ }
353
+ if (childRun?.status === 'cancelled') {
354
+ throw new Error('Sub-workflow was cancelled');
355
+ }
356
+ result = childRun?.output;
357
+ }
358
+ else {
359
+ throw new ChildWorkflowStartedException(runId, stepId, childRunId);
360
+ }
361
+ }
362
+ else {
363
+ result = await rpcService.rpcWithWire(rpcName, data, {
364
+ graph: graphWire,
365
+ });
366
+ }
327
367
  if (wireState.branchKey) {
328
368
  await workflowService.setBranchTaken(stepId, wireState.branchKey);
329
369
  }
330
370
  return result;
331
371
  }
332
372
  catch (error) {
373
+ if (error instanceof ChildWorkflowStartedException) {
374
+ throw error;
375
+ }
333
376
  if (error instanceof RPCNotFoundError) {
334
377
  await workflowService.updateRunStatus(runId, 'suspended', undefined, {
335
378
  message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
@@ -381,9 +424,31 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
381
424
  getState: () => workflowService.getRunState(runId),
382
425
  };
383
426
  try {
384
- const result = await rpcService.rpcWithWire(rpcName, input, {
385
- graph: graphWire,
386
- });
427
+ let result;
428
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
429
+ if (subWorkflowMeta) {
430
+ const childWire = {
431
+ type: 'workflow',
432
+ id: rpcName,
433
+ parentRunId: runId,
434
+ parentStepId: stepState.stepId,
435
+ };
436
+ const { runId: childRunId } = await workflowService.startWorkflow(rpcName, input, childWire, rpcService, { inline: true });
437
+ await workflowService.setStepChildRunId(stepState.stepId, childRunId);
438
+ const childRun = await workflowService.getRun(childRunId);
439
+ if (childRun?.status === 'failed') {
440
+ throw new Error(childRun.error?.message || 'Sub-workflow failed');
441
+ }
442
+ if (childRun?.status === 'cancelled') {
443
+ throw new Error('Sub-workflow was cancelled');
444
+ }
445
+ result = childRun?.output;
446
+ }
447
+ else {
448
+ result = await rpcService.rpcWithWire(rpcName, input, {
449
+ graph: graphWire,
450
+ });
451
+ }
387
452
  if (wireState.branchKey) {
388
453
  await workflowService.setBranchTaken(stepState.stepId, wireState.branchKey);
389
454
  }