diff --git a/CHANGELOG.md b/CHANGELOG.md index 8beeac5..5773c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.0 + +- Add isolated app development sessions with stable preview URLs, migration-replayed dummy databases, immutable revisions, and correlated diagnostics. +- Add `app dev start|sync|status|request|invoke|requests|verify|promote|stop`. +- Require successful explicit invocation of every dormant development Function before exact-revision verification. +- Promote only the verified receipt and refuse stale source, migration, or production-base revisions. +- Vendor the public dev-environment API contracts and bundler safeguards for the reserved `.opencloud` runtime directory. + ## 0.4.0 - Add `agent-feed` for the stable, bounded app health, signal, alert, and diff --git a/README.md b/README.md index 34d49a4..45252e9 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ plugin when one is available in that environment. ## Install a pinned release -OpenCloud application skills pin an exact CLI release. To install `v0.4.0` in +OpenCloud application skills pin an exact CLI release. To install `v0.5.0` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v0.4.0" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-0.4.0.tgz" +OPENCLOUD_CLI_VERSION="v0.5.0" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-0.5.0.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -94,6 +94,23 @@ Existing installations can still supply `OPENCLOUD_API_URL` and See the [OpenCloud CLI reference](https://docs.opencloud.ai/reference/cli) and [agent guide](https://docs.opencloud.ai/getting-started/agents). +## Isolated development environments + +Use the stable capability preview and isolated migration-replayed database before changing production: + +```bash +"$OPENCLOUD_CLI" app dev start . +"$OPENCLOUD_CLI" app dev sync . +"$OPENCLOUD_CLI" app dev request . / +"$OPENCLOUD_CLI" app dev invoke . function-name --body '{"example":true}' +"$OPENCLOUD_CLI" app dev requests . +"$OPENCLOUD_CLI" app dev verify . +"$OPENCLOUD_CLI" app dev promote . --idempotency-key "$IDEMPOTENCY_KEY" +"$OPENCLOUD_CLI" app dev stop . +``` + +Development data is isolated from production and uses dummy records. Auth, Storage, Realtime, cron, production secrets, and implicit Function execution are unavailable. Functions imported from `@opencloud/server` remain dormant until explicitly invoked, and exact-revision verification requires every declared Function to have a successful explicit invocation. + ## Agent Feed and alert rules Read the stable app health, signal, alert, and recent-event contract without diff --git a/package-lock.json b/package-lock.json index 8d070a8..ff1c9f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencloud/cli", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "playwright": "1.62.0" }, diff --git a/package.json b/package.json index 5e4a141..34ab2dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 9dc3614..b8d18ff 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -111,6 +111,27 @@ functions: expect(archivedFiles.sort()).toEqual(first.files); }); + it("never archives local .opencloud development metadata", async () => { + const root = await temporaryDirectory(); + await mkdir(path.join(root, ".opencloud")); + await writeFile(path.join(root, "index.html"), "hello"); + await writeFile( + path.join(root, ".opencloud", "dev.json"), + JSON.stringify({ sessionId: "bearer-capability" }), + ); + await writeManifest( + root, + ` +frontend: + directory: . +`, + ); + + const bundle = await buildBundle(root); + expect(bundle.files).toContain("index.html"); + expect(bundle.files.some((file) => file.startsWith(".opencloud/"))).toBe(false); + }); + it("preserves an explicit older SDK pin instead of replacing it with current", async () => { const root = await temporaryDirectory(); await mkdir(path.join(root, "frontend")); diff --git a/src/index.ts b/src/index.ts index 466af00..edd6260 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { Command, Option } from "commander"; import YAML from "yaml"; @@ -25,7 +25,7 @@ import { const program = new Command() .name("opencloud") .description("Agent- and human-facing client for the OpenCloud control plane") - .version("0.4.0", "-V, --cli-version", "print the CLI version") + .version("0.5.0", "-V, --cli-version", "print the CLI version") .addOption( new Option("--api-url ", "Control-plane API URL").env( "OPENCLOUD_API_URL", @@ -58,8 +58,7 @@ function client(): OpenCloudClient { apiUrl?: string; token?: string; }>(); - const stored = - options.apiUrl && options.token ? null : availableSession(); + const stored = options.apiUrl && options.token ? null : availableSession(); const apiUrl = options.apiUrl ?? stored?.apiUrl; const token = options.token ?? (stored?.state === "ready" ? stored.token : undefined); @@ -69,7 +68,7 @@ function client(): OpenCloudClient { ? "Email verification is still pending. Run opencloud onboard-complete after confirming the email." : stored?.state === "starting" ? "Onboarding has not completed. Re-run the same opencloud onboard command." - : "Run opencloud onboard, set OPENCLOUD_API_URL and OPENCLOUD_TOKEN, or pass --api-url and --token.", + : "Run opencloud onboard, set OPENCLOUD_API_URL and OPENCLOUD_TOKEN, or pass --api-url and --token.", ); } return new OpenCloudClient({ @@ -94,6 +93,208 @@ function printBundleFiles(files: string[]): void { ); } +interface LocalDevState { + schemaVersion: 1; + appId: string; + draftId: string; + sessionId: string; + artifactSha256: string; + updatedAt: string; +} + +interface DevSessionWire { + id: string; + appId: string; + draftId: string; + status: string; + previewUrl: string; + activeRevision: { + id: string; + draftRevision: number; + artifactSha256: string; + } | null; + verification: { receiptId: string; revisionId: string } | null; +} + +function devStatePath(sourceRoot: string): string { + return path.join(sourceRoot, ".opencloud", "dev.json"); +} + +async function readDevState(sourceRoot: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(devStatePath(sourceRoot), "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + const value = parsed as Partial; + if ( + value.schemaVersion !== 1 || + !value.appId || + !value.draftId || + !value.sessionId || + !value.artifactSha256 || + !value.updatedAt + ) { + throw new Error( + ".opencloud/dev.json is invalid; stop the session or repair the file", + ); + } + return value as LocalDevState; +} + +async function requireDevState(sourceRoot: string): Promise { + const state = await readDevState(sourceRoot); + if (!state) { + throw new Error( + "No local development session was found. Run opencloud app dev start first.", + ); + } + return state; +} + +async function saveDevState( + sourceRoot: string, + state: LocalDevState, +): Promise { + const directory = path.dirname(devStatePath(sourceRoot)); + const temporary = path.join( + directory, + `.dev-${process.pid}-${randomUUID()}.tmp`, + ); + await mkdir(directory, { recursive: true }); + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temporary, devStatePath(sourceRoot)); +} + +async function synchronizeValidatedDraft( + control: OpenCloudClient, + sourceRoot: string, + existingDraftId?: string, +): Promise<{ + appId: string; + draftId: string; + artifactSha256: string; + validation: unknown; +}> { + const bundle = await buildBundle(sourceRoot); + printBundleFiles(bundle.files); + let draft: { id: string; revision: number }; + if (existingDraftId) { + const existing = await control.call("getDraft", { + appId: bundle.manifest.appId, + draftId: existingDraftId, + }); + if (["deploying", "deployed", "discarded"].includes(existing.status)) { + throw new Error( + `The local dev draft is ${existing.status}; start a new dev session for the next change.`, + ); + } + draft = { id: existing.id, revision: existing.revision }; + } else { + const created = await control.call("createDraft", { + appId: bundle.manifest.appId, + body: { + name: `Dev ${bundle.manifest.version}`, + cloneActive: false, + }, + }); + draft = { id: created.id, revision: created.revision }; + } + + const remoteFiles = await control.call("listDraftFiles", { + appId: bundle.manifest.appId, + draftId: draft.id, + }); + const remote = new Map( + remoteFiles + .filter((file) => !file.deleted) + .map((file) => [file.path, file]), + ); + const local = new Map(); + for (const file of bundle.files) { + const content = + file === "opencloud.json" + ? Buffer.from(`${JSON.stringify(bundle.manifest, null, 2)}\n`) + : await readFile(path.join(sourceRoot, ...file.split("/"))); + local.set(file, { + content, + sha256: createHash("sha256").update(content).digest("hex"), + }); + } + + const changes: Array<{ + path: string; + baseSha256?: string | null; + contentBase64?: string; + delete?: boolean; + }> = []; + for (const [file, value] of local) { + const existing = remote.get(file); + if (existing?.sha256 === value.sha256) continue; + changes.push({ + path: file, + ...(existing ? { baseSha256: existing.sha256 } : {}), + contentBase64: value.content.toString("base64"), + }); + } + for (const [file, existing] of remote) { + if (!local.has(file)) { + changes.push({ path: file, baseSha256: existing.sha256, delete: true }); + } + } + changes.sort((left, right) => left.path.localeCompare(right.path)); + + let revision = draft.revision; + for (let offset = 0; offset < changes.length; offset += 200) { + const applied = await control.call("applyDraftChanges", { + appId: bundle.manifest.appId, + draftId: draft.id, + body: { + expectedRevision: revision, + changes: changes.slice(offset, offset + 200), + }, + }); + revision = applied.draft.revision; + } + const validation = await control.call("validateDraft", { + appId: bundle.manifest.appId, + draftId: draft.id, + body: {}, + }); + if (!validation.passed) { + output({ draft, validation }); + throw new Error("Authoritative server validation failed"); + } + if (validation.artifactSha256 !== bundle.sha256) { + throw new Error("Local and server canonical bundle digests do not match"); + } + return { + appId: bundle.manifest.appId, + draftId: draft.id, + artifactSha256: bundle.sha256, + validation, + }; +} + +async function storeDevSession( + sourceRoot: string, + artifactSha256: string, + session: DevSessionWire, +): Promise { + await saveDevState(sourceRoot, { + schemaVersion: 1, + appId: session.appId, + draftId: session.draftId, + sessionId: session.id, + artifactSha256, + updatedAt: new Date().toISOString(), + }); +} + function onboardingApiUrl(): string { return ( program.opts<{ apiUrl?: string }>().apiUrl ?? @@ -217,9 +418,7 @@ program } const idempotencyKey = options.idempotencyKey ?? - (stored?.state === "starting" - ? stored.idempotencyKey - : randomUUID()); + (stored?.state === "starting" ? stored.idempotencyKey : randomUUID()); await saveSession(file, { schemaVersion: 1, state: "starting", @@ -234,13 +433,7 @@ program idempotencyKey, ), ); - output( - await persistOnboardingResponse( - response, - apiUrl, - file, - ), - ); + output(await persistOnboardingResponse(response, apiUrl, file)); }); program @@ -275,13 +468,7 @@ program { completionToken: stored.completionToken }, ), ); - output( - await persistOnboardingResponse( - response, - stored.apiUrl, - file, - ), - ); + output(await persistOnboardingResponse(response, stored.apiUrl, file)); }); const app = program.command("app").description("Manage OpenCloud apps"); @@ -290,6 +477,10 @@ app .command("create") .requiredOption("--name ") .option("--visibility ", "public or private", "private") + .option( + "--owner-user-id ", + "required when using the platform operator credential", + ) .option("--idempotency-key ") .action(async (options) => { output( @@ -297,9 +488,12 @@ app "createApp", { body: { - name: options.name, - visibility: options.visibility, - }, + name: options.name, + visibility: options.visibility, + ...(options.ownerUserId + ? { ownerUserId: options.ownerUserId } + : {}), + }, }, { idempotencyKey: options.idempotencyKey ?? randomUUID(), @@ -350,10 +544,8 @@ app javascriptSdk: { package: "@opencloud/js", version: deployment.javascriptSdkVersion, - module: - `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.js`, - types: - `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.d.ts`, + module: `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.js`, + types: `/_opencloud/sdk/js/v${deployment.javascriptSdkVersion}/index.d.ts`, }, }); }); @@ -380,6 +572,214 @@ app }); }); +const dev = app + .command("dev") + .description( + "Develop against an isolated preview before production deployment", + ); + +dev + .command("start") + .description("Validate, sync, and start or resume an isolated dev preview") + .argument("") + .action(async (directory) => { + const sourceRoot = callerPath(directory); + const control = client(); + const previous = await readDevState(sourceRoot); + const synchronized = await synchronizeValidatedDraft( + control, + sourceRoot, + previous?.draftId, + ); + if (previous && previous.appId !== synchronized.appId) { + throw new Error("The app manifest no longer matches .opencloud/dev.json"); + } + const session = (await control.call("startDevSession", { + appId: synchronized.appId, + draftId: synchronized.draftId, + body: { apply: true }, + })) as DevSessionWire; + await storeDevSession(sourceRoot, synchronized.artifactSha256, session); + output({ + session, + validation: synchronized.validation, + localState: devStatePath(sourceRoot), + next: [ + "Open session.previewUrl or use `opencloud app dev request /`.", + "After edits run `opencloud app dev sync `.", + "Functions remain dormant until `opencloud app dev invoke `.", + "Run `opencloud app dev verify ` before promotion.", + ], + }); + }); + +dev + .command("sync") + .description( + "Validate local files and atomically replace the active dev revision", + ) + .argument("") + .action(async (directory) => { + const sourceRoot = callerPath(directory); + const state = await requireDevState(sourceRoot); + const control = client(); + const synchronized = await synchronizeValidatedDraft( + control, + sourceRoot, + state.draftId, + ); + if (state.appId !== synchronized.appId) { + throw new Error("The app manifest no longer matches .opencloud/dev.json"); + } + const session = (await control.call("applyDevRevision", { + appId: state.appId, + sessionId: state.sessionId, + })) as DevSessionWire; + await storeDevSession(sourceRoot, synchronized.artifactSha256, session); + output({ session, validation: synchronized.validation }); + }); + +dev + .command("status") + .description( + "Show the active revision, capability URL, and verification receipt", + ) + .argument("[directory]", "app source directory", ".") + .action(async (directory) => { + const sourceRoot = callerPath(directory); + const state = await requireDevState(sourceRoot); + output( + await client().call("getDevSession", { + appId: state.appId, + sessionId: state.sessionId, + }), + ); + }); + +dev + .command("request") + .description( + "Inspect a preview page or REST GET/HEAD through the trusted edge", + ) + .argument("") + .argument("[path]", "same-origin preview path", "/") + .addOption( + new Option("--method ", "HTTP method") + .choices(["GET", "HEAD"]) + .default("GET"), + ) + .action(async (directory, requestPath, options) => { + const state = await requireDevState(callerPath(directory)); + output( + await client().call("requestDevApp", { + appId: state.appId, + sessionId: state.sessionId, + body: { + path: String(requestPath), + method: options.method as "GET" | "HEAD", + }, + }), + ); + }); + +dev + .command("invoke") + .description("Explicitly boot one dev Function without production secrets") + .argument("") + .argument("") + .option("--body ", "JSON request body", "{}") + .action(async (directory, functionName, options) => { + const state = await requireDevState(callerPath(directory)); + let body: unknown; + try { + body = JSON.parse(String(options.body)); + } catch { + throw new Error("--body must be valid JSON"); + } + output( + await client().call("invokeDevFunction", { + appId: state.appId, + sessionId: state.sessionId, + functionName: String(functionName), + body: { body }, + }), + ); + }); + +dev + .command("requests") + .description("List correlated, redacted dev Function outcomes") + .argument("[directory]", "app source directory", ".") + .option("--limit ", "maximum records", "100") + .action(async (directory, options) => { + const state = await requireDevState(callerPath(directory)); + const limit = Number(options.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 200) { + throw new Error("--limit must be an integer between 1 and 200"); + } + output( + await client().call("listDevInvocations", { + appId: state.appId, + sessionId: state.sessionId, + query: { limit }, + }), + ); + }); + +dev + .command("verify") + .description( + "Run Chromium and primary-flow checks for the exact dev revision", + ) + .argument("[directory]", "app source directory", ".") + .action(async (directory) => { + const state = await requireDevState(callerPath(directory)); + const result = await client().call("verifyDevSession", { + appId: state.appId, + sessionId: state.sessionId, + }); + output(result); + if (!result.receipt.summary.passed) process.exitCode = 1; + }); + +dev + .command("promote") + .description( + "Deploy only the exact dev revision covered by the current receipt", + ) + .argument("[directory]", "app source directory", ".") + .option("--idempotency-key ") + .action(async (directory, options) => { + const state = await requireDevState(callerPath(directory)); + const result = await client().call( + "promoteDevRevision", + { appId: state.appId, sessionId: state.sessionId }, + { + idempotencyKey: options.idempotencyKey ?? randomUUID(), + timeoutMs: 120_000, + }, + ); + output({ + ...result, + next: "Follow the returned operation, verify production, then run `opencloud app dev stop ` to remove the isolated branch.", + }); + }); + +dev + .command("stop") + .description("Destroy the preview artifacts, Function links, and dev schema") + .argument("[directory]", "app source directory", ".") + .action(async (directory) => { + const sourceRoot = callerPath(directory); + const state = await requireDevState(sourceRoot); + const result = await client().call("stopDevSession", { + appId: state.appId, + sessionId: state.sessionId, + }); + await rm(devStatePath(sourceRoot), { force: true }); + output({ session: result, localStateRemoved: true }); + }); + app .command("request") .description( @@ -412,10 +812,7 @@ app .action(async (appId) => { const value = await client().get(`/v1/apps/${appId}`); const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - const result = await smokeApp( - value, - edgeUrl ? { edgeUrl } : {}, - ); + const result = await smokeApp(value, edgeUrl ? { edgeUrl } : {}); output(result); if (!result.passed) process.exitCode = 1; }); @@ -461,9 +858,7 @@ app app .command("verify") - .description( - "Run the authoritative OpenCloud release verification gate", - ) + .description("Run the authoritative OpenCloud release verification gate") .argument("") .option("--idempotency-key ") .option("--follow", "follow the durable verification operation", true) @@ -485,9 +880,7 @@ app if (!options.follow || !started.operation?.id) return; let operationValue: { state?: string } = started.operation; while ( - !["succeeded", "failed", "cancelled"].includes( - operationValue.state ?? "", - ) + !["succeeded", "failed", "cancelled"].includes(operationValue.state ?? "") ) { await new Promise((resolve) => setTimeout(resolve, Number(options.interval) * 1000), @@ -550,9 +943,7 @@ app .argument("") .action(async (appId, credentialId) => output( - await client().delete( - `/v1/apps/${appId}/credentials/${credentialId}`, - ), + await client().delete(`/v1/apps/${appId}/credentials/${credentialId}`), ), ); @@ -585,12 +976,9 @@ program .action(async (directory, options) => { const stored = availableSession(); const appId = - options.appId ?? - (stored?.state === "ready" ? stored.appId : undefined); + options.appId ?? (stored?.state === "ready" ? stored.appId : undefined); if (!appId) { - throw new Error( - "Pass --app-id or complete opencloud onboard first", - ); + throw new Error("Pass --app-id or complete opencloud onboard first"); } const root = callerPath(directory); await mkdir(path.join(root, "frontend"), { recursive: true }); @@ -689,9 +1077,7 @@ program throw new Error("Authoritative server validation failed"); } if (validation.artifactSha256 !== bundle.sha256) { - throw new Error( - "Local and server canonical bundle digests do not match", - ); + throw new Error("Local and server canonical bundle digests do not match"); } output( await control.call( @@ -738,10 +1124,7 @@ program .option("--max-files ") .action(async (directory, options) => { const bundle = await buildBundle(callerPath(directory)); - if ( - options.expectAppId && - bundle.manifest.appId !== options.expectAppId - ) { + if (options.expectAppId && bundle.manifest.appId !== options.expectAppId) { throw new Error( `Manifest appId ${bundle.manifest.appId} does not match ${options.expectAppId}`, ); @@ -782,11 +1165,14 @@ operation .option("--interval ", "poll interval", "2") .action(async (operationId, options) => { do { - const value = (await client().get( - `/v1/operations/${operationId}`, - )) as { state?: string }; + const value = (await client().get(`/v1/operations/${operationId}`)) as { + state?: string; + }; output(value); - if (!options.follow || ["succeeded", "failed", "cancelled"].includes(value.state ?? "")) { + if ( + !options.follow || + ["succeeded", "failed", "cancelled"].includes(value.state ?? "") + ) { break; } await new Promise((resolve) => @@ -811,11 +1197,7 @@ deployment .argument("") .argument("") .action(async (appId, deploymentId) => - output( - await client().get( - `/v1/apps/${appId}/deployments/${deploymentId}`, - ), - ), + output(await client().get(`/v1/apps/${appId}/deployments/${deploymentId}`)), ); deployment @@ -844,12 +1226,7 @@ session const control = client(); const value = await control.get(`/v1/apps/${appId}`); const edgeUrl = process.env.OPENCLOUD_EDGE_URL; - output( - await verifySessions( - value, - edgeUrl ? { edgeUrl } : {}, - ), - ); + output(await verifySessions(value, edgeUrl ? { edgeUrl } : {})); }); const cron = program @@ -870,11 +1247,7 @@ cron ...(options.state ? { state: String(options.state) } : {}), ...(options.after ? { after: String(options.after) } : {}), }); - output( - await client().get( - `/v1/apps/${appId}/cron/invocations?${query}`, - ), - ); + output(await client().get(`/v1/apps/${appId}/cron/invocations?${query}`)); }); cron @@ -914,22 +1287,17 @@ program const spec = parseRuntimeVerificationSpec(source, bundle.manifest); const edgeUrl = process.env.OPENCLOUD_EDGE_URL; output( - await verifyRuntime( - control, - appValue, - spec, - edgeUrl ? { edgeUrl } : {}, - ), + await verifyRuntime(control, appValue, spec, edgeUrl ? { edgeUrl } : {}), ); }); -const secret = program.command("secret").description("Manage app-scoped secrets"); +const secret = program + .command("secret") + .description("Manage app-scoped secrets"); secret .command("generate") - .description( - "Generate and store a random secret without returning its value", - ) + .description("Generate and store a random secret without returning its value") .argument("") .argument("") .option("--bytes ", "random byte count", "32") @@ -1033,7 +1401,11 @@ backup program .command("logs") .argument("") - .option("--from ", "ISO time", new Date(Date.now() - 60 * 60 * 1000).toISOString()) + .option( + "--from ", + "ISO time", + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ) .option("--to ", "ISO time", new Date().toISOString()) .option("--contains ") .option("--level ") @@ -1054,7 +1426,11 @@ program .command("metrics") .argument("") .requiredOption("--metric ") - .option("--from ", "ISO time", new Date(Date.now() - 60 * 60 * 1000).toISOString()) + .option( + "--from ", + "ISO time", + new Date(Date.now() - 60 * 60 * 1000).toISOString(), + ) .option("--to ", "ISO time", new Date().toISOString()) .option("--aggregation ", "none, sum, avg, max, min, rate", "none") .option("--step ", "query step", "60") @@ -1152,9 +1528,7 @@ alertRule .argument("") .argument("") .action(async (appId, ruleId) => - output( - await client().delete(`/v1/apps/${appId}/alert-rules/${ruleId}`), - ), + output(await client().delete(`/v1/apps/${appId}/alert-rules/${ruleId}`)), ); program.parseAsync().catch((error: unknown) => { diff --git a/vendor/browser-client/src/index.test.ts b/vendor/browser-client/src/index.test.ts new file mode 100644 index 0000000..13d1caa --- /dev/null +++ b/vendor/browser-client/src/index.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createOpenCloudClient, + type OpenCloudRuntimeConfig, +} from "./index.js"; + +const origin = "https://tasks.opencloud.ai"; +const initialNow = Date.parse("2026-01-01T00:00:00.000Z"); + +const runtimeConfig: OpenCloudRuntimeConfig = { + appId: "11111111-1111-4111-8111-111111111111", + deploymentVersion: "v1", + visibility: "private", + supabaseUrl: origin, + supabaseAnonKey: "anon-project-key", + storageBucket: "app-11111111-1111-4111-8111-111111111111", + functionsBasePath: "/functions/v1", + javascriptSdk: { + package: "@opencloud/js", + version: "0.2.2", + module: "/_opencloud/sdk/js/v0.2.2/index.js", + types: "/_opencloud/sdk/js/v0.2.2/index.d.ts", + docs: "https://docs.opencloud.ai/sdk/javascript/", + }, + browserClient: "/_opencloud/sdk/js/v0.2.2/index.js", + environment: "prod", +}; + +function wireSession( + accessToken: string, + refreshAfter = "2026-01-01T00:30:00.000Z", +) { + return { + appId: runtimeConfig.appId, + userId: "22222222-2222-4222-8222-222222222222", + profile: { + email: "person@example.test", + displayName: "Test Person", + avatarUrl: null, + }, + accessToken, + accessTokenExpiresAt: "2026-01-01T00:31:00.000Z", + refreshAfter, + sessionExpiresAt: "2026-01-31T00:00:00.000Z", + }; +} + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + readonly sent: Record[] = []; + readonly url: string; + readyState = 0; + private readonly listeners = new Map void>>(); + + constructor(url: string | URL) { + this.url = String(url); + FakeWebSocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.emit("open", {}); + }); + } + + addEventListener(type: string, listener: (event: never) => void): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + send(source: string): void { + const message = JSON.parse(source) as Record; + this.sent.push(message); + if (message.event === "phx_join") { + queueMicrotask(() => { + this.emit("message", { + data: JSON.stringify({ + event: "phx_reply", + ref: message.ref, + payload: { status: "ok" }, + }), + }); + }); + } + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", {}); + } + + private emit(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event as never); + } + } +} + +function fakeWebSocket(): typeof WebSocket { + return FakeWebSocket as unknown as typeof WebSocket; +} + +describe("@opencloud/js", () => { + it("keeps access tokens private while owning REST and Storage headers", async () => { + const runtimeRequests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn( + async (source: URL | RequestInfo, init?: RequestInit) => { + const url = String(source); + if (url.endsWith("/_opencloud/config")) { + return Response.json(runtimeConfig); + } + if (url.endsWith("/_opencloud/session/v2")) { + return Response.json({ + session: wireSession("private-access-token"), + }); + } + runtimeRequests.push({ url, init }); + return Response.json({ ok: true }); + }, + ) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + now: () => initialNow, + }); + + const session = await client.session(); + expect(session).toMatchObject({ + userId: "22222222-2222-4222-8222-222222222222", + profile: { displayName: "Test Person" }, + accessTokenExpiresAt: "2026-01-01T00:31:00.000Z", + }); + expect(session).not.toHaveProperty("accessToken"); + expect(JSON.stringify(session)).not.toContain("private-access-token"); + + await client.rest.request("todos?select=*", { + headers: { authorization: "Bearer attacker-controlled" }, + }); + await client.storage.request( + `object/${runtimeConfig.storageBucket}/person/file.txt`, + { method: "POST", body: "contents" }, + ); + + expect(runtimeRequests).toHaveLength(2); + for (const request of runtimeRequests) { + const headers = new Headers(request.init?.headers); + expect(headers.get("apikey")).toBe("anon-project-key"); + expect(headers.get("authorization")).toBe( + "Bearer private-access-token", + ); + expect(request.init?.credentials).toBe("same-origin"); + } + }); + + it("returns null from a successful public signed-out session envelope", async () => { + const fetchMock = vi.fn(async (source: URL | RequestInfo) => { + const url = String(source); + if (url.endsWith("/_opencloud/session/v2")) { + return Response.json({ session: null }); + } + throw new Error(`Unexpected URL ${url}`); + }) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + }); + + await expect(client.session()).resolves.toBeNull(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + `${origin}/_opencloud/session/v2`, + ); + }); + + it("refreshes through the broker before using an expired cache entry", async () => { + let now = initialNow; + let sessionRequests = 0; + const runtimeAuthorizations: string[] = []; + const fetchMock = vi.fn( + async (source: URL | RequestInfo, init?: RequestInit) => { + const url = String(source); + if (url.endsWith("/_opencloud/config")) { + return Response.json(runtimeConfig); + } + if (url.endsWith("/_opencloud/session/v2")) { + sessionRequests += 1; + return Response.json({ + session: wireSession( + `access-token-${sessionRequests}`, + sessionRequests === 1 + ? "2026-01-01T00:00:10.000Z" + : "2026-01-01T00:30:00.000Z", + ), + }); + } + runtimeAuthorizations.push( + new Headers(init?.headers).get("authorization") ?? "", + ); + return new Response(null, { status: 204 }); + }, + ) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + now: () => now, + }); + + await client.session(); + now += 11_000; + await client.rest.request("todos"); + + expect(sessionRequests).toBe(2); + expect(runtimeAuthorizations).toEqual(["Bearer access-token-2"]); + }); + + it("uses anonymous identity for public functions and user identity for JWT functions", async () => { + const calls: Array<{ url: string; authorization: string | null }> = []; + const fetchMock = vi.fn( + async (source: URL | RequestInfo, init?: RequestInit) => { + const url = String(source); + if (url.endsWith("/_opencloud/config")) { + return Response.json(runtimeConfig); + } + if (url.endsWith("/_opencloud/session/v2")) { + return Response.json({ + session: wireSession("signed-in-user-token"), + }); + } + calls.push({ + url, + authorization: new Headers(init?.headers).get("authorization"), + }); + return Response.json({ ok: true }); + }, + ) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + now: () => initialNow, + }); + + await client.functions.invokePublic("status-probe", { method: "POST" }); + await client.functions.invoke("private-probe", { method: "POST" }); + + expect(calls).toEqual([ + { + url: `${origin}/functions/v1/status-probe`, + authorization: "Bearer anon-project-key", + }, + { + url: `${origin}/functions/v1/private-probe`, + authorization: "Bearer signed-in-user-token", + }, + ]); + }); + + it("reads the host-bound aggregate telemetry summary without runtime credentials", async () => { + const fetchMock = vi.fn( + async (source: URL | RequestInfo, init?: RequestInit) => { + expect(String(source)).toBe( + `${origin}/_opencloud/telemetry/summary`, + ); + expect(new Headers(init?.headers).has("authorization")).toBe(false); + return Response.json({ + appId: runtimeConfig.appId, + asOf: "2026-01-01T00:00:00.000Z", + usage: null, + activity: { + window: { + from: "2025-12-31T00:00:00.000Z", + to: "2026-01-01T00:00:00.000Z", + seconds: 86400, + }, + telemetry: { + status: "available", + latestIngestedAt: "2025-12-31T23:59:59.000Z", + ingestionLagSeconds: 0.2, + sampledEntries: 12, + truncated: false, + }, + surfaces: Object.fromEntries( + [ + "page", + "rest", + "storage", + "realtime", + "function", + "cron", + ].map((surface) => [ + surface, + { + lastActivityAt: null, + requests24h: 0, + errors24h: 0, + lastStatus: null, + }, + ]), + ), + }, + }); + }, + ) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + }); + + await expect(client.telemetry.summary()).resolves.toMatchObject({ + appId: runtimeConfig.appId, + activity: { + telemetry: { + status: "available", + sampledEntries: 12, + }, + surfaces: { + rest: { + requests24h: 0, + errors24h: 0, + }, + }, + }, + }); + }); + + it("emits declared counters and gauges through the same-origin telemetry endpoint", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn( + async (source: URL | RequestInfo, init?: RequestInit) => { + requests.push({ url: String(source), init }); + return Response.json({ + accepted: 1, + duplicates: 0, + recordedAt: "2026-01-01T00:00:00.000Z", + }); + }, + ) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + }); + + await client.telemetry.increment("tasks_created", 1, { + dimensions: { assignee_type: "child" }, + idempotencyKey: "task-created:123", + }); + await client.telemetry.gauge("overdue_tasks", 7); + + expect(requests).toHaveLength(2); + expect(requests[0]?.url).toBe( + `${origin}/_opencloud/telemetry/metrics`, + ); + expect(requests[0]?.init?.method).toBe("POST"); + expect(requests[0]?.init?.credentials).toBe("same-origin"); + expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ + measurements: [ + { + name: "tasks_created", + value: 1, + dimensions: { assignee_type: "child" }, + idempotencyKey: "task-created:123", + }, + ], + }); + expect(JSON.parse(String(requests[1]?.init?.body))).toEqual({ + measurements: [ + { + name: "overdue_tasks", + value: 7, + dimensions: {}, + }, + ], + }); + }); + + it("joins, broadcasts, and reconnects private app-prefixed channels with a fresh session", async () => { + FakeWebSocket.instances = []; + let sessionRequests = 0; + const fetchMock = vi.fn(async (source: URL | RequestInfo) => { + const url = String(source); + if (url.endsWith("/_opencloud/config")) { + return Response.json(runtimeConfig); + } + if (url.endsWith("/_opencloud/session/v2")) { + sessionRequests += 1; + return Response.json({ + session: wireSession(`socket-token-${sessionRequests}`), + }); + } + throw new Error(`Unexpected URL ${url}`); + }) as unknown as typeof fetch; + const client = createOpenCloudClient({ + baseUrl: origin, + fetch: fetchMock, + WebSocket: fakeWebSocket(), + automaticSessionRefresh: false, + now: () => initialNow, + }); + const channel = client.realtime.channel("updates", { + reconnect: { initialDelayMs: 10, maxDelayMs: 10 }, + }); + + await channel.connect(); + await channel.broadcast("changed", { id: 1 }); + const first = FakeWebSocket.instances[0]!; + expect(first.url).toContain( + "wss://tasks.opencloud.ai/realtime/v1/websocket?", + ); + expect(first.sent[0]).toMatchObject({ + topic: `realtime:app:${runtimeConfig.appId}:updates`, + event: "phx_join", + payload: { access_token: "socket-token-1" }, + }); + expect(first.sent[1]).toMatchObject({ + event: "broadcast", + payload: { event: "changed", payload: { id: 1 } }, + }); + + first.close(); + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); + await vi.waitFor(() => + expect(FakeWebSocket.instances[1]?.sent[0]).toMatchObject({ + event: "phx_join", + payload: { access_token: "socket-token-2" }, + }), + ); + expect(channel.state).toBe("joined"); + channel.close(); + }); +}); diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index 2f63548..1f3ec7d 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -206,6 +206,7 @@ async function selectBundleFiles( relativeDirectory, label, ); + assertNotLocalMetadataPath(root, absoluteDirectory, label); await assertNoSymlinkComponents(root, absoluteDirectory, label); await assertDirectory(absoluteDirectory, label); await walkDirectory(root, absoluteDirectory, selection, manifestFile); @@ -222,6 +223,11 @@ async function selectBundleFiles( migration.file, `Migration file ${migration.file}`, ); + assertNotLocalMetadataPath( + root, + absoluteFile, + `Migration file ${migration.file}`, + ); assertNotAuthorManifestInput( root, absoluteFile, @@ -253,6 +259,11 @@ async function selectBundleFiles( definition.entrypoint, `Function entrypoint ${definition.entrypoint}`, ); + assertNotLocalMetadataPath( + root, + entrypoint, + `Function entrypoint ${definition.entrypoint}`, + ); assertNotAuthorManifestInput( root, entrypoint, @@ -286,6 +297,9 @@ async function walkDirectory( const target = path.join(directory, entry.name); const info = await lstat(target); const relative = bundleRelativePath(root, target); + if (relative === ".opencloud" || relative.startsWith(".opencloud/")) { + continue; + } if (info.isSymbolicLink()) { throw new Error(`App bundles cannot contain symlinks: ${relative}`); } @@ -300,6 +314,17 @@ async function walkDirectory( } } +function assertNotLocalMetadataPath( + root: string, + target: string, + label: string, +): void { + const relative = bundleRelativePath(root, target); + if (relative === ".opencloud" || relative.startsWith(".opencloud/")) { + throw new Error(`${label} cannot use the reserved .opencloud metadata directory`); + } +} + function addSelectedFile( root: string, file: string, diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index 1bc03db..a67a748 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -4,8 +4,8 @@ import { appStateSchema, appVisibilitySchema, completeAgentOnboardingRequestSchema, - createAppRequestSchema, createCredentialRequestSchema, + operatorCreateAppRequestSchema, deploymentStateSchema, operationStateSchema, startAgentOnboardingRequestSchema, @@ -166,6 +166,79 @@ const draftValidationOutput = z }) .passthrough(); +export const devSessionOutput = z + .object({ + id: uuid, + appId: uuid, + draftId: uuid, + status: z.enum([ + "active", + "verifying", + "verified", + "stale", + "stopped", + "expired", + ]), + previewUrl: z.url(), + baseDeploymentId: uuid.nullable(), + activeRevision: z + .object({ + id: uuid, + draftRevision: z.number().int().positive(), + artifactSha256: sha256, + migrationDigest: sha256, + }) + .nullable(), + verification: z + .object({ + receiptId: uuid, + revisionId: uuid, + expiresAt: z.string().nullable(), + }) + .nullable(), + capabilities: z.object({ + frontend: z.literal(true), + database: z.literal(true), + functions: z.literal(true), + productionSecrets: z.literal(false), + cron: z.literal(false), + storageSandbox: z.literal(false), + syntheticAuth: z.literal(false), + }), + createdAt: z.string(), + updatedAt: z.string(), + lastActivityAt: z.string(), + expiresAt: z.string(), + }) + .passthrough(); + +const devInvocationOutput = z + .object({ + id: uuid, + requestId: z.string(), + correlationId: z.string(), + functionName: z.string(), + caller: z.string(), + status: z.number().int().nullable(), + durationMs: z.number().int().nonnegative(), + error: jsonObject.nullable(), + createdAt: z.string(), + }) + .passthrough(); + +const devVerificationOutput = z.object({ + session: devSessionOutput, + receipt: z + .object({ + id: uuid, + revisionId: uuid, + artifactSha256: sha256, + expiresAt: z.string(), + summary: jsonObject, + }) + .passthrough(), +}); + const verificationOutput = z .object({ id: uuid, @@ -303,6 +376,7 @@ function operation< const appPath = z.object({ appId: uuid }); const draftPath = appPath.extend({ draftId: uuid }); const deploymentPath = appPath.extend({ deploymentId: uuid }); +const devSessionPath = appPath.extend({ sessionId: uuid }); export const controlPlaneOperations = { startAgentOnboarding: operation({ @@ -349,10 +423,10 @@ export const controlPlaneOperations = { path: "/v1/apps", summary: "Create an app", description: - "Creates an app with a server-generated unique address. The authenticated actor supplies only the title and visibility.", + "Creates an app with a server-generated unique address. Operators also provide the owning user identifier.", auth: "bearer", scopes: ["app:create"], - input: z.object({ body: createAppRequestSchema }), + input: z.object({ body: operatorCreateAppRequestSchema }), output: z.object({ app: controlPlaneAppSchema, operation: controlPlaneOperationSchema, @@ -694,6 +768,227 @@ export const controlPlaneOperations = { openWorldHint: false, }, }), + startDevSession: operation({ + method: "POST", + path: "/v1/apps/{appId}/drafts/{draftId}/dev-sessions", + summary: "Start a development session", + description: + "Creates or resumes an isolated preview for a validated draft and optionally applies its exact revision.", + auth: "bearer", + scopes: ["app:deploy"], + input: draftPath.extend({ + body: z.object({ apply: z.boolean().default(true) }), + }), + output: devSessionOutput, + bodyKey: "body", + idempotency: "none", + mcp: { + toolName: "start_dev_session", + title: "Start dev session", + description: + "Start or resume an isolated frontend and database preview for a validated draft.", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }), + getDevSession: operation({ + method: "GET", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}", + summary: "Get a development session", + description: "Returns preview state, capabilities, and verification status.", + auth: "bearer", + scopes: ["app:read"], + input: devSessionPath, + output: devSessionOutput, + idempotency: "none", + mcp: { + toolName: "get_dev_session", + title: "Get dev session", + description: "Inspect an OpenCloud development session.", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }), + applyDevRevision: operation({ + method: "POST", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/apply", + summary: "Apply a draft revision to development", + description: + "Materializes the exact validated draft and replays migrations into its isolated dev schema when needed.", + auth: "bearer", + scopes: ["app:deploy"], + input: devSessionPath, + output: devSessionOutput, + idempotency: "none", + mcp: { + toolName: "apply_dev_revision", + title: "Apply dev revision", + description: + "Sync the exact validated draft to its stable development preview.", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }), + requestDevApp: operation({ + method: "POST", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/request", + summary: "Request a development preview path", + description: + "Performs a bounded GET or HEAD against the capability preview and returns the response for agent inspection.", + auth: "bearer", + scopes: ["app:observe"], + input: devSessionPath.extend({ + body: z.object({ + path: z.string().min(1).max(2_048).default("/"), + method: z.enum(["GET", "HEAD"]).default("GET"), + }), + }), + output: z.object({ + status: z.number().int(), + contentType: z.string().nullable(), + requestId: z.string().nullable(), + body: z.string().nullable(), + }), + bodyKey: "body", + idempotency: "none", + mcp: { + toolName: "request_dev_app", + title: "Request dev app", + description: "Inspect a page or REST read from the development preview.", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }), + invokeDevFunction: operation({ + method: "POST", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/functions/{functionName}/invoke", + summary: "Explicitly invoke a development Function", + description: + "Boots one isolated dev Function invocation with no production secrets, cron, or implicit browser execution.", + auth: "bearer", + scopes: ["app:deploy"], + input: devSessionPath.extend({ + functionName: z.string().regex(/^[a-z][a-z0-9-]{0,62}$/), + body: z.object({ body: z.unknown().optional() }), + }), + output: z.object({ + status: z.number().int(), + requestId: z.string().nullable(), + body: z.unknown(), + }), + bodyKey: "body", + idempotency: "none", + mcp: { + toolName: "invoke_dev_function", + title: "Invoke dev Function", + description: + "Explicitly test one development Function and capture correlated diagnostics.", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }), + verifyDevSession: operation({ + method: "POST", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/verify", + summary: "Verify a development revision", + description: + "Runs Chromium, console, HTTP, and optional primary-flow checks and issues a receipt bound to the exact revision.", + auth: "bearer", + scopes: ["app:deploy"], + input: devSessionPath, + output: devVerificationOutput, + idempotency: "none", + mcp: { + toolName: "verify_dev_session", + title: "Verify dev session", + description: + "Run the development verification gate for the exact active revision.", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }), + listDevInvocations: operation({ + method: "GET", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/requests", + summary: "List development Function invocations", + description: + "Returns bounded, redacted Function outcomes correlated by request ID.", + auth: "bearer", + scopes: ["app:observe"], + input: devSessionPath.extend({ + query: z.object({ limit: z.number().int().min(1).max(200).default(100) }), + }), + output: z.array(devInvocationOutput), + queryKey: "query", + idempotency: "none", + mcp: { + toolName: "list_dev_invocations", + title: "List dev invocations", + description: "Inspect redacted development Function outcomes.", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }), + promoteDevRevision: operation({ + method: "POST", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}/promote", + summary: "Promote a verified development revision", + description: + "Deploys only the exact draft revision covered by the current, unexpired verification receipt.", + auth: "bearer", + scopes: ["app:deploy"], + input: devSessionPath, + output: z.object({ + draft: draftOutput, + deployment: controlPlaneDeploymentSchema, + operation: controlPlaneOperationSchema, + }), + idempotency: "required", + mcp: { + toolName: "promote_dev_revision", + title: "Promote dev revision", + description: + "Deploy the exact verified development revision to production.", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }), + stopDevSession: operation({ + method: "DELETE", + path: "/v1/apps/{appId}/dev-sessions/{sessionId}", + summary: "Stop a development session", + description: "Removes its preview artifacts, Function links, and dev schema.", + auth: "bearer", + scopes: ["app:deploy"], + input: devSessionPath, + output: devSessionOutput, + idempotency: "none", + mcp: { + toolName: "stop_dev_session", + title: "Stop dev session", + description: "Destroy an app's isolated development session.", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }), verifyApp: operation({ method: "POST", path: "/v1/apps/{appId}/verifications",