@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.
- package/CHANGELOG.md +24 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +1 -0
- package/dist/errors/errors.d.ts +14 -0
- package/dist/errors/errors.js +27 -0
- package/dist/handle-error.js +2 -1
- package/dist/middleware/auth-bearer.js +10 -1
- package/dist/middleware/auth-cookie.js +34 -25
- package/dist/middleware/timeout.js +11 -5
- package/dist/pikku-state.js +1 -1
- package/dist/services/local-content.d.ts +7 -4
- package/dist/services/local-content.js +42 -12
- package/dist/services/local-secrets.js +2 -2
- package/dist/testing/service-tests.js +1 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
- package/dist/wirings/channel/local/local-channel-runner.js +11 -6
- package/dist/wirings/http/http-runner.js +4 -2
- package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
- package/dist/wirings/workflow/graph/graph-runner.d.ts +7 -0
- package/dist/wirings/workflow/graph/graph-runner.js +72 -7
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +59 -16
- package/dist/wirings/workflow/workflow.types.d.ts +4 -0
- package/package.json +1 -1
- package/src/env.ts +1 -0
- package/src/errors/errors.ts +35 -0
- package/src/handle-error.ts +2 -1
- package/src/middleware/auth-bearer.ts +10 -1
- package/src/middleware/auth-cookie.ts +11 -4
- package/src/middleware/timeout.ts +14 -7
- package/src/pikku-state.ts +1 -1
- package/src/services/local-content.ts +61 -13
- package/src/services/local-secrets.test.ts +2 -2
- package/src/services/local-secrets.ts +2 -2
- package/src/testing/service-tests.ts +1 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
- package/src/wirings/channel/local/local-channel-runner.ts +11 -6
- package/src/wirings/http/http-runner.ts +4 -2
- package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
- package/src/wirings/workflow/graph/graph-runner.ts +84 -7
- package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +95 -25
- package/src/wirings/workflow/workflow.types.ts +4 -0
- 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';
|
package/dist/errors/errors.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/errors/errors.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/handle-error.js
CHANGED
|
@@ -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
|
|
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 }) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
}));
|
package/dist/pikku-state.js
CHANGED
|
@@ -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
|
-
|
|
12
|
+
private jwt?;
|
|
13
|
+
constructor(config: LocalContentConfig, logger: Logger, jwt?: JWTService | undefined);
|
|
14
|
+
private safePath;
|
|
13
15
|
init(): Promise<void>;
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
? `${this.config.server}${this.config.assetUrlPrefix}/${assetKey}
|
|
18
|
-
: `${this.config.assetUrlPrefix}/${assetKey}
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
}
|