fix: allow root token to also call ai drivers (#3442)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-07-24 17:41:38 -07:00
1 parent e6531d4357
commit 6c4fa629a9
14 files changed
+265 -265

No files matched your search

@@ -319,10 +319,11 @@ describe('DriverController.#handleCall (via captured router)', () => {
).rejects.toMatchObject({ statusCode: 404 });
});
it('rejects a bare session ("root" token) actor on a noUserSession driver with a helpful 403', async () => {
// The AI drivers all set `noUserSession` — a session token must
// not double as an AI credential. The message has to point the
// caller at the credentials that DO work.
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
// Privileged ("godmode") apps call AI with the user's own session
// token, so the AI drivers no longer set `noUserSession`: the
// session actor reaches the permission scan (403 forbidden — no
// perms granted) instead of the credential-shape rejection.
const actor = await makeUserActor();
const req = makeReq(
{
@@ -342,12 +343,11 @@ describe('DriverController.#handleCall (via captured router)', () => {
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_or_api_token_required',
message: expect.stringMatching(/app or worker token|API token/i),
legacyCode: 'forbidden',
});
});
it('admits app and access-token actors past the noUserSession gate (they fail later on permission, not credential shape)', async () => {
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
const base = await makeUserActor();
const delegatedActors: Actor[] = [
{ ...base, app: { uid: `app-${uuidv4()}` } },
@@ -387,7 +387,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
}
});
it('admits a user-scoped worker session past the noUserSession gate', async () => {
it('admits a user-scoped worker session on the AI drivers', async () => {
// Workers deployed without an app binding authenticate as a user
// actor whose session row is kind='worker' — they must keep their
// AI access (they fail later on permission here, not on
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Request, Response } from 'express';
@@ -116,17 +117,17 @@ const translateProviderError = (err: unknown): unknown => {
@Controller('/drivers')
export class DriverController extends PuterController {
/** iface → Map<driverName, driverInstance> */
/** Iface → Map<driverName, driverInstance> */
#drivers = new Map<string, Map<string, DriverInstance>>();
/** iface → default driver name */
/** Iface → default driver name */
#defaults = new Map<string, string>();
/**
* driver instance → resolved meta. Cached so the per-call rate-limit
* lookup doesn't have to walk prototype chains on every request.
* Driver instance → resolved meta. Cached so the per-call rate-limit lookup
* doesn't have to walk prototype chains on every request.
*/
#meta = new WeakMap<DriverInstance, DriverMeta>();
/**
* driver instance → the set of method names callable via `/drivers/call`.
* Driver instance → the set of method names callable via `/drivers/call`.
* Resolved once at registration (server startup) via
* `resolveCallableMethods`; the request path only does a `Set.has` lookup.
* This is what stops framework/lifecycle methods (`onServerStart`, etc.)
@@ -236,7 +237,7 @@ export class DriverController extends PuterController {
const driverMeta = this.#meta.get(driver);
// Drivers flagged `noUserSession` (the AI drivers) refuse the bare
// Drivers flagged `noUserSession` refuse the bare
// account-session ("root") token: callers must present an app or
// worker token, or an API token minted from the dashboard. This is
// the per-driver counterpart of the `noUserSession` route option —
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import crypto from 'node:crypto';
@@ -72,13 +73,13 @@ type ProviderAttempt = {
};
/**
* Capture what an upstream provider gave us so the classifier downstream
* can decide a user-facing status code instead of always returning 500.
* Capture what an upstream provider gave us so the classifier downstream can
* decide a user-facing status code instead of always returning 500.
*
* OpenAI-SDK-based providers throw `APIError` with `.status` and a
* structured `.error` body — pull both. For arbitrary errors we fall
* back to the message and a status sniff so providers that throw plain
* `Error("... 503 ...")` strings still classify correctly.
* OpenAI-SDK-based providers throw `APIError` with `.status` and a structured
* `.error` body — pull both. For arbitrary errors we fall back to the message
* and a status sniff so providers that throw plain `Error("... 503 ...")`
* strings still classify correctly.
*/
const toAttempt = (
modelId: string,
@@ -126,11 +127,12 @@ const isUpstream5xx = (a: ProviderAttempt) =>
* Map an exhausted fallback chain to a single user-facing HttpError.
*
* Per-class rules (see also alarm gate in server.ts):
* - all rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
* - all auth failures → 500 `upstream_auth_failed` (paged: our config)
* - all upstream 5xx → 400 `upstream_provider_unavailable` (no page)
* - all upstream 4xx (other) → 400 `upstream_bad_request` (no page)
* - mixed → 400 `upstream_failed` (no page)
*
* - All rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
* - All upstream 5xx → 400 `upstream_provider_unavailable` (no page)
* - All upstream 4xx (other) → 400 `upstream_bad_request` (no page)
* - Mixed → 400 `upstream_failed` (no page)
*/
const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
const fields = { attempts };
@@ -196,16 +198,15 @@ const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
/**
* Driver implementing the `puter-chat-completion` interface.
*
* Manages multiple upstream providers (Claude, OpenAI, …) and handles
* model resolution, provider routing, fallback on failure, and message
* normalisation. Each provider is a plain `IChatProvider` — the driver
* instantiates them from config on boot.
* Manages multiple upstream providers (Claude, OpenAI, …) and handles model
* resolution, provider routing, fallback on failure, and message normalisation.
* Each provider is a plain `IChatProvider` — the driver instantiates them from
* config on boot.
*
* Providers handle their own metering internally.
*/
export class ChatCompletionDriver extends PuterDriver {
readonly driverInterface = 'puter-chat-completion';
readonly noUserSession = true;
readonly driverName = 'ai-chat';
readonly isDefault = true;
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import crypto from 'node:crypto';
@@ -37,17 +38,16 @@ import type { IGenerateParams, IImageModel, IImageProvider } from './types.js';
/**
* Driver implementing the `puter-image-generation` interface.
*
* Manages multiple upstream providers and routes `generate()` calls
* based on the requested model. Mirrors ChatCompletionDriver's pattern:
* providers are instantiated from config on boot, a model map is built
* from each provider's declared models, and calls are dispatched.
* Manages multiple upstream providers and routes `generate()` calls based on
* the requested model. Mirrors ChatCompletionDriver's pattern: providers are
* instantiated from config on boot, a model map is built from each provider's
* declared models, and calls are dispatched.
*
* Output is a URL string (web URL or data URI) — no streaming, no
* TypedValue wrapper.
* Output is a URL string (web URL or data URI) — no streaming, no TypedValue
* wrapper.
*/
export class ImageGenerationDriver extends PuterDriver {
readonly driverInterface = 'puter-image-generation';
readonly noUserSession = true;
readonly driverName = 'ai-image';
// puter-js's `txt2img` falls through `options.driver` into the
// driver-name slot (e.g. `xai-image-generation`), so alias all provider
+17 -15
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import {
@@ -33,9 +34,9 @@ import { loadFileInput, type LoadedFile } from '../util/fileInput.js';
import { OCR_COSTS } from './costs.js';
/**
* Driver implementing `puter-ocr` — document OCR. Two providers:
* • `aws-textract` — AWS Textract (region-aware clients; direct S3 source when available)
* • `mistral` — Mistral OCR (URL/data-URL based)
* Driver implementing `puter-ocr` — document OCR. Two providers: •
* `aws-textract` — AWS Textract (region-aware clients; direct S3 source when
* available) • `mistral` — Mistral OCR (URL/data-URL based)
*/
interface RecognizeArgs {
source?: unknown;
@@ -79,7 +80,6 @@ interface MistralOcrClient {
export class OCRDriver extends PuterDriver {
readonly driverInterface = 'puter-ocr';
readonly noUserSession = true;
readonly driverName = 'ai-ocr';
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
@@ -117,9 +117,11 @@ export class OCRDriver extends PuterDriver {
const providers = this.config.providers ?? {};
const textract = providers['aws-textract'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const textractAws = (textract?.aws ?? textract) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const textractAccessKey = textractAws?.access_key as string | undefined;
const textractSecretKey = textractAws?.secret_key as string | undefined;
const textractRegion =
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Readable } from 'node:stream';
@@ -27,8 +28,8 @@ import { loadFileInput } from '../util/fileInput.js';
import { VOICE_CHANGER_COSTS } from './costs.js';
/**
* Driver implementing `puter-speech2speech` — voice changer. Currently a
* single provider (ElevenLabs).
* Driver implementing `puter-speech2speech` — voice changer. Currently a single
* provider (ElevenLabs).
*/
const DEFAULT_MODEL = 'eleven_multilingual_sts_v2';
@@ -57,7 +58,6 @@ interface ConvertArgs {
export class VoiceChangerDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2speech';
readonly noUserSession = true;
readonly driverName = 'elevenlabs-voice-changer';
readonly isDefault = true;
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import OpenAI, { toFile } from 'openai';
@@ -26,8 +27,8 @@ import { loadFileInput } from '../util/fileInput.js';
import { SPEECH_TO_TEXT_COSTS } from './costs.js';
/**
* Driver implementing `puter-speech2txt`. Wraps OpenAI's audio API
* (Whisper + GPT-4o transcribe models) for transcription and translation.
* Driver implementing `puter-speech2txt`. Wraps OpenAI's audio API (Whisper +
* GPT-4o transcribe models) for transcription and translation.
*
* `file` may be a path, uid/uuid ref, or data URL.
*/
@@ -100,7 +101,6 @@ interface TranscribeArgs {
export class SpeechToTextDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2txt';
readonly noUserSession = true;
readonly driverName = 'openai-speech2txt';
readonly isDefault = true;
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Context } from '../../core/context.js';
@@ -26,10 +27,11 @@ import { loadFileInput } from '../util/fileInput.js';
/**
* Driver implementing `puter-speech2txt` for the xAI (Grok) STT API.
*
* Uses the xAI /v1/stt REST endpoint which accepts multipart/form-data
* with an audio file and returns a JSON transcript with word-level timestamps.
* Uses the xAI /v1/stt REST endpoint which accepts multipart/form-data with an
* audio file and returns a JSON transcript with word-level timestamps.
*
* Pricing: $0.10/hr REST = 10 cents/hr = 10 * 1_000_000 / 3600 ≈ 2778 microcents/second
* Pricing: $0.10/hr REST = 10 cents/hr = 10 * 1_000_000 / 3600 ≈ 2778
* microcents/second
*/
const API_BASE = 'https://api.x.ai/v1';
@@ -66,7 +68,6 @@ interface TranscribeArgs {
export class XAISpeechToTextDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2txt';
readonly noUserSession = true;
readonly driverName = 'xai-speech2txt';
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
+18 -26
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Context } from '../../core/context.js';
@@ -37,10 +38,9 @@ import type {
/**
* Driver implementing the `puter-tts` interface.
*
* Manages multiple upstream TTS providers (OpenAI, ElevenLabs, AWS Polly)
* and handles provider routing, voice/engine aggregation, and speech
* synthesis. Each provider is an `ITTSProvider` instantiated from config
* on boot.
* Manages multiple upstream TTS providers (OpenAI, ElevenLabs, AWS Polly) and
* handles provider routing, voice/engine aggregation, and speech synthesis.
* Each provider is an `ITTSProvider` instantiated from config on boot.
*/
// puter-js still routes TTS via legacy per-provider driver names rather
// than passing `{ provider }` in args, so alias the unified driver under
@@ -64,7 +64,6 @@ const ALIAS_TO_PROVIDER: Record<TTSAlias, string> = {
export class TTSDriver extends PuterDriver {
readonly driverInterface = 'puter-tts';
readonly noUserSession = true;
readonly driverName = 'ai-tts';
readonly driverAliases = [...TTS_ALIASES];
readonly isDefault = true;
@@ -88,9 +87,7 @@ export class TTSDriver extends PuterDriver {
// -- Interface methods -------------------------------------------
/**
* List all available voices across all configured providers.
*/
/** List all available voices across all configured providers. */
async list_voices(args?: Record<string, unknown>): Promise<ITTSVoice[]> {
const provider =
(args?.provider as string | undefined) ?? this.#providerFromAlias();
@@ -109,9 +106,7 @@ export class TTSDriver extends PuterDriver {
return allVoices;
}
/**
* List all available engines/models across all configured providers.
*/
/** List all available engines/models across all configured providers. */
async list_engines(args?: Record<string, unknown>): Promise<ITTSEngine[]> {
const provider =
(args?.provider as string | undefined) ?? this.#providerFromAlias();
@@ -130,9 +125,7 @@ export class TTSDriver extends PuterDriver {
return allEngines;
}
/**
* List provider names that are currently configured.
*/
/** List provider names that are currently configured. */
async list(): Promise<string[]> {
return Object.keys(this.#providers);
}
@@ -158,9 +151,8 @@ export class TTSDriver extends PuterDriver {
}
/**
* Synthesize speech from text. Routes to the appropriate provider
* based on the `provider` argument, or falls back to the first
* available provider.
* Synthesize speech from text. Routes to the appropriate provider based on
* the `provider` argument, or falls back to the first available provider.
*/
async synthesize(
args: ISynthesizeArgs,
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { posix as pathPosix } from 'node:path';
@@ -48,7 +49,6 @@ const DEFAULT_PROVIDER = 'openai-video-generation';
*/
export class VideoGenerationDriver extends PuterDriver {
readonly driverInterface = 'puter-video-generation';
readonly noUserSession = true;
readonly driverName = 'ai-video';
// puter-js's `txt2vid` can pass a provider id via `options.driver`, so
// alias all provider ids here. `generate` falls back to
+5 -5
View File
@@ -151,11 +151,11 @@ describe.each([
expect(m.concurrent).toBe(AI_CONCURRENT);
});
it('refuses bare account-session ("root") tokens', () => {
// AI calls need a delegated credential — an app/worker token or a
// dashboard-minted API token. `DriverController` enforces this off
// the meta flag; dropping it silently reopens session-token AI use.
expect(m.noUserSession).toBe(true);
it('accepts bare account-session ("root") tokens', () => {
// Privileged ("godmode") apps run on the user's own session token
// rather than an app token, so the AI drivers can't distinguish
// them from a browser session and have to admit both.
expect(m.noUserSession).toBe(false);
});
});
+62 -63
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Readable } from 'node:stream';
@@ -78,24 +79,24 @@ export interface DriverRateLimitSpec {
/** Window length, in milliseconds. */
window: number;
/**
* Per-subscription overrides for `limit`. Keyed by
* `SubscriptionPolicy.id` (`user_free`, `temp_free`, `unlimited`,
* etc.). Falls back to `limit` when the actor's subscription isn't
* in the map. Same mechanic as `DriverConcurrentSpec.bySubscription`.
* Per-subscription overrides for `limit`. Keyed by `SubscriptionPolicy.id`
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to `limit` when
* the actor's subscription isn't in the map. Same mechanic as
* `DriverConcurrentSpec.bySubscription`.
*/
bySubscription?: Record<string, number>;
/**
* Storage backend to count against. Omit to use the server-wide
* default configured by `config.rate_limit.backend`.
* Storage backend to count against. Omit to use the server-wide default
* configured by `config.rate_limit.backend`.
*/
backend?: RateLimitBackend;
}
export interface DriverRateLimitConfig {
/**
* Applied to any method not listed in `methods`. Lets a driver opt
* the whole interface into tighter limits than the global driver
* default without enumerating every method.
* Applied to any method not listed in `methods`. Lets a driver opt the
* whole interface into tighter limits than the global driver default
* without enumerating every method.
*/
default?: DriverRateLimitSpec;
/** Per-method overrides. Keys are driver method names. */
@@ -103,10 +104,9 @@ export interface DriverRateLimitConfig {
}
/**
* Validate a `rateLimit` block declared by a driver. Throws on bad
* shape so registration fails loudly at boot rather than silently
* misconfiguring production traffic. Returns the value unchanged on
* success for chaining.
* Validate a `rateLimit` block declared by a driver. Throws on bad shape so
* registration fails loudly at boot rather than silently misconfiguring
* production traffic. Returns the value unchanged on success for chaining.
*/
export function validateDriverRateLimit(
value: unknown,
@@ -183,9 +183,9 @@ function validateBySubscription(value: unknown, label: string): void {
}
/**
* Resolve the spec that applies to a given method on a driver. Per-method
* entry wins over `default`; returns `undefined` if neither is set so the
* caller can apply its own fallback.
* Resolve the spec that applies to a given method on a driver. Per-method entry
* wins over `default`; returns `undefined` if neither is set so the caller can
* apply its own fallback.
*/
export function resolveDriverMethodRateLimit(
cfg: DriverRateLimitConfig | undefined,
@@ -205,15 +205,15 @@ export interface DriverConcurrentSpec {
/** Maximum simultaneous in-flight requests. */
limit: number;
/**
* Per-subscription overrides keyed by `SubscriptionPolicy.id`
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to
* `limit` when the actor's subscription isn't in the map.
* Per-subscription overrides keyed by `SubscriptionPolicy.id` (`user_free`,
* `temp_free`, `unlimited`, etc.). Falls back to `limit` when the actor's
* subscription isn't in the map.
*/
bySubscription?: Record<string, number>;
/**
* Storage backend. Memory is per-process (use only on single-node
* deployments); redis coordinates across nodes; kv is rarely the
* right choice for concurrency but supported for parity.
* deployments); redis coordinates across nodes; kv is rarely the right
* choice for concurrency but supported for parity.
*/
backend?: RateLimitBackend;
}
@@ -226,8 +226,8 @@ export interface DriverConcurrentConfig {
}
/**
* Validate a `concurrent` block. Mirrors `validateDriverRateLimit` —
* throws with a labelled path so a malformed entry surfaces at boot.
* Validate a `concurrent` block. Mirrors `validateDriverRateLimit` — throws
* with a labelled path so a malformed entry surfaces at boot.
*/
export function validateDriverConcurrent(
value: unknown,
@@ -284,10 +284,10 @@ function validateConcurrentSpec(value: unknown, label: string): void {
}
/**
* Resolve the concurrent spec for a given method on a driver. Same
* precedence as `resolveDriverMethodRateLimit`: per-method wins over
* `default`; `undefined` means no concurrency cap is declared, and the
* caller should leave the method unbounded.
* Resolve the concurrent spec for a given method on a driver. Same precedence
* as `resolveDriverMethodRateLimit`: per-method wins over `default`;
* `undefined` means no concurrency cap is declared, and the caller should leave
* the method unbounded.
*/
export function resolveDriverMethodConcurrent(
cfg: DriverConcurrentConfig | undefined,
@@ -320,31 +320,30 @@ export interface DriverMeta {
/**
* Rate-limit policy for this driver. Per-method specs override the
* `default` spec; both are optional. `DriverController` consults this
* before invoking the method and falls back to the global driver
* default (600/min) if nothing is declared.
* before invoking the method and falls back to the global driver default
* (600/min) if nothing is declared.
*/
rateLimit?: DriverRateLimitConfig;
/**
* Concurrent in-flight policy for this driver. When set, the
* controller acquires a slot before invoking the method and releases
* in `finally`. Absent → no concurrency cap (current behaviour).
* Concurrent in-flight policy for this driver. When set, the controller
* acquires a slot before invoking the method and releases in `finally`.
* Absent → no concurrency cap (current behaviour).
*/
concurrent?: DriverConcurrentConfig;
/**
* When true, `/drivers/call` rejects bare account-session ("root")
* tokens for this driver: callers must present an app/worker token or
* a dashboard-minted API token. Per-driver counterpart of the
* `noUserSession` route option — the dispatch route is shared, so the
* flag lives on the driver. Set on the AI drivers so a copied session
* token can't double as an AI credential.
* When true, `/drivers/call` rejects bare account-session ("root") tokens
* for this driver: callers must present an app/worker token or a
* dashboard-minted API token. Per-driver counterpart of the `noUserSession`
* route option — the dispatch route is shared, so the flag lives on the
* driver.
*/
noUserSession?: boolean;
}
/**
* Extract driver metadata from a driver instance. Checks decorator-set
* prototype metadata first, then falls back to instance properties.
* Returns `null` if the driver doesn't declare an interface.
* prototype metadata first, then falls back to instance properties. Returns
* `null` if the driver doesn't declare an interface.
*/
export function resolveDriverMeta(
driver: WithLifecycle & Record<string, unknown>,
@@ -414,15 +413,15 @@ export function resolveDriverMeta(
/**
* Framework/lifecycle method names that must never be reachable via
* `/drivers/call`. These live on `PuterDriver` (see `drivers/types.ts`) and
* are the machinery the dispatch surface must exclude. For class-based
* drivers a concrete `override` of one still carries the same name and is
* caught here; for plain-object drivers (registered by extensions — see
* `server.ts`, `typeof DriverClass === 'object'`) there is no base prototype
* to distinguish them, so this denylist is the *only* thing keeping a
* hook off the RPC surface. Any lifecycle hook added to `PuterDriver` must
* be added here too — the per-driver guard test (`callableMethods.test.ts`)
* fails loudly if a base method starts leaking into every driver's surface.
* `/drivers/call`. These live on `PuterDriver` (see `drivers/types.ts`) and are
* the machinery the dispatch surface must exclude. For class-based drivers a
* concrete `override` of one still carries the same name and is caught here;
* for plain-object drivers (registered by extensions — see `server.ts`, `typeof
* DriverClass === 'object'`) there is no base prototype to distinguish them, so
* this denylist is the _only_ thing keeping a hook off the RPC surface. Any
* lifecycle hook added to `PuterDriver` must be added here too — the per-driver
* guard test (`callableMethods.test.ts`) fails loudly if a base method starts
* leaking into every driver's surface.
*/
export const RESERVED_DRIVER_METHODS: ReadonlySet<string> = new Set([
'onServerStart',
@@ -437,16 +436,16 @@ export const RESERVED_DRIVER_METHODS: ReadonlySet<string> = new Set([
* The RPC surface is defined structurally rather than by a hand-maintained
* per-method allow-list. Walking from the instance up to (but not including)
* `Object.prototype`, a name is callable iff it resolves to a function and is
* neither `constructor` nor a `RESERVED_DRIVER_METHODS` entry. This covers
* both driver shapes the server accepts (`server.ts`): class instances (RPC
* methods on the concrete prototype, config on the instance) and plain
* objects (everything own, used verbatim by extensions). It excludes all
* neither `constructor` nor a `RESERVED_DRIVER_METHODS` entry. This covers both
* driver shapes the server accepts (`server.ts`): class instances (RPC methods
* on the concrete prototype, config on the instance) and plain objects
* (everything own, used verbatim by extensions). It excludes all
* `Object.prototype` members (`toString`, `valueOf`, `__proto__`, …), the
* `constructor`, and the lifecycle hooks.
*
* `#`-private helpers need no handling: they are not real property keys, so
* `getOwnPropertyNames` never lists them and `driver['#x']` is `undefined`.
* Only *plain* public methods can appear here.
* Only _plain_ public methods can appear here.
*
* Getters are excluded (we read the descriptor's `.value`, never access the
* property), so evaluating this set never runs driver code. Intended to be
+31 -31
View File
@@ -76,13 +76,13 @@ export const createPgMockPostgresDatabaseClient = async (
/**
* When `PUTER_TEST_DB_ENGINE=postgres` is set, `setupTestServer` swaps its
* default sqlite test database for an in-memory Postgres backed by pgmock
* (with the bundled Postgres migrations applied on boot). Tests that
* explicitly override `database` still win — the env var only affects the
* implicit default used by callers that don't pass any DB overrides.
* default sqlite test database for an in-memory Postgres backed by pgmock (with
* the bundled Postgres migrations applied on boot). Tests that explicitly
* override `database` still win — the env var only affects the implicit default
* used by callers that don't pass any DB overrides.
*
* Recognized values: `postgres` → pgmock. Anything else (including unset) →
* the original sqlite-in-memory default.
* Recognized values: `postgres` → pgmock. Anything else (including unset) → the
* original sqlite-in-memory default.
*/
const testDatabaseDefault = (): IConfig['database'] => {
const engine = (process.env.PUTER_TEST_DB_ENGINE ?? '').toLowerCase();
@@ -105,10 +105,10 @@ export type SetupTestServerOptions = {
};
/**
* Grab a free port by binding to 0 and releasing it. Done up-front (rather
* than letting the server listen on 0) so the port is known while building
* config — `origin` / `api_base_url` consumers like LocalWorkerService read
* it at construction time.
* Grab a free port by binding to 0 and releasing it. Done up-front (rather than
* letting the server listen on 0) so the port is known while building config —
* `origin` / `api_base_url` consumers like LocalWorkerService read it at
* construction time.
*/
export const allocateEphemeralPort = (): Promise<number> =>
new Promise((resolve, reject) => {
@@ -210,25 +210,25 @@ export type TestUserCredentials = {
password: string;
token: string;
/**
* Full-access access token (what the dashboard's "API Token" flow
* mints). AI surfaces reject bare session tokens (`noUserSession`),
* so suites exercising them authenticate with this instead.
* Full-access access token (what the dashboard's "API Token" flow mints).
* The `/puterai/*` wire routes reject bare session tokens
* (`noUserSession`), so suites exercising them authenticate with this.
*/
apiToken: string;
/**
* User-scoped worker session token (what deploying a worker with no
* app binding mints — `kind='worker'` session row). Never treated as
* a root token: suites use it to prove worker credentials pass the
* `noUserSession` gates.
* User-scoped worker session token (what deploying a worker with no app
* binding mints — `kind='worker'` session row). Never treated as a root
* token: suites use it to prove worker credentials pass the `noUserSession`
* gates.
*/
workerToken: string;
};
/**
* Seed a user with a known password directly through the stores (same steps
* as DefaultUserService's admin bootstrap: bcrypt-hashed password, home
* directory tree, optional admin-group membership) and mint a session token
* the same way `POST /login` does.
* Seed a user with a known password directly through the stores (same steps as
* DefaultUserService's admin bootstrap: bcrypt-hashed password, home directory
* tree, optional admin-group membership) and mint a session token the same way
* `POST /login` does.
*/
export const createTestUser = async (
server: PuterServer,
@@ -292,20 +292,20 @@ export const createTestUser = async (
export type PuterTestEnv = {
/**
* Root origin (`http://puter.localhost:<port>`) — GUI and root-only
* routes like `POST /login` live here.
* Root origin (`http://puter.localhost:<port>`) — GUI and root-only routes
* like `POST /login` live here.
*/
origin: string;
/**
* API origin (`http://api.puter.localhost:<port>`) — what puter.js
* clients use as their APIOrigin. Routes gated on the `api` subdomain
* (e.g. `/whoami`) only match this host.
* API origin (`http://api.puter.localhost:<port>`) — what puter.js clients
* use as their APIOrigin. Routes gated on the `api` subdomain (e.g.
* `/whoami`) only match this host.
*/
apiOrigin: string;
/**
* Seeded accounts: an admin and two regular (non-privileged) users.
* `other` exists so suites can exercise cross-user flows (permission
* grants, access denials) without creating users on the fly.
* Seeded accounts: an admin and two regular (non-privileged) users. `other`
* exists so suites can exercise cross-user flows (permission grants, access
* denials) without creating users on the fly.
*/
users: {
admin: TestUserCredentials;
@@ -332,8 +332,8 @@ export const TEST_OTHER_USER_CREDENTIALS = {
/**
* Boot an in-memory Puter server on a real ephemeral port with deterministic
* credentials, for client test runners (puter.js on node, browsers, workerd).
* Clients can authenticate with the pre-minted tokens or via a real
* `POST /login` using the fixed passwords — no stdout scraping.
* Clients can authenticate with the pre-minted tokens or via a real `POST
* /login` using the fixed passwords — no stdout scraping.
*/
export const setupPuterTestEnv = async (
configOverrides?: IConfig,
+18 -14
View File
@@ -8,10 +8,10 @@ import { suite, type TestContext } from '../harness/types.ts';
*/
/**
* The AI drivers reject bare session tokens (`noUserSession` driver meta):
* programmatic AI callers must hold an app/worker token or a
* dashboard-minted API token. Authenticate each AI test the way a real
* caller would — with the full-access API token. The harness re-issues the
* The `/puterai/*` wire routes still require a delegated credential, so
* these tests authenticate the way a programmatic caller would — with the
* full-access API token. (The drivers themselves also take a plain session
* token; that path has its own test below.) The harness re-issues the
* session token to shared SDK instances between tests, so no restore is
* needed here.
*/
@@ -190,9 +190,10 @@ export default suite('ai', {
}
},
'a bare session token cannot call the AI driver': async (t) => {
// No useApiToken here — the point is that the account session
// ("root") token is rejected with guidance toward app/API tokens.
'a bare session token can call the AI driver': async (t) => {
// No useApiToken here — privileged ("godmode") apps run on the
// user's own account session token, so the driver has to accept
// it. The `/puterai/*` wire routes still don't (see below).
const res = await fetch(`${t.env.apiOrigin}/drivers/call`, {
method: 'POST',
headers: {
@@ -209,22 +210,25 @@ export default suite('ai', {
},
}),
});
t.assert.equal(res.status, 403, 'session token should be rejected');
const body = JSON.stringify(await res.json());
t.assert.equal(
res.status,
200,
`session token should reach the driver, got ${res.status}: ${body}`,
);
t.assert.ok(
body.includes('app_or_api_token_required'),
`rejection should carry app_or_api_token_required, got ${body}`,
!body.includes('app_or_api_token_required'),
`session token must not be rejected by credential shape, got ${body}`,
);
},
'a worker token passes the AI credential gate': async (t) => {
'a worker token can call the AI driver': async (t) => {
// Workers are never treated as root tokens. This uses a REAL
// user-scoped worker session token (minted the same way an
// app-less worker deployment mints one), so the whole middleware
// path is exercised: JWT → session row (kind='worker') → actor →
// noUserSession gate. Calling the driver's `models` method keeps
// this free of any AI inference — the gate rejects by credential
// shape before the handler, so a 200 here proves admission.
// driver. Calling the driver's `models` method keeps this free of
// any AI inference.
const res = await fetch(`${t.env.apiOrigin}/drivers/call`, {
method: 'POST',
headers: {