From 52c0c3441c6f42ab264aa2c4c77f79144bdb0633 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Sun, 19 Jul 2026 02:09:46 +0800 Subject: [PATCH] fix: host authenticated gateways on protected Spaces --- Dockerfile | 4 +-- README.md | 9 ++--- dist/mlclaw-space-runtime.js | 3 +- dist/mlclaw.mjs | 52 +++++++++++++++++++--------- package-lock.json | 4 +-- package.json | 4 +-- src/mlclaw-space-runtime/app.ts | 1 - src/mlclaw-space-runtime/pages.ts | 2 +- src/mlclaw/cli.ts | 46 +++++++++++++++++-------- src/mlclaw/deployment-state.ts | 4 +-- src/mlclaw/hub-api.ts | 28 ++++++++++++---- src/mlclaw/local-config.ts | 4 +-- src/mlclaw/runtime-image.ts | 2 +- test/mlclaw.cli.test.ts | 51 +++++++++++++++++----------- test/mlclaw.hub-api.test.ts | 56 +++++++++++++++++++++++++------ test/mlclaw.space-runtime.test.ts | 4 +-- 16 files changed, 187 insertions(+), 87 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7da5803..7a3842a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ ARG OPENCLAW_VERSION=2026.7.1 ARG OPENCLAW_BASE_IMAGE=ghcr.io/openclaw/openclaw:${OPENCLAW_VERSION} -ARG BROKERKIT_PLUGIN_VERSION=0.3.2 +ARG BROKERKIT_PLUGIN_VERSION=0.3.3 ARG BROKERKIT_VERSION=hf-broker/v0.4.0 -ARG MLCLAW_RUNTIME_IMAGE=ghcr.io/huggingface/mlclaw:0.4.6-openclaw-2026.7.1 +ARG MLCLAW_RUNTIME_IMAGE=ghcr.io/huggingface/mlclaw:0.4.7-openclaw-2026.7.1 FROM golang:1.26.5-bookworm AS hf-broker-build ARG BROKERKIT_VERSION diff --git a/README.md b/README.md index f004837..dd65cec 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ # ML Claw ML Claw deploys an OpenClaw agent on Hugging Face with durable state in a -private Storage Bucket. The default deployment is a private Hugging Face Space -with a browser gateway protected by Hugging Face OAuth. +private Storage Bucket. The default deployment is a protected Hugging Face +Space: its source stays private, its app URL is reachable, and the browser +gateway is protected by ML Claw's Hugging Face OAuth session. [ml-intern](https://github.com/huggingface/ml-intern) is Hugging Face's open-source ML engineer for reading papers, training models, and shipping @@ -77,7 +78,7 @@ npx mlclaw@latest bootstrap --name mlclaw This creates: -- a private Docker Space for the browser gateway when the account can host it; +- a protected Docker Space for the browser gateway when the account can host it; - a private Storage Bucket for OpenClaw state after the Space is accepted; - no explicit Space hardware request unless you pass `--hardware`; - a Docker Space that starts from the prebuilt `ghcr.io/huggingface/mlclaw` image; @@ -106,7 +107,7 @@ Open the Space, sign in with your Hugging Face account, and use the OpenClaw browser gateway directly. The gateway includes a small ML Claw control link for settings, status, credentials, and sign out. -Create a public Space only when you explicitly want a public demo or template: +Expose the Space source only when you explicitly want a public demo or template: ```bash npx mlclaw@latest bootstrap --name mlclaw --public-space diff --git a/dist/mlclaw-space-runtime.js b/dist/mlclaw-space-runtime.js index 848658a..ecd408b 100755 --- a/dist/mlclaw-space-runtime.js +++ b/dist/mlclaw-space-runtime.js @@ -8937,7 +8937,7 @@ function templatePage(config2) {

ML Claw

Run the local bootstrapper to create a Hugging Face hosted OpenClaw agent for ML workflows.

-

Do not set this up by only clicking Duplicate. The bootstrapper creates the private Space, private Storage Bucket, OAuth settings, secrets, model configuration, and local manifest.

+

Do not set this up by only clicking Duplicate. The bootstrapper creates the protected Space, private Storage Bucket, OAuth settings, secrets, model configuration, and local manifest.

With Node.js

npx mlclaw@latest bootstrap --name mlclaw

macOS or Linux without Node.js

@@ -10348,7 +10348,6 @@ function safeErrorClass(error) { } function delegatedHeaders(c) { c.header("access-control-allow-origin", "null"); - c.header("access-control-allow-credentials", "true"); c.header("cache-control", "no-store"); c.header("vary", "origin"); c.header("x-content-type-options", "nosniff"); diff --git a/dist/mlclaw.mjs b/dist/mlclaw.mjs index b5b2080..da36733 100755 --- a/dist/mlclaw.mjs +++ b/dist/mlclaw.mjs @@ -14968,7 +14968,7 @@ var HubApi = class { organization: owner === me2.name ? null : owner, type: "space", sdk: "docker", - private: options?.private !== false + visibility: options?.visibility ?? "protected" }; if (options?.hardware) { payload.hardware = options.hardware; @@ -15001,8 +15001,19 @@ var HubApi = class { } } async getSpaceVisibility(repoId) { - const info = await this.requestJson(`/api/spaces/${repoId}`); - return info.private === true ? "private" : "public"; + const [owner] = splitRepoId(repoId); + const me2 = await this.whoami(); + let url = owner === me2.name ? `${this.hubUrl}/api/settings/repositories` : `${this.hubUrl}/api/organizations/${encodeURIComponent(owner)}/settings/repositories`; + while (url) { + const response = await this.request(url); + const repositories = await response.json(); + const visibility = repositories.find((repo) => repo.id === repoId && repo.type === "space")?.visibility; + if (visibility === "private" || visibility === "protected" || visibility === "public") { + return visibility; + } + url = nextLink(response.headers.get("link")); + } + throw new Error(`Hub repository settings omitted Space visibility for ${repoId}`); } async updateSpaceVisibility(repoId, visibility) { await this.requestJson(`/api/spaces/${repoId}/settings`, { @@ -15375,7 +15386,7 @@ import fs12 from "node:fs"; import path13 from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; var DEFAULT_OPENCLAW_VERSION = "2026.7.1"; -var DEFAULT_BROKERKIT_PLUGIN_VERSION = "0.3.2"; +var DEFAULT_BROKERKIT_PLUGIN_VERSION = "0.3.3"; var DEFAULT_BROKERKIT_VERSION = "hf-broker/v0.4.0"; var DEFAULT_RUNTIME_IMAGE_REPOSITORY = "ghcr.io/huggingface/mlclaw"; var PACKAGE_METADATA = readPackageMetadata(); @@ -19848,7 +19859,7 @@ var manifestFields = { }).strict().optional(), credentialKeySha256: external_exports.string().regex(/^[a-f0-9]{64}$/).optional(), tailscaleMode: external_exports.enum(["off", "direct", "serve"]).optional(), - spaceVisibility: external_exports.enum(["private", "public"]).optional(), + spaceVisibility: external_exports.enum(["private", "protected", "public"]).optional(), spaceHardware: external_exports.string().min(1).max(128).optional(), spaceSleepTime: external_exports.number().int().min(-1).optional(), recoveredWithoutCredentialKey: external_exports.boolean().optional(), @@ -20013,7 +20024,7 @@ var desiredStateSchema = external_exports.object({ runtimeImage: external_exports.string().min(1).max(1024), space: external_exports.object({ repo: external_exports.string().min(3).max(256), - visibility: external_exports.enum(["private", "public"]), + visibility: external_exports.enum(["private", "protected", "public"]), hardware: external_exports.string().min(1).max(128).optional(), sleepTime: external_exports.number().int().min(-1).optional() }).strict() @@ -20067,7 +20078,7 @@ function deploymentIdentity(manifest, statePrefix = "openclaw-state") { createdAt: manifest.createdAt }); } -function deploymentDesiredState(manifest, visibility = manifest.spaceVisibility ?? "private") { +function deploymentDesiredState(manifest, visibility = manifest.spaceVisibility ?? "protected") { return desiredStateSchema.parse({ schemaVersion: 1, deploymentId: manifest.deploymentId, @@ -20751,7 +20762,7 @@ function createProgram(runtimeOverrides = {}) { program2.name("mlclaw").description("Deploy OpenClaw to a Hugging Face Space and private bucket").showHelpAfterError().exitOverride((err) => { throw err; }); - program2.command("bootstrap", { isDefault: true }).alias("configure").description("Create or update a Hugging Face OpenClaw deployment").option("--owner ", "Hugging Face user or organization").option("--name ", "Agent and runtime resource base name").option("--bucket ", "State bucket to create or adopt").option("--gateway ", "Where the live gateway runs").option("--telegram-token ", "Optional Telegram bot token").option("--telegram-token-file ", "File containing TELEGRAM_BOT_TOKEN=... or a raw token").option("--telegram-user-id ", "Allowed Telegram user ID").option("--telegram-api-root ", "Telegram API root override").option("--telegram-proxy ", "Telegram proxy URL override").option("--hardware ", "Hugging Face Space hardware flavor").option("--sleep-time ", "Space sleep timeout in seconds; -1 means never sleep", parseInteger).option("--model ", "OpenClaw model identifier").option("--runtime-image ", "ML Claw runtime image").option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false).option("--public-space", "Create the Hugging Face Space as public instead of private", false).addOption(new Option("--gateway-token ").hideHelp()).option("--router-token ", "Hugging Face Router inference token for Space gateway model calls").option( + program2.command("bootstrap", { isDefault: true }).alias("configure").description("Create or update a Hugging Face OpenClaw deployment").option("--owner ", "Hugging Face user or organization").option("--name ", "Agent and runtime resource base name").option("--bucket ", "State bucket to create or adopt").option("--gateway ", "Where the live gateway runs").option("--telegram-token ", "Optional Telegram bot token").option("--telegram-token-file ", "File containing TELEGRAM_BOT_TOKEN=... or a raw token").option("--telegram-user-id ", "Allowed Telegram user ID").option("--telegram-api-root ", "Telegram API root override").option("--telegram-proxy ", "Telegram proxy URL override").option("--hardware ", "Hugging Face Space hardware flavor").option("--sleep-time ", "Space sleep timeout in seconds; -1 means never sleep", parseInteger).option("--model ", "OpenClaw model identifier").option("--runtime-image ", "ML Claw runtime image").option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false).option("--public-space", "Expose the Space source as well as the authenticated app", false).addOption(new Option("--gateway-token ").hideHelp()).option("--router-token ", "Hugging Face Router inference token for Space gateway model calls").option( "--router-token-file ", "File containing MLCLAW_ROUTER_TOKEN=..., HF_ROUTER_TOKEN=..., or a raw token" ).option("--broker-hf-token-file ", "File containing MLCLAW_BROKER_HF_TOKEN=... or a raw Hugging Face token").option("--docker-context ", "Docker context for local gateway mode").option("--container-runtime ", "Local container runtime", "auto").option("--local-port ", "Loopback port for a local gateway", parseLocalPort).option("--tailscale ", "Tailnet access mode", parseTailscaleMode).option("--tailscale-port ", "Tailnet listener or Serve HTTPS port", parseLocalPort).option( @@ -20802,7 +20813,7 @@ function createProgram(runtimeOverrides = {}) { gateway.command("logs").argument("", "Agent name").option("--tail ", "Number of log lines", parseInteger, 200).action(async (agent, opts) => { await gatewayLogs(agent, opts, runtime); }); - gateway.command("migrate").argument("", "Agent name").requiredOption("--to ", "Target gateway location").option("--hardware ", "Hugging Face Space hardware flavor").option("--sleep-time ", "Space sleep timeout in seconds; -1 means never sleep", parseInteger).option("--runtime-image ", "ML Claw runtime image").option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false).option("--public-space", "Create the Hugging Face Space as public instead of private", false).option("--router-token ", "Hugging Face Router inference token for Space gateway model calls").option( + gateway.command("migrate").argument("", "Agent name").requiredOption("--to ", "Target gateway location").option("--hardware ", "Hugging Face Space hardware flavor").option("--sleep-time ", "Space sleep timeout in seconds; -1 means never sleep", parseInteger).option("--runtime-image ", "ML Claw runtime image").option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false).option("--public-space", "Expose the Space source as well as the authenticated app", false).option("--router-token ", "Hugging Face Router inference token for Space gateway model calls").option( "--router-token-file ", "File containing MLCLAW_ROUTER_TOKEN=..., HF_ROUTER_TOKEN=..., or a raw token" ).option("--docker-context ", "Docker context for local gateway startup when migrating to local").option("--container-runtime ", "Local container runtime", "auto").option("--local-port ", "Loopback port for the local gateway", parseLocalPort).option("--tailscale ", "Tailnet access mode", parseTailscaleMode).option("--tailscale-port ", "Tailnet listener or Serve HTTPS port", parseLocalPort).option("--no-pull", "Do not docker pull before starting a local gateway").option("--takeover", "Start even if another live runtime lease is present", false).option("--yes", "Confirm paid hardware prompts for automation", false).action(async (agent, opts) => { @@ -21353,7 +21364,7 @@ async function reconcileManifest(params) { if (currentDesired && currentDesired.deploymentId !== requestedManifest.deploymentId) { throw new Error(`bucket ${requestedManifest.bucket} desired state belongs to another deployment`); } - const visibility = params.visibility ?? requestedManifest.spaceVisibility ?? "private"; + const visibility = params.visibility ?? requestedManifest.spaceVisibility ?? "protected"; const candidate = deploymentDesiredState(requestedManifest, visibility); const sameDesired = currentDesired && JSON.stringify({ ...currentDesired, generation: 0, updatedAt: "" }) === JSON.stringify({ ...candidate, generation: 0, updatedAt: "" }); if (currentDesired && currentDesired.generation > requestedManifest.desiredGeneration && !sameDesired) { @@ -21543,7 +21554,7 @@ async function resolveBootstrapPlan(params) { spacePlan = { space: names.space, exists, - visibility: opts.publicSpace ? "public" : existingManifest?.spaceVisibility ?? currentVisibility ?? "private", + visibility: opts.publicSpace ? "public" : "protected", ...currentVisibility ? { currentVisibility } : {} }; } @@ -21824,7 +21835,7 @@ async function createOrAdoptSpace(params) { } params.runtime.stdout.log(`Creating ${params.spacePlan.visibility} Space ${params.spacePlan.space}`); await params.hub.createDockerSpace(params.spacePlan.space, { - private: params.spacePlan.visibility === "private", + visibility: params.spacePlan.visibility, ...params.hardware ? { hardware: params.hardware } : {}, ...typeof params.sleepTime === "number" ? { sleepTimeSeconds: params.sleepTime } : {} }); @@ -22301,11 +22312,11 @@ async function deploySpaceGateway(params) { const assertLease = params.assertLease ?? (async () => void 0); if (!params.spacePrepared) { runtime.stdout.log( - params.spaceExists ? `Updating existing Space ${manifest.space}` : `Creating ${params.publicSpace ? "public" : "private"} Space ${manifest.space}` + params.spaceExists ? `Updating existing Space ${manifest.space}` : `Creating ${params.visibility ?? "protected"} Space ${manifest.space}` ); await assertLease(); await hub.createDockerSpace(manifest.space, { - private: !params.publicSpace, + visibility: params.visibility ?? "protected", ...params.hardware && !params.spaceExists ? { hardware: params.hardware } : {}, ...typeof params.sleepTime === "number" ? { sleepTimeSeconds: params.sleepTime } : {} }); @@ -22739,7 +22750,7 @@ async function gatewayMigrate(agent, opts, runtime) { }); updated = { ...updated, - spaceVisibility: opts.publicSpace ? "public" : current.spaceVisibility ?? "private", + spaceVisibility: opts.publicSpace ? "public" : "protected", ...paidHardware.kind === "explicit" ? { spaceHardware: paidHardware.hardware } : {}, ...typeof paidHardware.sleepTime === "number" ? { spaceSleepTime: paidHardware.sleepTime } : {} }; @@ -22770,7 +22781,7 @@ async function gatewayMigrate(agent, opts, runtime) { manifest: updated, secrets: deploymentSecrets2, allowedUsers: me2.name, - publicSpace: updated.spaceVisibility === "public", + visibility: updated.spaceVisibility === "public" ? "public" : "protected", spaceExists, assertLease, ...paidHardware.kind === "explicit" ? { hardware: paidHardware.hardware } : {}, @@ -23609,6 +23620,15 @@ async function doctor(repoId, opts, hub, runtime) { } return; } + const visibility = await hub.getSpaceVisibility(repoId); + if (visibility !== "protected") { + if (fix) { + await hub.updateSpaceVisibility(repoId, "protected"); + fixed.push("set protected Space visibility"); + } else { + issues.push(`Space visibility is ${visibility}; hosted gateways require protected visibility`); + } + } const bucket = variables.get("OPENCLAW_HF_STATE_BUCKET")?.value ?? opts.bucket; let signedInUser; const currentUsername = async () => { diff --git a/package-lock.json b/package-lock.json index 8fad671..eb04b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mlclaw", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mlclaw", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "dependencies": { "@clack/prompts": "^1.4.0", diff --git a/package.json b/package.json index dff6703..6413290 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "mlclaw", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "config": { "openclawVersion": "2026.7.1", "brokerkitVersion": "hf-broker/v0.4.0", - "brokerkitPluginVersion": "0.3.2", + "brokerkitPluginVersion": "0.3.3", "runtimeImageRepository": "ghcr.io/huggingface/mlclaw" }, "repository": { diff --git a/src/mlclaw-space-runtime/app.ts b/src/mlclaw-space-runtime/app.ts index 590c3e7..0f940e6 100644 --- a/src/mlclaw-space-runtime/app.ts +++ b/src/mlclaw-space-runtime/app.ts @@ -798,7 +798,6 @@ function safeErrorClass(error: unknown): string { function delegatedHeaders(c: Context): void { c.header("access-control-allow-origin", "null"); - c.header("access-control-allow-credentials", "true"); c.header("cache-control", "no-store"); c.header("vary", "origin"); c.header("x-content-type-options", "nosniff"); diff --git a/src/mlclaw-space-runtime/pages.ts b/src/mlclaw-space-runtime/pages.ts index 8bb2099..6167ba3 100644 --- a/src/mlclaw-space-runtime/pages.ts +++ b/src/mlclaw-space-runtime/pages.ts @@ -8,7 +8,7 @@ export function templatePage(config: SpaceRuntimeConfig): string {

ML Claw

Run the local bootstrapper to create a Hugging Face hosted OpenClaw agent for ML workflows.

-

Do not set this up by only clicking Duplicate. The bootstrapper creates the private Space, private Storage Bucket, OAuth settings, secrets, model configuration, and local manifest.

+

Do not set this up by only clicking Duplicate. The bootstrapper creates the protected Space, private Storage Bucket, OAuth settings, secrets, model configuration, and local manifest.

With Node.js

npx mlclaw@latest bootstrap --name mlclaw

macOS or Linux without Node.js

diff --git a/src/mlclaw/cli.ts b/src/mlclaw/cli.ts index 6f6ee86..f46d24c 100644 --- a/src/mlclaw/cli.ts +++ b/src/mlclaw/cli.ts @@ -22,7 +22,14 @@ import { } from "./docker.js"; import { parseGatewayLocation, type GatewayLocation } from "./gateway-location.js"; import { pushTemplateToSpace } from "./git.js"; -import { HubApi, HubApiError, type HubIdentity, type SpaceRuntime, type SpaceVolume } from "./hub-api.js"; +import { + HubApi, + HubApiError, + type HubIdentity, + type SpaceRuntime, + type SpaceVisibility, + type SpaceVolume, +} from "./hub-api.js"; import { assertNoLiveForeignLease, clearRuntimeHandoffRequest, @@ -111,6 +118,7 @@ const REMOTE_DISCOVERY_TIMEOUT_MS = 20_000; type ContainerRuntimePreference = "auto" | ContainerEngine; type HostedFallbackChoice = "local" | "pro" | "cancel"; type TailscaleMode = "off" | "direct" | "serve"; +type HostedSpaceVisibility = Exclude; const STALE_PATH_VARS = ["OPENCLAW_STATE_DIR", "OPENCLAW_WORKSPACE_DIR", "OPENCLAW_CONFIG_PATH"]; const SNAPSHOT_MANIFEST_REMOTE_NAME = "manifest.json"; @@ -220,8 +228,8 @@ type BootstrapBucketPlan = { type BootstrapSpacePlan = { space: string; exists: boolean; - visibility: "private" | "public"; - currentVisibility?: "private" | "public"; + visibility: HostedSpaceVisibility; + currentVisibility?: SpaceVisibility; }; type SpaceHardwareRequest = @@ -347,7 +355,7 @@ export function createProgram(runtimeOverrides: CliRuntime = {}): Command { .option("--model ", "OpenClaw model identifier") .option("--runtime-image ", "ML Claw runtime image") .option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false) - .option("--public-space", "Create the Hugging Face Space as public instead of private", false) + .option("--public-space", "Expose the Space source as well as the authenticated app", false) .addOption(new Option("--gateway-token ").hideHelp()) .option("--router-token ", "Hugging Face Router inference token for Space gateway model calls") .option( @@ -492,7 +500,7 @@ export function createProgram(runtimeOverrides: CliRuntime = {}): Command { .option("--sleep-time ", "Space sleep timeout in seconds; -1 means never sleep", parseInteger) .option("--runtime-image ", "ML Claw runtime image") .option("--bundled-runtime", "Generate a bundled Space runtime instead of using the prebuilt ML Claw image", false) - .option("--public-space", "Create the Hugging Face Space as public instead of private", false) + .option("--public-space", "Expose the Space source as well as the authenticated app", false) .option("--router-token ", "Hugging Face Router inference token for Space gateway model calls") .option( "--router-token-file ", @@ -1110,7 +1118,7 @@ type ReconcileManifestFinalizer = (context: { async function reconcileManifest(params: { manifest: DeploymentManifest; bucketPrefix?: string | undefined; - visibility?: "private" | "public" | undefined; + visibility?: SpaceVisibility | undefined; credentialKey?: string | undefined; initialIdentityClaimed?: boolean | undefined; previousIdentityBucket?: string | undefined; @@ -1180,7 +1188,7 @@ async function reconcileManifest(params: { if (currentDesired && currentDesired.deploymentId !== requestedManifest.deploymentId) { throw new Error(`bucket ${requestedManifest.bucket} desired state belongs to another deployment`); } - const visibility = params.visibility ?? requestedManifest.spaceVisibility ?? "private"; + const visibility = params.visibility ?? requestedManifest.spaceVisibility ?? "protected"; const candidate = deploymentDesiredState(requestedManifest, visibility); const sameDesired = currentDesired && @@ -1408,7 +1416,7 @@ async function resolveBootstrapPlan(params: { spacePlan = { space: names.space, exists, - visibility: opts.publicSpace ? "public" : (existingManifest?.spaceVisibility ?? currentVisibility ?? "private"), + visibility: opts.publicSpace ? "public" : "protected", ...(currentVisibility ? { currentVisibility } : {}), }; } @@ -1758,7 +1766,7 @@ async function createOrAdoptSpace(params: { } params.runtime.stdout.log(`Creating ${params.spacePlan.visibility} Space ${params.spacePlan.space}`); await params.hub.createDockerSpace(params.spacePlan.space, { - private: params.spacePlan.visibility === "private", + visibility: params.spacePlan.visibility, ...(params.hardware ? { hardware: params.hardware } : {}), ...(typeof params.sleepTime === "number" ? { sleepTimeSeconds: params.sleepTime } : {}), }); @@ -2362,7 +2370,7 @@ async function deploySpaceGateway(params: { hardware?: string; sleepTime?: number; templateRuntimeImage?: string; - publicSpace?: boolean; + visibility?: HostedSpaceVisibility; spaceExists?: boolean; spacePrepared?: boolean; assertLease?: () => Promise; @@ -2373,11 +2381,11 @@ async function deploySpaceGateway(params: { runtime.stdout.log( params.spaceExists ? `Updating existing Space ${manifest.space}` - : `Creating ${params.publicSpace ? "public" : "private"} Space ${manifest.space}`, + : `Creating ${params.visibility ?? "protected"} Space ${manifest.space}`, ); await assertLease(); await hub.createDockerSpace(manifest.space, { - private: !params.publicSpace, + visibility: params.visibility ?? "protected", ...(params.hardware && !params.spaceExists ? { hardware: params.hardware } : {}), ...(typeof params.sleepTime === "number" ? { sleepTimeSeconds: params.sleepTime } : {}), }); @@ -2853,7 +2861,7 @@ async function gatewayMigrate( }); updated = { ...updated, - spaceVisibility: opts.publicSpace ? "public" : (current.spaceVisibility ?? "private"), + spaceVisibility: opts.publicSpace ? "public" : "protected", ...(paidHardware.kind === "explicit" ? { spaceHardware: paidHardware.hardware } : {}), ...(typeof paidHardware.sleepTime === "number" ? { spaceSleepTime: paidHardware.sleepTime } : {}), }; @@ -2884,7 +2892,7 @@ async function gatewayMigrate( manifest: updated, secrets: deploymentSecrets, allowedUsers: me.name, - publicSpace: updated.spaceVisibility === "public", + visibility: updated.spaceVisibility === "public" ? "public" : "protected", spaceExists, assertLease, ...(paidHardware.kind === "explicit" ? { hardware: paidHardware.hardware } : {}), @@ -3914,6 +3922,16 @@ async function doctor(repoId: string, opts: DoctorOptions, hub: HubApi, runtime: return; } + const visibility = await hub.getSpaceVisibility(repoId); + if (visibility !== "protected") { + if (fix) { + await hub.updateSpaceVisibility(repoId, "protected"); + fixed.push("set protected Space visibility"); + } else { + issues.push(`Space visibility is ${visibility}; hosted gateways require protected visibility`); + } + } + const bucket = variables.get("OPENCLAW_HF_STATE_BUCKET")?.value ?? opts.bucket; let signedInUser: string | undefined; const currentUsername = async () => { diff --git a/src/mlclaw/deployment-state.ts b/src/mlclaw/deployment-state.ts index c07f04f..d4d923a 100644 --- a/src/mlclaw/deployment-state.ts +++ b/src/mlclaw/deployment-state.ts @@ -45,7 +45,7 @@ const desiredStateSchema = z space: z .object({ repo: z.string().min(3).max(256), - visibility: z.enum(["private", "public"]), + visibility: z.enum(["private", "protected", "public"]), hardware: z.string().min(1).max(128).optional(), sleepTime: z.number().int().min(-1).optional(), }) @@ -126,7 +126,7 @@ export function deploymentIdentity(manifest: DeploymentManifest, statePrefix = " export function deploymentDesiredState( manifest: DeploymentManifest, - visibility: "private" | "public" = manifest.spaceVisibility ?? "private", + visibility: "private" | "protected" | "public" = manifest.spaceVisibility ?? "protected", ): DeploymentDesiredState { return desiredStateSchema.parse({ schemaVersion: 1, diff --git a/src/mlclaw/hub-api.ts b/src/mlclaw/hub-api.ts index 97a445f..45c60f4 100644 --- a/src/mlclaw/hub-api.ts +++ b/src/mlclaw/hub-api.ts @@ -34,6 +34,8 @@ type SpaceInfo = { private?: boolean; runtime?: SpaceRuntime | null; }; +export type SpaceVisibility = "private" | "protected" | "public"; +type RepositorySettingsEntry = { id?: string; type?: string; visibility?: SpaceVisibility }; export type HubCommitFile = { path: string; content: Uint8Array | Buffer }; type ModelInfo = { sha?: string }; @@ -153,7 +155,7 @@ export class HubApi { async createDockerSpace( repoId: string, - options?: { private?: boolean; hardware?: string; sleepTimeSeconds?: number }, + options?: { visibility?: "protected" | "public"; hardware?: string; sleepTimeSeconds?: number }, ): Promise { const [owner, name] = splitRepoId(repoId); const me = await this.whoami(); @@ -162,7 +164,7 @@ export class HubApi { organization: owner === me.name ? null : owner, type: "space", sdk: "docker", - private: options?.private !== false, + visibility: options?.visibility ?? "protected", }; if (options?.hardware) { payload.hardware = options.hardware; @@ -196,12 +198,26 @@ export class HubApi { } } - async getSpaceVisibility(repoId: string): Promise<"private" | "public"> { - const info = await this.requestJson(`/api/spaces/${repoId}`); - return info.private === true ? "private" : "public"; + async getSpaceVisibility(repoId: string): Promise { + const [owner] = splitRepoId(repoId); + const me = await this.whoami(); + let url: string | null = + owner === me.name + ? `${this.hubUrl}/api/settings/repositories` + : `${this.hubUrl}/api/organizations/${encodeURIComponent(owner)}/settings/repositories`; + while (url) { + const response = await this.request(url); + const repositories = (await response.json()) as RepositorySettingsEntry[]; + const visibility = repositories.find((repo) => repo.id === repoId && repo.type === "space")?.visibility; + if (visibility === "private" || visibility === "protected" || visibility === "public") { + return visibility; + } + url = nextLink(response.headers.get("link")); + } + throw new Error(`Hub repository settings omitted Space visibility for ${repoId}`); } - async updateSpaceVisibility(repoId: string, visibility: "private" | "public"): Promise { + async updateSpaceVisibility(repoId: string, visibility: "protected" | "public"): Promise { await this.requestJson(`/api/spaces/${repoId}/settings`, { method: "PUT", body: JSON.stringify({ visibility }), diff --git a/src/mlclaw/local-config.ts b/src/mlclaw/local-config.ts index ee0d9d3..d573759 100644 --- a/src/mlclaw/local-config.ts +++ b/src/mlclaw/local-config.ts @@ -22,7 +22,7 @@ export type DeploymentManifest = { brokerCredential?: BrokerCredentialMetadata; credentialKeySha256?: string; tailscaleMode?: "off" | "direct" | "serve"; - spaceVisibility?: "private" | "public"; + spaceVisibility?: "private" | "protected" | "public"; spaceHardware?: string; spaceSleepTime?: number; recoveredWithoutCredentialKey?: boolean; @@ -137,7 +137,7 @@ const manifestFields = { .regex(/^[a-f0-9]{64}$/) .optional(), tailscaleMode: z.enum(["off", "direct", "serve"]).optional(), - spaceVisibility: z.enum(["private", "public"]).optional(), + spaceVisibility: z.enum(["private", "protected", "public"]).optional(), spaceHardware: z.string().min(1).max(128).optional(), spaceSleepTime: z.number().int().min(-1).optional(), recoveredWithoutCredentialKey: z.boolean().optional(), diff --git a/src/mlclaw/runtime-image.ts b/src/mlclaw/runtime-image.ts index ea133db..ed7f95d 100644 --- a/src/mlclaw/runtime-image.ts +++ b/src/mlclaw/runtime-image.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_OPENCLAW_VERSION = "2026.7.1"; -const DEFAULT_BROKERKIT_PLUGIN_VERSION = "0.3.2"; +const DEFAULT_BROKERKIT_PLUGIN_VERSION = "0.3.3"; export const DEFAULT_BROKERKIT_VERSION = "hf-broker/v0.4.0"; const DEFAULT_RUNTIME_IMAGE_REPOSITORY = "ghcr.io/huggingface/mlclaw"; diff --git a/test/mlclaw.cli.test.ts b/test/mlclaw.cli.test.ts index 99981c6..60c256a 100644 --- a/test/mlclaw.cli.test.ts +++ b/test/mlclaw.cli.test.ts @@ -66,7 +66,7 @@ function createFakeHub( spaceRuntime?: SpaceRuntime; existingBuckets?: string[]; existingSpaces?: string[]; - spaceVisibilities?: Record; + spaceVisibilities?: Record; createDockerSpaceError?: Error; restartSpaceErrors?: Error[]; failFirstTombstoneUpload?: boolean; @@ -85,7 +85,7 @@ function createFakeHub( const existingBuckets = new Set(opts.existingBuckets ?? []); const existingSpaces = new Set(opts.existingSpaces ?? []); const spaceVisibilities = new Map( - [...existingSpaces].map((repoId) => [repoId, opts.spaceVisibilities?.[repoId] ?? "private"] as const), + [...existingSpaces].map((repoId) => [repoId, opts.spaceVisibilities?.[repoId] ?? "protected"] as const), ); const bucketClient = { async uploadFiles(files: Array<{ path: string; content: Blob }>) { @@ -170,8 +170,8 @@ function createFakeHub( } const repoId = String(args[0]); existingSpaces.add(repoId); - const options = args[1] as { private?: boolean } | undefined; - spaceVisibilities.set(repoId, options?.private === false ? "public" : "private"); + const options = args[1] as { visibility?: "protected" | "public" } | undefined; + spaceVisibilities.set(repoId, options?.visibility ?? "protected"); }, async bucketExists(bucket: string) { calls.push({ name: "bucketExists", args: [bucket] }); @@ -195,9 +195,9 @@ function createFakeHub( }, async getSpaceVisibility(repoId: string) { calls.push({ name: "getSpaceVisibility", args: [repoId] }); - return spaceVisibilities.get(repoId) ?? "private"; + return spaceVisibilities.get(repoId) ?? "protected"; }, - async updateSpaceVisibility(repoId: string, visibility: "private" | "public") { + async updateSpaceVisibility(repoId: string, visibility: "protected" | "public") { calls.push({ name: "updateSpaceVisibility", args: [repoId, visibility] }); spaceVisibilities.set(repoId, visibility); }, @@ -1793,7 +1793,7 @@ describe("mlclaw CLI", () => { }), expect.objectContaining({ title: "Bootstrap plan", - message: expect.stringContaining("Space: alice/research (will be created as private)"), + message: expect.stringContaining("Space: alice/research (will be created as protected)"), }), { title: "HERE IS YOUR ML CLAW", @@ -1807,7 +1807,7 @@ describe("mlclaw CLI", () => { args: [ "alice/research", { - private: true, + visibility: "protected", hardware: "cpu-upgrade", sleepTimeSeconds: -1, }, @@ -1910,7 +1910,7 @@ describe("mlclaw CLI", () => { }); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["alice/research", { private: true }], + args: ["alice/research", { visibility: "protected" }], }); expect(hub.calls.findIndex((call) => call.name === "createDockerSpace")).toBeLessThan( hub.calls.findIndex((call) => call.name === "createBucket"), @@ -2656,7 +2656,7 @@ describe("mlclaw CLI", () => { expect(hub.calls).toContainEqual({ name: "createBucket", args: ["alice/mlclaw-fresh-data", true] }); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["alice/mlclaw-fresh", { private: true }], + args: ["alice/mlclaw-fresh", { visibility: "protected" }], }); expect(hub.calls.some((call) => call.name === "requestSpaceHardware")).toBe(false); await expect(readManifest(runtime.configRoot, "mlclaw-fresh")).resolves.toMatchObject({ @@ -2681,7 +2681,7 @@ describe("mlclaw CLI", () => { expect(code).toBe(0); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["alice/research", { private: false }], + args: ["alice/research", { visibility: "public" }], }); expect(hub.calls.some((call) => call.name === "requestSpaceHardware")).toBe(false); }); @@ -2704,7 +2704,7 @@ describe("mlclaw CLI", () => { }); }); - it("preserves the actual visibility of a legacy Space manifest", async () => { + it("converges an existing Space to protected visibility", async () => { const hub = createFakeHub({ existingSpaces: ["alice/research"], spaceVisibilities: { "alice/research": "public" }, @@ -2726,9 +2726,12 @@ describe("mlclaw CLI", () => { await expect(main(["bootstrap", "--name", "research", "--yes"], runtime)).resolves.toBe(0); - expect(hub.calls.some((call) => call.name === "updateSpaceVisibility")).toBe(false); + expect(hub.calls).toContainEqual({ + name: "updateSpaceVisibility", + args: ["alice/research", "protected"], + }); expect(JSON.parse(hub.bucketObjects.get(".mlclaw/desired-state.json") ?? "null")).toMatchObject({ - space: { visibility: "public" }, + space: { visibility: "protected" }, }); }); @@ -2755,12 +2758,12 @@ describe("mlclaw CLI", () => { await expect(main(["bootstrap", "--yes"], runtime)).resolves.toBe(0); await expect(readManifest(runtime.configRoot, "research")).resolves.toMatchObject({ - spaceVisibility: "public", + spaceVisibility: "protected", spaceHardware: "cpu-upgrade", spaceSleepTime: 42, }); expect(JSON.parse(hub.bucketObjects.get(".mlclaw/desired-state.json") ?? "null")).toMatchObject({ - space: { visibility: "public", hardware: "cpu-upgrade", sleepTime: 42 }, + space: { visibility: "protected", hardware: "cpu-upgrade", sleepTime: 42 }, }); }); @@ -2801,7 +2804,7 @@ describe("mlclaw CLI", () => { expect(pushes).toBe(1); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["alice/research", { private: true }], + args: ["alice/research", { visibility: "protected" }], }); }); @@ -2934,7 +2937,7 @@ describe("mlclaw CLI", () => { expect(code).toBe(0); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["research-org/research", { private: true }], + args: ["research-org/research", { visibility: "protected" }], }); expect(hub.calls.some((call) => call.name === "requestSpaceHardware")).toBe(false); expect(hub.calls).toContainEqual({ @@ -3345,7 +3348,10 @@ describe("mlclaw CLI", () => { }); it("repairs app Space mounted state and removes stale broad Hub token secrets", async () => { - const hub = createFakeHub(); + const hub = createFakeHub({ + existingSpaces: ["alice/research"], + spaceVisibilities: { "alice/research": "private" }, + }); await hub.addSpaceVariable("alice/research", "OPENCLAW_HF_STATE_BUCKET", "alice/research-data"); await hub.addSpaceVariable("alice/research", "MLCLAW_TEMPLATE_REV", "test-template"); await hub.addSpaceVariable("alice/research", "MLCLAW_GATEWAY_LOCATION", "space"); @@ -3374,6 +3380,11 @@ describe("mlclaw CLI", () => { expect(output.join("\n")).toContain("deleted stale secrets HF_TOKEN, HUGGINGFACE_HUB_TOKEN"); expect(output.join("\n")).toContain("mounted bucket alice/research-data at /data/mlclaw-state"); expect(output.join("\n")).toContain("set secret MLCLAW_CREDENTIAL_KEY"); + expect(output.join("\n")).toContain("set protected Space visibility"); + expect(hub.calls).toContainEqual({ + name: "updateSpaceVisibility", + args: ["alice/research", "protected"], + }); expect(hub.calls).toContainEqual({ name: "addSpaceVariable", args: ["alice/research", "MLCLAW_STATE_MOUNT_DIR", "/data/mlclaw-state"], @@ -3677,7 +3688,7 @@ describe("mlclaw CLI", () => { expect(hub.calls).toContainEqual({ name: "spaceExists", args: ["alice/research"] }); expect(hub.calls).toContainEqual({ name: "createDockerSpace", - args: ["alice/research", { private: true, sleepTimeSeconds: -1 }], + args: ["alice/research", { visibility: "protected", sleepTimeSeconds: -1 }], }); expect(hub.calls).toContainEqual({ name: "requestSpaceHardware", diff --git a/test/mlclaw.hub-api.test.ts b/test/mlclaw.hub-api.test.ts index e90dcb5..01437e2 100644 --- a/test/mlclaw.hub-api.test.ts +++ b/test/mlclaw.hub-api.test.ts @@ -141,7 +141,7 @@ describe("HubApi Space commits", () => { expect(requests).toEqual(["https://huggingface.co/api/buckets/research-org"]); }); - it("creates Docker Spaces as private by default", async () => { + it("creates Docker Spaces as protected by default", async () => { const requests: Array<{ url: string; init: RequestInit }> = []; const hub = new HubApi({ token: "hf_test_token", @@ -167,7 +167,7 @@ describe("HubApi Space commits", () => { organization: null, type: "space", sdk: "docker", - private: true, + visibility: "protected", }); }); @@ -187,7 +187,7 @@ describe("HubApi Space commits", () => { }, }); - await hub.createDockerSpace("alice/research", { private: false }); + await hub.createDockerSpace("alice/research", { visibility: "public" }); const request = requests.find((entry) => entry.url === "https://huggingface.co/api/repos/create"); expect(request).toBeDefined(); @@ -196,7 +196,7 @@ describe("HubApi Space commits", () => { organization: null, type: "space", sdk: "docker", - private: false, + visibility: "public", }); }); @@ -207,26 +207,33 @@ describe("HubApi Space commits", () => { fetch: async (url, init) => { const request = { url: String(url), init: init ?? {} }; requests.push(request); - if (request.url.endsWith("/api/spaces/alice/research")) { - return Response.json({ private: false }); + if (request.url.endsWith("/api/whoami-v2")) { + return Response.json({ name: "alice" }); + } + if (request.url.endsWith("/api/settings/repositories")) { + return Response.json([{ id: "alice/research", type: "space", visibility: "private" }]); } return Response.json({}); }, }); - await expect(hub.getSpaceVisibility("alice/research")).resolves.toBe("public"); - await hub.updateSpaceVisibility("alice/research", "private"); + await expect(hub.getSpaceVisibility("alice/research")).resolves.toBe("private"); + await hub.updateSpaceVisibility("alice/research", "protected"); expect(requests).toEqual([ { - url: "https://huggingface.co/api/spaces/alice/research", + url: "https://huggingface.co/api/whoami-v2", + init: expect.objectContaining({ headers: { Authorization: "Bearer hf_test_token" } }), + }, + { + url: "https://huggingface.co/api/settings/repositories", init: expect.objectContaining({ headers: { Authorization: "Bearer hf_test_token" } }), }, { url: "https://huggingface.co/api/spaces/alice/research/settings", init: expect.objectContaining({ method: "PUT", - body: JSON.stringify({ visibility: "private" }), + body: JSON.stringify({ visibility: "protected" }), headers: { Authorization: "Bearer hf_test_token", "Content-Type": "application/json", @@ -236,6 +243,35 @@ describe("HubApi Space commits", () => { ]); }); + it("finds organization Space visibility across settings pages", async () => { + const requests: string[] = []; + const hub = new HubApi({ + token: "hf_test_token", + fetch: async (url) => { + const requestUrl = String(url); + requests.push(requestUrl); + if (requestUrl.endsWith("/api/whoami-v2")) { + return Response.json({ name: "alice" }); + } + if (requestUrl.endsWith("/api/organizations/research-org/settings/repositories")) { + return Response.json([{ id: "research-org/model", type: "model", visibility: "private" }], { + headers: { + link: '; rel="next"', + }, + }); + } + return Response.json([{ id: "research-org/research", type: "space", visibility: "protected" }]); + }, + }); + + await expect(hub.getSpaceVisibility("research-org/research")).resolves.toBe("protected"); + expect(requests).toEqual([ + "https://huggingface.co/api/whoami-v2", + "https://huggingface.co/api/organizations/research-org/settings/repositories", + "https://huggingface.co/api/organizations/research-org/settings/repositories?cursor=next", + ]); + }); + it("uploads files and deletes stale paths through the commit API", async () => { const requests: Array<{ url: string; init: RequestInit }> = []; const hub = new HubApi({ diff --git a/test/mlclaw.space-runtime.test.ts b/test/mlclaw.space-runtime.test.ts index 91df086..f4bda31 100644 --- a/test/mlclaw.space-runtime.test.ts +++ b/test/mlclaw.space-runtime.test.ts @@ -350,7 +350,7 @@ describe("ML Claw Space runtime", () => { expect(preflight.headers.get("access-control-allow-origin")).toBe("null"); expect(preflight.headers.get("access-control-allow-headers")).toBe("brokerkit-session, content-type"); expect(preflight.headers.get("access-control-allow-methods")).toBe("GET, POST, OPTIONS"); - expect(preflight.headers.get("access-control-allow-credentials")).toBe("true"); + expect(preflight.headers.get("access-control-allow-credentials")).toBeNull(); const authorizedHeaders = { origin: "null", authorization: "Bearer simulated-host-credential", @@ -358,7 +358,7 @@ describe("ML Claw Space runtime", () => { }; const snapshot = await fetch(`${base}/snapshot`, { headers: authorizedHeaders }); expect(snapshot.status).toBe(200); - expect(snapshot.headers.get("access-control-allow-credentials")).toBe("true"); + expect(snapshot.headers.get("access-control-allow-credentials")).toBeNull(); const snapshotBody = (await snapshot.json()) as { api_version: string; cursor: string;