@pikku/core 0.12.63 → 0.12.64
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 +147 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +32 -25
- package/dist/permissions.js +104 -185
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +1 -9
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +153 -372
- package/src/permissions.ts +139 -259
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +1 -10
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -21,15 +21,18 @@ import type { AIStorageService } from '../services/ai-storage-service.js';
|
|
|
21
21
|
import type { ContentService } from '../services/content-service.js';
|
|
22
22
|
import type { ScenarioActors } from '../services/scenario-actors-service.js';
|
|
23
23
|
import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js';
|
|
24
|
+
import type { AIEmbeddingService } from '../services/ai-embedding-service.js';
|
|
24
25
|
import type { AIRunStateService } from '../services/ai-run-state-service.js';
|
|
25
26
|
import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
|
|
26
27
|
import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
|
|
27
28
|
import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
|
|
28
29
|
import type { CredentialService } from '../services/credential-service.js';
|
|
29
30
|
import type { EmailService } from '../services/email-service.js';
|
|
31
|
+
import type { WebhookService, WebhookServiceConfig } from '../services/webhook-service.js';
|
|
30
32
|
import type { MetaService } from '../services/meta-service.js';
|
|
31
33
|
import type { CoverageService } from '../services/v8-coverage-service.js';
|
|
32
34
|
import type { SessionStore } from '../services/session-store.js';
|
|
35
|
+
import type { ScopeService } from '../services/scope-service.js';
|
|
33
36
|
import type { AuditDurability, AuditLog, AuditService } from '../services/audit-service.js';
|
|
34
37
|
export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
|
|
35
38
|
export interface FunctionServicesMeta {
|
|
@@ -63,13 +66,13 @@ export type MiddlewareMetadata = {
|
|
|
63
66
|
* - type: 'tag' = Tag-based permission group (references tagGroup in pikkuState)
|
|
64
67
|
* - type: 'wire' = Wire-level individual permission
|
|
65
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* A reference to a permission function a function declares, resolved by name.
|
|
71
|
+
* Used at filter time (e.g. listing agent tools) to run the session-only
|
|
72
|
+
* (pikkuAuth) predicates without request data. Permissions are function-scoped
|
|
73
|
+
* only — there are no wire- or tag-level permission references.
|
|
74
|
+
*/
|
|
66
75
|
export type PermissionMetadata = {
|
|
67
|
-
type: 'http';
|
|
68
|
-
route: string;
|
|
69
|
-
} | {
|
|
70
|
-
type: 'tag';
|
|
71
|
-
tag: string;
|
|
72
|
-
} | {
|
|
73
76
|
type: 'wire';
|
|
74
77
|
name: string;
|
|
75
78
|
inline?: boolean;
|
|
@@ -78,6 +81,8 @@ export type FunctionRuntimeMeta = {
|
|
|
78
81
|
pikkuFuncId: string;
|
|
79
82
|
inputSchemaName: string | null;
|
|
80
83
|
outputSchemaName: string | null;
|
|
84
|
+
/** Scopes the session must hold to run this function. All are required (AND). */
|
|
85
|
+
scopes?: string[];
|
|
81
86
|
expose?: boolean;
|
|
82
87
|
remote?: boolean;
|
|
83
88
|
mcp?: boolean;
|
|
@@ -184,6 +189,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
|
|
|
184
189
|
/** Secrets used by the application (optional). */
|
|
185
190
|
secrets?: {};
|
|
186
191
|
workflow?: WorkflowServiceConfig;
|
|
192
|
+
/** Default retry and signing settings for outgoing webhooks. */
|
|
193
|
+
webhook?: WebhookServiceConfig;
|
|
187
194
|
/** Runtime Postgres adapter options (pool sizing). */
|
|
188
195
|
postgres?: PostgresConfig;
|
|
189
196
|
} & Config;
|
|
@@ -195,6 +202,12 @@ export interface CoreUserSession {
|
|
|
195
202
|
orgId?: string;
|
|
196
203
|
/** True when the session belongs to a synthetic scenario actor — lets audits/analytics address synthetic traffic */
|
|
197
204
|
actor?: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Scopes granted to this session, checked against a function's `scopes`.
|
|
207
|
+
* Populated by whoever builds the session (e.g. better-auth's `mapSession`
|
|
208
|
+
* resolving them via a ScopeService) — core reads them and never fetches.
|
|
209
|
+
*/
|
|
210
|
+
scopes?: string[];
|
|
198
211
|
}
|
|
199
212
|
/**
|
|
200
213
|
* Interface for core singleton services provided by Pikku.
|
|
@@ -228,6 +241,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
228
241
|
content?: ContentService;
|
|
229
242
|
/** AI agent runner service (model calls + tool loop) */
|
|
230
243
|
aiAgentRunner?: AIAgentRunnerService;
|
|
244
|
+
/** Dedicated embedding service (vector stores use it at index & query time) */
|
|
245
|
+
aiEmbedding?: AIEmbeddingService;
|
|
231
246
|
/** AI run state service (run lifecycle + approval persistence) */
|
|
232
247
|
aiRunState?: AIRunStateService;
|
|
233
248
|
/** Agent run service (listing threads, runs, steps) */
|
|
@@ -238,6 +253,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
238
253
|
credentialService?: CredentialService;
|
|
239
254
|
/** Email service for outbound messages and template-backed delivery */
|
|
240
255
|
emailService?: EmailService;
|
|
256
|
+
/**
|
|
257
|
+
* Webhook service for outgoing webhook delivery via a queue. A store-backed
|
|
258
|
+
* implementation (e.g. `KyselyWebhookService`) additionally records delivery
|
|
259
|
+
* history; the queue-only default throws on the delivery-read methods.
|
|
260
|
+
*/
|
|
261
|
+
webhookService?: WebhookService;
|
|
241
262
|
/** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
|
|
242
263
|
metaService?: MetaService;
|
|
243
264
|
/** V8 precise-coverage collector (`pikku dev --coverage` only) */
|
|
@@ -254,6 +275,11 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
254
275
|
auditLog?: AuditLog;
|
|
255
276
|
/** Session store for persisting user sessions keyed by pikkuUserId */
|
|
256
277
|
sessionStore?: SessionStore;
|
|
278
|
+
/**
|
|
279
|
+
* Resolves and administers user scopes. Called when building a session (e.g.
|
|
280
|
+
* better-auth's `mapSession`), never by the function runner.
|
|
281
|
+
*/
|
|
282
|
+
scopeService?: ScopeService;
|
|
257
283
|
}
|
|
258
284
|
/**
|
|
259
285
|
* Represents different forms of wire within Pikku and the outside world.
|
|
@@ -268,6 +294,8 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
|
|
|
268
294
|
traceId: string;
|
|
269
295
|
/** Function id for the current invocation */
|
|
270
296
|
functionId: string;
|
|
297
|
+
/** The addon instance namespace (wireAddon name) currently executing, if any */
|
|
298
|
+
addonNamespace: string;
|
|
271
299
|
http: PikkuHTTP<In>;
|
|
272
300
|
mcp: PikkuMCP<MCPTools>;
|
|
273
301
|
channel: [IsChannel] extends [null] ? PikkuChannel<unknown, Out> : PikkuChannel<unknown, Out> | undefined;
|
|
@@ -35,6 +35,26 @@ export interface PikkuPackageState {
|
|
|
35
35
|
rpcEndpoint?: string;
|
|
36
36
|
auth?: boolean;
|
|
37
37
|
tags?: string[];
|
|
38
|
+
/** Per-instance name-aliases: logical name the addon reads -> actual project secret name */
|
|
39
|
+
secretOverrides?: Record<string, string>;
|
|
40
|
+
/** Per-instance name-aliases: logical name the addon reads -> actual project variable name */
|
|
41
|
+
variableOverrides?: Record<string, string>;
|
|
42
|
+
/** Per-instance name-aliases: logical name the addon reads -> actual project credential name */
|
|
43
|
+
credentialOverrides?: Record<string, string>;
|
|
44
|
+
/** Set by `wireRemoteAddon`: this namespace is consumed remotely over HTTP, not bundled */
|
|
45
|
+
remote?: boolean;
|
|
46
|
+
/** Remote host base URL (wireRemoteAddon) — string or resolver over singleton services */
|
|
47
|
+
serverUrl?: string | ((services: any) => string | Promise<string>);
|
|
48
|
+
/** Consumer-side auth binding for the hosted addon (wireRemoteAddon) */
|
|
49
|
+
remoteAuth?: {
|
|
50
|
+
credentialId: string;
|
|
51
|
+
} | {
|
|
52
|
+
secretId: string;
|
|
53
|
+
} | {
|
|
54
|
+
resolve: (services: any, wire: any) => string | Promise<string>;
|
|
55
|
+
};
|
|
56
|
+
/** Map consumer-facing fn name → remote fn name (wireRemoteAddon) */
|
|
57
|
+
remoteName?: (fn: string) => string;
|
|
38
58
|
}>;
|
|
39
59
|
};
|
|
40
60
|
http: {
|
|
@@ -94,8 +114,6 @@ export interface PikkuPackageState {
|
|
|
94
114
|
tagGroup: Record<string, CorePikkuChannelMiddleware[]>;
|
|
95
115
|
};
|
|
96
116
|
permissions: {
|
|
97
|
-
tagGroup: Record<string, CorePermissionGroup | CorePikkuPermission[]>;
|
|
98
|
-
httpGroup: Record<string, CorePermissionGroup | CorePikkuPermission[]>;
|
|
99
117
|
global: (CorePermissionGroup | CorePikkuPermission)[];
|
|
100
118
|
};
|
|
101
119
|
misc: {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC-SHA256 a payload, hex-encoded.
|
|
3
|
+
*
|
|
4
|
+
* Senders wrap this in their own scheme prefix (`sha256=`, `v0=`, …).
|
|
5
|
+
*
|
|
6
|
+
* @param secret - The signing key shared with the other side
|
|
7
|
+
* @param payload - The exact bytes that were signed
|
|
8
|
+
*/
|
|
9
|
+
export declare function hmacSha256Hex(secret: string, payload: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Compare two signatures without leaking their contents through timing.
|
|
12
|
+
*
|
|
13
|
+
* Returns false rather than throwing on a length mismatch, which is what
|
|
14
|
+
* `timingSafeEqual` does when the buffers differ in size.
|
|
15
|
+
*/
|
|
16
|
+
export declare function timingSafeStringEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* HMAC-SHA256 a payload, hex-encoded.
|
|
4
|
+
*
|
|
5
|
+
* Senders wrap this in their own scheme prefix (`sha256=`, `v0=`, …).
|
|
6
|
+
*
|
|
7
|
+
* @param secret - The signing key shared with the other side
|
|
8
|
+
* @param payload - The exact bytes that were signed
|
|
9
|
+
*/
|
|
10
|
+
export function hmacSha256Hex(secret, payload) {
|
|
11
|
+
return createHmac('sha256', secret).update(payload).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Compare two signatures without leaking their contents through timing.
|
|
15
|
+
*
|
|
16
|
+
* Returns false rather than throwing on a length mismatch, which is what
|
|
17
|
+
* `timingSafeEqual` does when the buffers differ in size.
|
|
18
|
+
*/
|
|
19
|
+
export function timingSafeStringEqual(a, b) {
|
|
20
|
+
try {
|
|
21
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-aware fetch helpers.
|
|
3
|
+
*
|
|
4
|
+
* `@pikku/core` runs in edge runtimes (Cloudflare Workers) with no Node `dns`,
|
|
5
|
+
* so we cannot resolve hostnames to check for private targets. We reject the
|
|
6
|
+
* obvious internal literals and, crucially, re-validate every redirect hop —
|
|
7
|
+
* a public URL that 302s to `169.254.169.254` is the common bypass. This does
|
|
8
|
+
* NOT defend against a public hostname that itself resolves to a private IP
|
|
9
|
+
* (DNS rebinding), which is out of reach without DNS resolution.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Whether a hostname is an obvious internal/private target (loopback, private
|
|
13
|
+
* IPv4 ranges, link-local incl. the cloud metadata endpoint, or private IPv6).
|
|
14
|
+
*
|
|
15
|
+
* Alias/encoded forms that resolve to the same targets are also rejected: a
|
|
16
|
+
* trailing-dot FQDN (`localhost.`), the `*.localhost` reserved name, IPv4-mapped
|
|
17
|
+
* IPv6 (`::ffff:127.0.0.1`), and octal/decimal/hex-encoded IPv4. This is
|
|
18
|
+
* best-effort literal matching only — it cannot catch a public hostname that
|
|
19
|
+
* *resolves* to a private IP (DNS rebinding), which needs DNS resolution
|
|
20
|
+
* unavailable in edge runtimes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isPrivateHost(hostname: string): boolean;
|
|
23
|
+
export interface SafeFetchOptions {
|
|
24
|
+
/**
|
|
25
|
+
* When set, the host of every hop must appear in this allowlist. When omitted,
|
|
26
|
+
* any host that is not {@link isPrivateHost} is permitted.
|
|
27
|
+
*/
|
|
28
|
+
allowedHosts?: string[];
|
|
29
|
+
/** Maximum redirect hops to follow (each re-validated). Defaults to 3. */
|
|
30
|
+
maxRedirects?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse and validate a URL for outbound fetching: only http(s), and — unless an
|
|
34
|
+
* `allowedHosts` allowlist is supplied — not an obvious private/internal host.
|
|
35
|
+
* Returns the parsed URL or throws.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertFetchableUrl(url: string, options?: SafeFetchOptions): URL;
|
|
38
|
+
/**
|
|
39
|
+
* `fetch` with SSRF protection. The initial URL and every redirect target are
|
|
40
|
+
* validated with {@link assertFetchableUrl}. Redirects are followed manually
|
|
41
|
+
* (`redirect: 'manual'`) so an unsafe `Location` can never be followed into the
|
|
42
|
+
* internal network; only the redirect statuses in {@link REDIRECT_STATUSES} are
|
|
43
|
+
* followed, and the method/body are transformed per {@link redirectInit}. Each
|
|
44
|
+
* intermediate redirect response body is cancelled before the next hop so it is
|
|
45
|
+
* not left dangling. When a redirect cannot or should not be followed (no
|
|
46
|
+
* `Location`, or the hop budget is exhausted) the raw redirect response is
|
|
47
|
+
* returned for the caller to handle by status. Credential headers
|
|
48
|
+
* (`Authorization`, `Cookie`) are stripped whenever a redirect crosses origin,
|
|
49
|
+
* so they never leak to a redirected host.
|
|
50
|
+
*/
|
|
51
|
+
export declare function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-aware fetch helpers.
|
|
3
|
+
*
|
|
4
|
+
* `@pikku/core` runs in edge runtimes (Cloudflare Workers) with no Node `dns`,
|
|
5
|
+
* so we cannot resolve hostnames to check for private targets. We reject the
|
|
6
|
+
* obvious internal literals and, crucially, re-validate every redirect hop —
|
|
7
|
+
* a public URL that 302s to `169.254.169.254` is the common bypass. This does
|
|
8
|
+
* NOT defend against a public hostname that itself resolves to a private IP
|
|
9
|
+
* (DNS rebinding), which is out of reach without DNS resolution.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Parse the many textual encodings of an IPv4 address that `fetch`/`undici`
|
|
13
|
+
* (and `inet_aton`-style parsers) accept — a dotted quad whose octets may be
|
|
14
|
+
* decimal, octal (`0177`) or hex (`0x7f`), or the whole address as a single
|
|
15
|
+
* 32-bit integer (decimal `2130706433`, hex `0x7f000001`). Returns the four
|
|
16
|
+
* octets, or `null` when the host is not a numeric IPv4 literal.
|
|
17
|
+
*/
|
|
18
|
+
function parseIPv4Octets(host) {
|
|
19
|
+
const toInt = (part) => {
|
|
20
|
+
let n;
|
|
21
|
+
if (/^0x[0-9a-f]+$/.test(part))
|
|
22
|
+
n = parseInt(part, 16);
|
|
23
|
+
else if (/^0[0-7]+$/.test(part))
|
|
24
|
+
n = parseInt(part, 8);
|
|
25
|
+
else if (/^\d+$/.test(part))
|
|
26
|
+
n = parseInt(part, 10);
|
|
27
|
+
else
|
|
28
|
+
return null;
|
|
29
|
+
return Number.isInteger(n) ? n : null;
|
|
30
|
+
};
|
|
31
|
+
if (!host.includes('.')) {
|
|
32
|
+
const n = toInt(host);
|
|
33
|
+
if (n === null || n < 0 || n > 0xffffffff)
|
|
34
|
+
return null;
|
|
35
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
36
|
+
}
|
|
37
|
+
const parts = host.split('.');
|
|
38
|
+
if (parts.length !== 4)
|
|
39
|
+
return null;
|
|
40
|
+
const octets = [];
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
const n = toInt(part);
|
|
43
|
+
if (n === null || n < 0 || n > 0xff)
|
|
44
|
+
return null;
|
|
45
|
+
octets.push(n);
|
|
46
|
+
}
|
|
47
|
+
return octets;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether a hostname is an obvious internal/private target (loopback, private
|
|
51
|
+
* IPv4 ranges, link-local incl. the cloud metadata endpoint, or private IPv6).
|
|
52
|
+
*
|
|
53
|
+
* Alias/encoded forms that resolve to the same targets are also rejected: a
|
|
54
|
+
* trailing-dot FQDN (`localhost.`), the `*.localhost` reserved name, IPv4-mapped
|
|
55
|
+
* IPv6 (`::ffff:127.0.0.1`), and octal/decimal/hex-encoded IPv4. This is
|
|
56
|
+
* best-effort literal matching only — it cannot catch a public hostname that
|
|
57
|
+
* *resolves* to a private IP (DNS rebinding), which needs DNS resolution
|
|
58
|
+
* unavailable in edge runtimes.
|
|
59
|
+
*/
|
|
60
|
+
export function isPrivateHost(hostname) {
|
|
61
|
+
const host = hostname
|
|
62
|
+
.replace(/^\[|\]$/g, '')
|
|
63
|
+
.replace(/\.$/, '')
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
if (host === '' || host === 'localhost' || host.endsWith('.localhost'))
|
|
66
|
+
return true;
|
|
67
|
+
if (host.includes(':')) {
|
|
68
|
+
if (host === '::' || host === '::1')
|
|
69
|
+
return true;
|
|
70
|
+
const mappedV4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
71
|
+
if (mappedV4)
|
|
72
|
+
return isPrivateHost(mappedV4[1]);
|
|
73
|
+
const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
74
|
+
if (mappedHex) {
|
|
75
|
+
const hi = parseInt(mappedHex[1], 16);
|
|
76
|
+
const lo = parseInt(mappedHex[2], 16);
|
|
77
|
+
return isPrivateHost(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
78
|
+
}
|
|
79
|
+
if (/^fe[89ab]/.test(host))
|
|
80
|
+
return true; // link-local fe80::/10
|
|
81
|
+
if (host.startsWith('fc') || host.startsWith('fd'))
|
|
82
|
+
return true; // unique-local fc00::/7
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const v4 = parseIPv4Octets(host);
|
|
86
|
+
if (v4) {
|
|
87
|
+
const [a, b] = v4;
|
|
88
|
+
if (a === 127 || a === 10 || a === 0)
|
|
89
|
+
return true;
|
|
90
|
+
if (a === 169 && b === 254)
|
|
91
|
+
return true; // link-local incl. cloud metadata
|
|
92
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
93
|
+
return true;
|
|
94
|
+
if (a === 192 && b === 168)
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse and validate a URL for outbound fetching: only http(s), and — unless an
|
|
101
|
+
* `allowedHosts` allowlist is supplied — not an obvious private/internal host.
|
|
102
|
+
* Returns the parsed URL or throws.
|
|
103
|
+
*/
|
|
104
|
+
export function assertFetchableUrl(url, options = {}) {
|
|
105
|
+
const parsed = new URL(url);
|
|
106
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
107
|
+
throw new Error(`Refusing to fetch non-HTTP(S) URL: ${parsed.protocol}`);
|
|
108
|
+
}
|
|
109
|
+
if (options.allowedHosts) {
|
|
110
|
+
if (!options.allowedHosts.includes(parsed.hostname)) {
|
|
111
|
+
throw new Error(`URL host is not in the allowlist: ${parsed.hostname}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (isPrivateHost(parsed.hostname)) {
|
|
115
|
+
throw new Error(`Refusing to fetch from a private/internal host: ${parsed.hostname}`);
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The only 3xx statuses that request a redirect be followed. `300` (Multiple
|
|
121
|
+
* Choices), `304` (Not Modified), `305` (Use Proxy) and `306` are returned to
|
|
122
|
+
* the caller as-is rather than followed.
|
|
123
|
+
*/
|
|
124
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
125
|
+
/**
|
|
126
|
+
* Drop credential-bearing headers (`Authorization`, `Cookie`) so they are not
|
|
127
|
+
* replayed to a different origin across a redirect.
|
|
128
|
+
*/
|
|
129
|
+
function stripCredentialHeaders(init) {
|
|
130
|
+
if (!init.headers)
|
|
131
|
+
return init;
|
|
132
|
+
const headers = new Headers(init.headers);
|
|
133
|
+
headers.delete('authorization');
|
|
134
|
+
headers.delete('cookie');
|
|
135
|
+
return { ...init, headers };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply the WHATWG-fetch method/body transform for a redirect: a `303` (and a
|
|
139
|
+
* `301`/`302` on a `POST`) becomes a bodyless `GET`; `307`/`308` preserve the
|
|
140
|
+
* original method and body. When the method changes to `GET` the request body
|
|
141
|
+
* and its `Content-*` headers are dropped.
|
|
142
|
+
*/
|
|
143
|
+
function redirectInit(status, init) {
|
|
144
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
145
|
+
const toGet = (status === 303 && method !== 'GET' && method !== 'HEAD') ||
|
|
146
|
+
((status === 301 || status === 302) && method === 'POST');
|
|
147
|
+
if (!toGet)
|
|
148
|
+
return init;
|
|
149
|
+
const headers = new Headers(init.headers);
|
|
150
|
+
headers.delete('content-length');
|
|
151
|
+
headers.delete('content-type');
|
|
152
|
+
return { ...init, method: 'GET', body: undefined, headers };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `fetch` with SSRF protection. The initial URL and every redirect target are
|
|
156
|
+
* validated with {@link assertFetchableUrl}. Redirects are followed manually
|
|
157
|
+
* (`redirect: 'manual'`) so an unsafe `Location` can never be followed into the
|
|
158
|
+
* internal network; only the redirect statuses in {@link REDIRECT_STATUSES} are
|
|
159
|
+
* followed, and the method/body are transformed per {@link redirectInit}. Each
|
|
160
|
+
* intermediate redirect response body is cancelled before the next hop so it is
|
|
161
|
+
* not left dangling. When a redirect cannot or should not be followed (no
|
|
162
|
+
* `Location`, or the hop budget is exhausted) the raw redirect response is
|
|
163
|
+
* returned for the caller to handle by status. Credential headers
|
|
164
|
+
* (`Authorization`, `Cookie`) are stripped whenever a redirect crosses origin,
|
|
165
|
+
* so they never leak to a redirected host.
|
|
166
|
+
*/
|
|
167
|
+
export async function safeFetch(url, init = {}, options = {}) {
|
|
168
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
169
|
+
let currentUrl = assertFetchableUrl(url, options).toString();
|
|
170
|
+
let currentInit = init;
|
|
171
|
+
for (let hop = 0;; hop++) {
|
|
172
|
+
const response = await fetch(currentUrl, {
|
|
173
|
+
...currentInit,
|
|
174
|
+
redirect: 'manual',
|
|
175
|
+
});
|
|
176
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
177
|
+
return response;
|
|
178
|
+
}
|
|
179
|
+
const location = response.headers.get('location');
|
|
180
|
+
if (!location || hop >= maxRedirects) {
|
|
181
|
+
return response;
|
|
182
|
+
}
|
|
183
|
+
const nextUrl = assertFetchableUrl(new URL(location, currentUrl).toString(), options).toString();
|
|
184
|
+
await response.body?.cancel();
|
|
185
|
+
let nextInit = redirectInit(response.status, currentInit);
|
|
186
|
+
if (new URL(nextUrl).origin !== new URL(currentUrl).origin) {
|
|
187
|
+
nextInit = stripCredentialHeaders(nextInit);
|
|
188
|
+
}
|
|
189
|
+
currentInit = nextInit;
|
|
190
|
+
currentUrl = nextUrl;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AIStreamChannel } from './ai-agent.types.js';
|
|
2
|
+
type AGUIEvent = {
|
|
3
|
+
type: 'TEXT_MESSAGE_START';
|
|
4
|
+
messageId: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'TEXT_MESSAGE_CONTENT';
|
|
7
|
+
messageId: string;
|
|
8
|
+
delta: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'TEXT_MESSAGE_END';
|
|
11
|
+
messageId: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'TOOL_CALL_START';
|
|
14
|
+
toolCallId: string;
|
|
15
|
+
toolCallName: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'TOOL_CALL_ARGS';
|
|
18
|
+
toolCallId: string;
|
|
19
|
+
delta: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'TOOL_CALL_END';
|
|
22
|
+
toolCallId: string;
|
|
23
|
+
toolCallName: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: 'TOOL_CALL_RESULT';
|
|
26
|
+
messageId: string;
|
|
27
|
+
toolCallId: string;
|
|
28
|
+
role: 'tool';
|
|
29
|
+
content: string;
|
|
30
|
+
} | {
|
|
31
|
+
type: 'THINKING_START';
|
|
32
|
+
} | {
|
|
33
|
+
type: 'THINKING_TEXT_MESSAGE_START';
|
|
34
|
+
messageId: string;
|
|
35
|
+
} | {
|
|
36
|
+
type: 'THINKING_TEXT_MESSAGE_CONTENT';
|
|
37
|
+
messageId: string;
|
|
38
|
+
delta: string;
|
|
39
|
+
} | {
|
|
40
|
+
type: 'THINKING_TEXT_MESSAGE_END';
|
|
41
|
+
messageId: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: 'THINKING_END';
|
|
44
|
+
} | {
|
|
45
|
+
type: 'RUN_STARTED';
|
|
46
|
+
threadId: string;
|
|
47
|
+
runId: string;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'RUN_FINISHED';
|
|
50
|
+
threadId: string;
|
|
51
|
+
runId: string;
|
|
52
|
+
model?: string;
|
|
53
|
+
usage?: {
|
|
54
|
+
promptTokens: number;
|
|
55
|
+
completionTokens: number;
|
|
56
|
+
totalTokens: number;
|
|
57
|
+
};
|
|
58
|
+
} | {
|
|
59
|
+
type: 'RUN_ERROR';
|
|
60
|
+
message: string;
|
|
61
|
+
code?: string;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'STEP_STARTED';
|
|
64
|
+
stepName: string;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'STEP_FINISHED';
|
|
67
|
+
stepName: string;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'CUSTOM';
|
|
70
|
+
name: string;
|
|
71
|
+
value: unknown;
|
|
72
|
+
};
|
|
73
|
+
export type { AGUIEvent };
|
|
74
|
+
export type AGUIChannelOptions = {
|
|
75
|
+
threadId?: string;
|
|
76
|
+
runId?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Late-bound runId source, resolved when the run is opened (first event).
|
|
79
|
+
* Lets the stream path report the AIRunStateService runId on RUN_STARTED
|
|
80
|
+
* even though that id is only created after the channel is wrapped.
|
|
81
|
+
*/
|
|
82
|
+
getRunId?: () => string | undefined;
|
|
83
|
+
};
|
|
84
|
+
export declare function wrapChannelWithAGUI(inner: AIStreamChannel, options?: AGUIChannelOptions): AIStreamChannel;
|