diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index b80ccabc03603..75de74157c4fe 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -4,7 +4,8 @@ import { CodexSettings, EasDeviceHostConfig, type ServerSettings, - UsageLimitSourceConfig, + CliproxyUsageLimitSourceConfig, + CommandUsageLimitSourceConfig, secretSettingsFields, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; @@ -31,7 +32,10 @@ const providerSecretFields = new Map([ [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const, ), ]); -const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); +const usageLimitSecretFields = [ + ...secretSettingsFields(CliproxyUsageLimitSourceConfig), + ...secretSettingsFields(CommandUsageLimitSourceConfig), +]; const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index b8976fca81e7d..44b0ffac321a4 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -4,7 +4,10 @@ import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -78,6 +81,198 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => HttpClient.HttpClient, HttpClient.make(() => Effect.die("Unexpected network request")), ), + commandSpawnerLayer({}), + NodeCrypto.layer, + ]), + Effect.scoped, + ), +); + +const VALID_COMMAND_OUTPUT = JSON.stringify({ + accounts: [ + { + id: "droid-b", + driver: "droid", + email: "support@scenextras.com", + plan: "pro", + usageLimits: { + checkedAt: "2026-10-23T00:00:00.000Z", + windows: [ + { + id: "monthly", + kind: "monthly", + label: "Monthly quota", + usedPercent: 41, + }, + ], + }, + }, + ], +}); + +const commandSpawnerLayer = ( + outputs: Readonly< + Record + >, +) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const result = "command" in command ? outputs[command.command] : undefined; + if (result === undefined) { + return Effect.die( + `Unexpected command: ${"command" in command ? command.command : "piped"}`, + ); + } + const encode = new TextEncoder(); + const stdoutBytes = + typeof result.stdout === "string" ? encode.encode(result.stdout) : result.stdout; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: stdoutBytes.length > 0 ? Stream.make(stdoutBytes) : Stream.empty, + stderr: + (result.stderr ?? "").length > 0 + ? Stream.make(encode.encode(result.stderr ?? "")) + : Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +it.effect("reads accounts from command sources and reports command failures", () => + Effect.gen(function* () { + const sources = yield* UsageLimitSources.make; + yield* sources.refresh; + const snapshots = yield* sources.current; + + const byId = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot])); + expect(snapshots.length).toBe(5); + + const ok = byId.get(UsageLimitSourceId.make("command-ok")); + expect(ok?.kind).toBe("command"); + expect(ok?.label).toBe("/usr/local/bin/droidpool"); + expect(ok?.error).toBeUndefined(); + expect(ok?.accounts).toEqual([ + { + id: "droid-b", + driver: "droid", + email: "support@scenextras.com", + plan: "pro", + usageLimits: { + checkedAt: "2026-10-23T00:00:00.000Z", + windows: [ + { + id: "monthly", + kind: "monthly", + label: "Monthly quota", + usedPercent: 41, + }, + ], + }, + }, + ]); + + const named = byId.get(UsageLimitSourceId.make("command-named")); + expect(named?.label).toBe("Pool status"); + expect(named?.accounts.length).toBe(1); + + const malformed = byId.get(UsageLimitSourceId.make("command-bad-json")); + expect(malformed?.accounts).toEqual([]); + expect(malformed?.error).toBe( + 'The command\'s output is not valid usage-limit JSON (expected { "accounts": [...] }).', + ); + + const failing = byId.get(UsageLimitSourceId.make("command-fails")); + expect(failing?.accounts).toEqual([]); + expect(failing?.error).toBe( + "The command failed with exit code 3. pool is locked by another process", + ); + + const overflow = byId.get(UsageLimitSourceId.make("command-overflow")); + expect(overflow?.accounts).toEqual([]); + expect(overflow?.error).toBe("The command produced more than 4 MiB of output."); + + const reset = yield* sources + .consumeResetCredit({ + sourceId: UsageLimitSourceId.make("command-ok"), + accountId: "droid-b", + creditId: "credit-1", + }) + .pipe(Effect.result); + expect(reset._tag).toBe("Failure"); + if (reset._tag === "Failure") { + expect(reset.failure.detail).toBe( + "Reset credits are only available for CLIProxyAPI hub sources.", + ); + } + }).pipe( + Effect.provide([ + ServerSettings.layerTest({ + usageLimitSources: { + [UsageLimitSourceId.make("command-ok")]: { + kind: "command", + command: "/usr/local/bin/droidpool", + args: ["list", "--json"], + enabled: true, + }, + [UsageLimitSourceId.make("command-named")]: { + kind: "command", + label: "Pool status", + command: "/usr/local/bin/droidpool2", + enabled: true, + }, + [UsageLimitSourceId.make("command-bad-json")]: { + kind: "command", + command: "/usr/local/bin/bad-json", + enabled: true, + }, + [UsageLimitSourceId.make("command-fails")]: { + kind: "command", + command: "/usr/local/bin/fails", + enabled: true, + }, + [UsageLimitSourceId.make("command-overflow")]: { + kind: "command", + command: "/usr/local/bin/overflow", + enabled: true, + }, + [UsageLimitSourceId.make("command-off")]: { + kind: "command", + command: "/usr/local/bin/droidpool", + enabled: false, + }, + }, + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected network request")), + ), + commandSpawnerLayer({ + "/usr/local/bin/droidpool": { stdout: VALID_COMMAND_OUTPUT, exitCode: 0 }, + "/usr/local/bin/droidpool2": { stdout: VALID_COMMAND_OUTPUT, exitCode: 0 }, + "/usr/local/bin/bad-json": { stdout: "not json at all", exitCode: 0 }, + "/usr/local/bin/fails": { + stdout: "", + stderr: "warning: something minor\npool is locked by another process\n", + exitCode: 3, + }, + "/usr/local/bin/overflow": { + stdout: new Uint8Array(5 * 1024 * 1024), + exitCode: 0, + }, + }), NodeCrypto.layer, ]), Effect.scoped, diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 0ba4ecf49b9ab..a7ebb8db02d00 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -23,6 +23,7 @@ import * as Stream from "effect/Stream"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as Settings from "../serverSettings.ts"; import { makeCliproxyApi } from "./cliproxyApi.ts"; +import { makeCommandApi } from "./commandApi.ts"; export class UsageLimitSources extends Context.Service< UsageLimitSources, @@ -39,6 +40,7 @@ export class UsageLimitSources extends Context.Service< function sourceLabel(id: string, config: UsageLimitSourceConfig): string { if (config.label) return config.label; const fallback = id.trim() || "Usage limit source"; + if (config.kind === "command") return config.command || fallback; try { return new URL(config.url).host || fallback; } catch { @@ -49,6 +51,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; + const commandApi = yield* makeCommandApi; const settingsService = yield* Settings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const stateRef = yield* Ref.make>([]); @@ -63,10 +66,16 @@ export const make = Effect.gen(function* () { ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const base = { id, kind: config.kind, label: sourceLabel(id, config), checkedAt } as const; - if (config.managementKey.length === 0) { + const read = + config.kind === "command" + ? commandApi.readAccounts(config) + : config.managementKey.length === 0 + ? null + : api.readAccounts(config); + if (read === null) { return { ...base, accounts: [], error: "No management key configured." }; } - const accounts = yield* api.readAccounts(config).pipe(Effect.result); + const accounts = yield* read.pipe(Effect.result); if (accounts._tag === "Failure") { yield* Effect.logDebug("usage limit source read failed", { id, cause: accounts.failure }); return { ...base, accounts: [], error: accounts.failure.detail }; @@ -110,7 +119,17 @@ export const make = Effect.gen(function* () { Effect.mapError(() => new UsageLimitSourceError({ detail: "Could not read hub settings." })), ); const config = settings.usageLimitSources[input.sourceId]; - if (!config?.enabled || !config.managementKey) { + if (!config?.enabled) { + return yield* new UsageLimitSourceError({ + detail: "The usage limit source is missing or disabled.", + }); + } + if (config.kind !== "cliproxy") { + return yield* new UsageLimitSourceError({ + detail: "Reset credits are only available for CLIProxyAPI hub sources.", + }); + } + if (!config.managementKey) { return yield* new UsageLimitSourceError({ detail: "The usage limit source is missing or disabled.", }); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 4c981b96dfb6d..1c9a4ce151d69 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -3,7 +3,7 @@ import { UsageLimitSourceError, type ProviderConsumeResetCreditResult, type UsageLimitSourceAccount, - type UsageLimitSourceConfig, + type CliproxyUsageLimitSourceConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -164,7 +164,7 @@ export const makeCliproxyApi = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const management = Effect.fn("CliproxyApi.management")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, path: string, body?: unknown, ) { @@ -192,13 +192,15 @@ export const makeCliproxyApi = Effect.gen(function* () { return response; }); - const authFiles = Effect.fn("CliproxyApi.authFiles")(function* (config: UsageLimitSourceConfig) { + const authFiles = Effect.fn("CliproxyApi.authFiles")(function* ( + config: CliproxyUsageLimitSourceConfig, + ) { const response = yield* management(config, "auth-files"); return (yield* decodeAuthFiles(response)).files; }); const apiCall = Effect.fn("CliproxyApi.apiCall")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, url: string, data?: unknown, @@ -237,7 +239,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const credits = Effect.fn("CliproxyApi.credits")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, ) { const body = yield* apiCall(config, account, CREDIT_URL); @@ -254,7 +256,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccount = Effect.fn("CliproxyApi.readAccount")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, account: typeof AuthFile.Type, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -354,7 +356,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const readAccounts = Effect.fn("CliproxyApi.readAccounts")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, ): Effect.fn.Return, UsageLimitSourceError> { const accounts = yield* authFiles(config).pipe( Effect.mapError((error) => @@ -374,7 +376,7 @@ export const makeCliproxyApi = Effect.gen(function* () { }); const consume = Effect.fn("CliproxyApi.consume")(function* ( - config: UsageLimitSourceConfig, + config: CliproxyUsageLimitSourceConfig, accountId: string, creditId: string, ): Effect.fn.Return { diff --git a/apps/server/src/usage/commandApi.ts b/apps/server/src/usage/commandApi.ts new file mode 100644 index 0000000000000..6a9b2cb494d8f --- /dev/null +++ b/apps/server/src/usage/commandApi.ts @@ -0,0 +1,131 @@ +import { + UsageLimitSourceAccount, + UsageLimitSourceError, + type CommandUsageLimitSourceConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +const COMMAND_TIMEOUT_MS = 30_000; +const MAX_STDOUT_BYTES = 4 * 1024 * 1024; +const MAX_STDERR_BYTES = 8 * 1024; +const MAX_ERROR_DETAIL = 200; + +interface BoundedText { + readonly text: string; + readonly truncated: boolean; +} + +/** + * Bounds cumulative captured bytes, not chunk count: `Stream.take` would only + * cap how many `Uint8Array` chunks are emitted, so a fast writer could still + * queue unbounded output before the timeout. Bytes past `maxBytes` are + * discarded as the stream keeps draining, so the child never blocks on a full + * pipe and memory stays flat. + */ +const collectBoundedText = ( + stream: Stream.Stream, + maxBytes: number, +): Effect.Effect => { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + (): { text: string; bytes: number; truncated: boolean } => ({ + text: "", + bytes: 0, + truncated: false, + }), + (acc, chunk) => { + const room = maxBytes - acc.bytes; + const kept = room <= 0 ? chunk.subarray(0, 0) : chunk.subarray(0, room); + return { + text: kept.byteLength > 0 ? acc.text + decoder.decode(kept, { stream: true }) : acc.text, + bytes: acc.bytes + kept.byteLength, + truncated: acc.truncated || kept.byteLength < chunk.byteLength, + }; + }, + ).pipe( + // Flush any buffered incomplete UTF-8 sequence the stream left behind. + Effect.map((acc) => ({ + text: acc.text + decoder.decode(), + truncated: acc.truncated, + })), + ); +}; + +const CommandAccountsOutput = Schema.Struct({ + accounts: Schema.Array(UsageLimitSourceAccount), +}); +const decodeCommandOutput = Schema.decodeUnknownEffect( + Schema.fromJsonString(CommandAccountsOutput), +); + +/** + * Runs a `kind: "command"` usage-limit source. The command owns credential + * lookup itself — T3 only reads the `{ "accounts": [...] }` JSON document it + * prints on stdout. + */ +export const makeCommandApi = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readAccounts = Effect.fn("CommandApi.readAccounts")(function* ( + config: CommandUsageLimitSourceConfig, + ): Effect.fn.Return, UsageLimitSourceError> { + const result = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(config.command, [...config.args], { + forceKillAfter: "1 second", + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectBoundedText(child.stdout, MAX_STDOUT_BYTES), + collectBoundedText(child.stderr, MAX_STDERR_BYTES), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; + }), + ).pipe( + Effect.timeout(COMMAND_TIMEOUT_MS), + Effect.mapError((cause) => { + const detail = Cause.isTimeoutError(cause) + ? `The command did not finish within ${COMMAND_TIMEOUT_MS / 1000} seconds.` + : `The command could not be run (${config.command}): ${cause instanceof Error ? cause.message : String(cause)}`; + return new UsageLimitSourceError({ detail }); + }), + ); + + if (result.stdout.truncated) { + return yield* new UsageLimitSourceError({ + detail: `The command produced more than ${MAX_STDOUT_BYTES / (1024 * 1024)} MiB of output.`, + }); + } + + if (result.exitCode !== 0) { + const stderrTail = result.stderr.text.trim().split("\n").pop()?.slice(0, MAX_ERROR_DETAIL); + return yield* new UsageLimitSourceError({ + detail: `The command failed with exit code ${result.exitCode}.${stderrTail ? ` ${stderrTail}` : ""}`, + }); + } + + const output = yield* decodeCommandOutput(result.stdout.text).pipe( + Effect.mapError( + () => + new UsageLimitSourceError({ + detail: + 'The command\'s output is not valid usage-limit JSON (expected { "accounts": [...] }).', + }), + ), + ); + return output.accounts; + }); + + return { readAccounts }; +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index 317bf71338133..3a0efaecba043 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -15,21 +15,94 @@ import { import { Input } from "../ui/input"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; +import { Switch } from "../ui/switch"; /** * Dots and dashes in the host are kept so `foo-bar.com` and `foo.bar.com` do * not collide; anything else (a port's colon, a path) is folded to a dash. */ +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + function sourceIdFromUrl(url: string): UsageLimitSourceId { let host = url; try { host = new URL(url).host; } catch {} - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); + return UsageLimitSourceId.make(`cliproxy-${slugify(host) || "hub"}`); +} + +function sourceIdFromCommand(command: string, args: ReadonlyArray): UsageLimitSourceId { + const slug = slugify([command, ...args].join(" ")) + .slice(0, 40) + .replace(/-+$/g, ""); + // The slug is a display hint; the hash keeps two differently-argumented + // sources for the same executable distinct even past the cutoff. + return UsageLimitSourceId.make( + `command-${slug || "usage"}-${shortHash(`${command}${args.join("\0")}`)}`, + ); +} + +function shortHash(value: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193) >>> 0; + } + return hash.toString(36); +} + +/** + * Whitespace separates arguments; single and double quotes group them. A + * backslash only escapes a quote or another backslash, so ordinary path + * separators in quoted Windows paths survive. An unclosed quote just runs to + * the end of the input. + */ +function parseArgsText(text: string): string[] { + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let pending = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (quote === "'") { + if (ch === "'") quote = null; + else current += ch; + continue; + } + const next = text[i + 1]; + if (ch === "\\" && (next === '"' || next === "'" || next === "\\")) { + current += next; + i++; + pending = true; + continue; + } + if (quote === '"') { + if (ch === '"') quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + pending = true; + continue; + } + if (/\s/.test(ch)) { + if (pending || current.length > 0) { + args.push(current); + current = ""; + pending = false; + } + continue; + } + current += ch; + pending = true; + } + if (pending || current.length > 0) args.push(current); + return args; } /** @@ -48,31 +121,51 @@ export function AddUsageLimitSourceDialog({ readonly environmentLabel: string; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [kind, setKind] = useState<"cliproxy" | "command">("cliproxy"); const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [managementKey, setManagementKey] = useState(""); + const [command, setCommand] = useState(""); + const [argsText, setArgsText] = useState(""); const trimmedUrl = url.trim(); - const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0; + const trimmedCommand = command.trim(); + const isCommand = kind === "command"; + const canSave = isCommand + ? trimmedCommand.length > 0 + : trimmedUrl.length > 0 && managementKey.trim().length > 0; const reset = () => { setLabel(""); setUrl(""); setManagementKey(""); + setCommand(""); + setArgsText(""); }; const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); + const labelField = label.trim() ? { label: label.trim() } : {}; + const args = parseArgsText(argsText); updateSettings({ - usageLimitSources: { - [id]: { - kind: "cliproxy", - ...(label.trim() ? { label: label.trim() } : {}), - url: trimmedUrl, - managementKey: managementKey.trim(), - enabled: true, - }, - }, + usageLimitSources: isCommand + ? { + [sourceIdFromCommand(trimmedCommand, args)]: { + kind: "command" as const, + ...labelField, + command: trimmedCommand, + args, + enabled: true, + }, + } + : { + [sourceIdFromUrl(trimmedUrl)]: { + kind: "cliproxy" as const, + ...labelField, + url: trimmedUrl, + managementKey: managementKey.trim(), + enabled: true, + }, + }, }); reset(); onOpenChange(false); @@ -88,10 +181,12 @@ export function AddUsageLimitSourceDialog({ > - Add a CLIProxyAPI hub + Add a usage source - Show the quota of every account the hub pools, next to the providers on{" "} - {environmentLabel}. The key stays on that server. + Show account quota next to the providers on {environmentLabel} + {isCommand + ? ". The command prints the accounts JSON on the server." + : ". The key stays on that server."} @@ -102,29 +197,70 @@ export function AddUsageLimitSourceDialog({ save(); }} > -
- - setUrl(event.target.value)} - autoFocus - /> -
-
- - setManagementKey(event.target.value)} +
+ setKind(checked ? "command" : "cliproxy")} /> +
+ {isCommand ? ( + <> +
+ + setCommand(event.target.value)} + autoFocus + /> +
+
+ + setArgsText(event.target.value)} + /> +
+

+ The command must print {'{ "accounts": [...] }'} JSON on stdout and exit 0. Keep + credentials inside the script, not in arguments. +

+ + ) : ( + <> +
+ + setUrl(event.target.value)} + autoFocus + /> +
+
+ + setManagementKey(event.target.value)} + /> +
+ + )}
setLabel(event.target.value)} /> @@ -142,7 +278,7 @@ export function AddUsageLimitSourceDialog({ Cancel diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index bab7ddd9c52a6..2510cc41f23d3 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -91,21 +91,24 @@ export function UsageProviderSettings({ ) : ( entries.map(([id, source]) => { - const label = source.label?.trim() || source.url; + const target = source.kind === "command" ? source.command : source.url; + const label = source.label?.trim() || target; return ( - CLI Proxy{source.enabled ? "" : " · Disabled"} - {label !== source.url ? ` · ${source.url}` : ""} + {source.kind === "command" ? "Command" : "CLI Proxy"} + {source.enabled ? "" : " · Disabled"} + {label !== target ? ` · ${target}` : ""} } control={ !readOnly ? ( updateSettings({ usageLimitSources: { [id]: null } })} /> ) : null @@ -129,9 +132,11 @@ export function UsageProviderSettings({ function RemoveUsageProviderButton({ label, + kind, onConfirm, }: { readonly label: string; + readonly kind: "cliproxy" | "command"; readonly onConfirm: () => void; }) { const [open, setOpen] = useState(false); @@ -145,9 +150,9 @@ function RemoveUsageProviderButton({ Remove {label}? - The hub's management key is deleted from this server. Its accounts leave the Limits - view; the hub itself is untouched. Add it again with the URL and key to bring them - back. + {kind === "command" + ? "This usage source is removed from settings; its accounts leave the Limits view. The command itself is untouched." + : "The hub's management key is deleted from this server. Its accounts leave the Limits view; the hub itself is untouched. Add it again with the URL and key to bring them back."} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 894a33a05e5fc..dd0d2c9dadbf1 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -78,7 +78,7 @@ export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; export const UsageLimitSourceSnapshot = Schema.Struct({ id: UsageLimitSourceId, - kind: Schema.Literal("cliproxy"), + kind: Schema.Literals(["cliproxy", "command"]), label: TrimmedNonEmptyString, checkedAt: IsoDateTime, accounts: ForwardCompatibleArray(UsageLimitSourceAccount), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4d55e0e038fe4..a5d5ea4408a10 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -815,13 +815,36 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; -export const UsageLimitSourceConfig = Schema.Struct({ +export const CliproxyUsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), url: TrimmedNonEmptyString, managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); +export type CliproxyUsageLimitSourceConfig = typeof CliproxyUsageLimitSourceConfig.Type; + +/** + * `kind: "command"` sources run a command on the T3 server host and read a + * JSON document of the form `{ "accounts": UsageLimitSourceAccount[] }` from + * its stdout. This is the escape hatch for providers that can report quota + * or rate-limit windows but have no CLIProxyAPI hub integration — the + * command itself owns credential lookup, so nothing secret has to live in + * `args`. + */ +export const CommandUsageLimitSourceConfig = Schema.Struct({ + kind: Schema.Literal("command"), + label: Schema.optional(TrimmedNonEmptyString), + command: TrimmedNonEmptyString, + args: Schema.Array(TrimmedString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type CommandUsageLimitSourceConfig = typeof CommandUsageLimitSourceConfig.Type; + +export const UsageLimitSourceConfig = Schema.Union([ + CliproxyUsageLimitSourceConfig, + CommandUsageLimitSourceConfig, +]); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; export const GitHubHost = TrimmedNonEmptyString.pipe( @@ -1655,9 +1678,15 @@ export function requiredScopesForServerSettingsPatch( changesSettings = true; } } + // A command source spawns a configured process on the server host, so saving + // one needs the same grant as launching an agent or a shell. + const needsExecution = Object.values(patch.usageLimitSources ?? {}).some( + (source) => source?.kind === "command", + ); return [ ...(changesSettings || !changesProviders ? [AuthSettingsWriteScope] : []), ...(changesProviders ? [AuthProvidersManageScope] : []), + ...(needsExecution ? [AuthOrchestrationOperateScope] : []), ]; }