Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ HARNESS_SECURITY_POSTURE=auto
#ANTHROPIC_API_KEY=sk-ant-...
#OPENAI_API_KEY=sk-...
#OPENROUTER_API_KEY=sk-or-...
#CMA_ENVIRONMENT_ID=env_...
#CMA_ENVIRONMENT_KEY=sk-ant-oat01-...

ORG_ID=acme
PORT=8080
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,21 @@ Each person and each room has its own scoped memory, files, keychain view, permi
crons, web apps, and durable sandbox.

It's built with open source in mind. Pick your own harness and model and switch between
them — Pi, OpenCode, Codex, and Claude Code all drive the same core, so a deployment
isn't tied to any single vendor.
them — Pi, OpenCode, Codex, Claude Code, and Claude Managed Agents all drive the same
core, so a deployment isn't tied to any single vendor.

The first four run the agent loop in-process. Claude Managed Agents runs it as a
hosted session on Anthropic's servers, which makes QM's brain/hands split physical:
the brain (harness) is a managed service, and the hands are your sandbox. Tool calls
come back to QM to run in your sandbox, with your credentials, under the same command
policy and approvals as the other harnesses.

The hosted Claude brain has stronger resiliency and performance than in-process
Claude Code. Conversation state lives with the session, so an agent that is mid-task
during a redeploy of QM survives with its conversation intact, and a turn sends just
one new message. Claude works with the native bash toolset it knows best, and every
session has a live trace in the Claude console, which makes debugging an agent much
easier.

## Features

Expand Down Expand Up @@ -49,7 +62,7 @@ flowchart LR

subgraph CORE["Headless core"]
API["API · identity · policy · scheduler"]
LOOP["Agent loop<br/>(Pi, OpenCode, Claude Code)"]
LOOP["Agent loop<br/>(Pi, OpenCode, Claude Code, CMA)"]
API <--> LOOP
end

Expand Down
13 changes: 11 additions & 2 deletions cli/src/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { existsSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { assertNodeEngine, emailTransportPreflight, flySandboxTokenPreflight } from "../preflight.ts";
import {
assertNodeEngine,
cmaEnvironmentPreflight,
emailTransportPreflight,
flySandboxTokenPreflight,
} from "../preflight.ts";
import { readEnvFile } from "../util.ts";
import { CliError, errMessage, header, note, ok, step, warn } from "../log.ts";
import { validateSandboxLayer, type SandboxValidation } from "../sandbox-layer.ts";
import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts";
import { mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts";
import { configuredHarness, mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts";
import { computedSecrets, runtimeSecretNames, type ComputedSecret } from "../secrets.ts";
import { isVirtualService, runnableServices } from "../services.ts";
import { serviceEnvironment } from "../backends/aws.ts";
Expand Down Expand Up @@ -33,6 +38,9 @@ export function runChecks(
if (provider.requiresSandboxApp && !config.sandbox?.app?.trim()) {
configError("contract sandbox.app: a Fly agent-computer app is required for docker and fly targets");
}
if (configuredHarness(config) === "cma" && !config.env.core?.CMA_ENVIRONMENT_ID?.trim()) {
configError("contract env.core.CMA_ENVIRONMENT_ID: HARNESS=cma needs a self-hosted CMA environment id");
}
for (const skill of config.skills) {
const path = resolve(configDir, skill);
let isDirectory: boolean;
Expand Down Expand Up @@ -179,6 +187,7 @@ export async function runCheckCommand(
const secrets = readEnvFile(envFile ?? join(configDir, ".env"));
await flySandboxTokenPreflight(config, secrets);
await emailTransportPreflight(config, secrets);
await cmaEnvironmentPreflight(config, secrets);
note("");
ok("check passed — config, sandbox layer, and plugins are valid.");
}
4 changes: 2 additions & 2 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const MODEL_PROVIDER_KEYS: Readonly<Record<ModelProvider, string>> = {
};

export const MODEL_PROVIDER_HARNESSES: Readonly<Record<ModelProvider, readonly string[]>> = {
anthropic: ["pi", "opencode", "claude", "mock"],
anthropic: ["pi", "opencode", "claude", "cma", "mock"],
openai: ["pi", "opencode", "codex", "mock"],
openrouter: ["pi", "mock"],
};
Expand Down Expand Up @@ -710,7 +710,7 @@ function validate(raw: unknown, path: string): QmConfig {
return out;
}

function configuredHarness(config: QmConfig): string {
export function configuredHarness(config: QmConfig): string {
return config.env.core?.HARNESS?.trim() || (config.target === "fly" ? "pi" : "mock");
}

Expand Down
130 changes: 119 additions & 11 deletions cli/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { connect as netConnect, type Socket } from "node:net";
import { connect as tlsConnect } from "node:tls";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { QmConfig } from "./config.ts";
import { configuredHarness, type QmConfig } from "./config.ts";
import { CliError, errMessage, step, warn } from "./log.ts";
import { deploymentSecretValue } from "./util.ts";

Expand Down Expand Up @@ -67,6 +67,20 @@ export function assertNodeEngine(deploymentDir?: string): void {
}
}

async function probeFetch(
fetchImpl: typeof fetch,
url: string,
init: RequestInit,
subject: string,
): Promise<Response | null> {
try {
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
} catch (e) {
warn(`could not verify ${subject}: ${errMessage(e)} — continuing`);
return null;
}
}

export async function flySandboxTokenPreflight(
config: QmConfig,
secrets: ReadonlyMap<string, string>,
Expand All @@ -76,16 +90,13 @@ export async function flySandboxTokenPreflight(
if (!app) return;
const token = deploymentSecretValue("FLY_SANDBOX_API_TOKEN", secrets.get("FLY_SANDBOX_API_TOKEN"))?.trim();
if (!token) return;
let response: Response;
try {
response = await fetchImpl(`https://api.machines.dev/v1/apps/${encodeURIComponent(app)}`, {
headers: { authorization: token.startsWith("FlyV1") ? token : `Bearer ${token}` },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
} catch (e) {
warn(`could not verify FLY_SANDBOX_API_TOKEN against Fly app ${app}: ${errMessage(e)} — continuing`);
return;
}
const response = await probeFetch(
fetchImpl,
`https://api.machines.dev/v1/apps/${encodeURIComponent(app)}`,
{ headers: { authorization: token.startsWith("FlyV1") ? token : `Bearer ${token}` } },
`FLY_SANDBOX_API_TOKEN against Fly app ${app}`,
);
if (!response) return;
if (response.status === 401 || response.status === 403 || response.status === 404) {
throw new CliError(
`FLY_SANDBOX_API_TOKEN cannot access the Fly app ${app} (HTTP ${response.status}) — the app may not exist ` +
Expand All @@ -102,6 +113,103 @@ export async function flySandboxTokenPreflight(
step(`Fly sandbox app ${app}: FLY_SANDBOX_API_TOKEN ok`);
}

const CMA_CONSOLE_HINT = "the Claude console (platform.claude.com)";
const CMA_DEFAULT_BASE_URL = "https://api.anthropic.com";
const CMA_PROBE_HEADERS = { "anthropic-version": "2023-06-01", "anthropic-beta": "managed-agents-2026-04-01" };

export async function cmaEnvironmentPreflight(
config: QmConfig,
secrets: ReadonlyMap<string, string>,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
if (configuredHarness(config) !== "cma") return;
const core = config.env.core ?? {};
const environmentId = core.CMA_ENVIRONMENT_ID?.trim();
if (!environmentId) return;
const baseUrl = (core.CMA_BASE_URL?.trim() || CMA_DEFAULT_BASE_URL).replace(/\/$/, "");
const environmentPath = `${baseUrl}/v1/environments/${encodeURIComponent(environmentId)}`;
if (!(await cmaEnvironmentReadable(environmentPath, environmentId, secrets, fetchImpl))) return;
await cmaEnvironmentKeyAccepted(environmentPath, environmentId, secrets, fetchImpl);
}

async function cmaEnvironmentReadable(
environmentPath: string,
environmentId: string,
secrets: ReadonlyMap<string, string>,
fetchImpl: typeof fetch,
): Promise<boolean> {
const apiKey = deploymentSecretValue("ANTHROPIC_API_KEY", secrets.get("ANTHROPIC_API_KEY"))?.trim();
if (!apiKey) return true;
const response = await probeFetch(
fetchImpl,
environmentPath,
{ headers: { ...CMA_PROBE_HEADERS, "x-api-key": apiKey } },
`CMA environment ${environmentId}`,
);
if (!response) return false;
if (response.status === 401 || response.status === 403) {
throw new CliError(
`ANTHROPIC_API_KEY cannot read CMA environment ${environmentId} (HTTP ${response.status}) — ` +
"the key is invalid or belongs to a different workspace than the environment.",
{ clause: "harness.cma-environment" },
);
}
if (response.status === 404) {
throw new CliError(
`CMA environment ${environmentId} was not found (HTTP 404) — the id has a typo, the environment lives in ` +
"another workspace, or the organization has no Claude Managed Agents access. Create a self-hosted " +
`environment in ${CMA_CONSOLE_HINT} and set env.core.CMA_ENVIRONMENT_ID to its id.`,
{ clause: "harness.cma-environment" },
);
}
if (!response.ok) {
warn(
`the Claude API returned HTTP ${response.status} while verifying CMA environment ${environmentId} — continuing`,
);
return true;
}
const parsed = (await response.json().catch(() => null)) as { config?: { type?: string } } | null;
const environmentType = parsed?.config?.type;
if (environmentType && environmentType !== "self_hosted") {
throw new CliError(
`CMA environment ${environmentId} has type "${environmentType}" — qm needs a self-hosted environment so ` +
`tool calls run in each scope's own sandbox. Create one with the self-hosted type in ${CMA_CONSOLE_HINT}.`,
{ clause: "harness.cma-environment" },
);
}
step(`CMA environment ${environmentId}: ANTHROPIC_API_KEY ok${environmentType ? ` (${environmentType})` : ""}`);
return true;
}

async function cmaEnvironmentKeyAccepted(
environmentPath: string,
environmentId: string,
secrets: ReadonlyMap<string, string>,
fetchImpl: typeof fetch,
): Promise<void> {
const environmentKey = deploymentSecretValue("CMA_ENVIRONMENT_KEY", secrets.get("CMA_ENVIRONMENT_KEY"))?.trim();
if (!environmentKey) return;
const response = await probeFetch(
fetchImpl,
`${environmentPath}/work/wk_qm_preflight_probe/heartbeat`,
{ method: "POST", headers: { ...CMA_PROBE_HEADERS, authorization: `Bearer ${environmentKey}` } },
`CMA_ENVIRONMENT_KEY against environment ${environmentId}`,
);
if (!response) return;
if (response.status === 401 || response.status === 403) {
throw new CliError(
`CMA_ENVIRONMENT_KEY is not accepted by environment ${environmentId} (HTTP ${response.status}) — generate a ` +
`fresh environment key on the environment's page in ${CMA_CONSOLE_HINT} and update the secret.`,
{ clause: "harness.cma-environment" },
);
}
if (response.status !== 404 && !response.ok) {
warn(`the Claude API returned HTTP ${response.status} while verifying CMA_ENVIRONMENT_KEY — continuing`);
return;
}
step(`CMA environment ${environmentId}: CMA_ENVIRONMENT_KEY ok`);
}

type SmtpTlsMode = "starttls" | "implicit" | "none";

export interface SmtpVerifyOptions {
Expand Down
27 changes: 24 additions & 3 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,28 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
{
name: "ANTHROPIC_API_KEY",
service: "core",
required: { when: { kind: "model-provider", provider: "anthropic" }, optionalOtherwise: true },
required: {
when: {
kind: "any",
conditions: [
{ kind: "env-equals", service: "core", name: "HARNESS", value: "cma" },
{ kind: "model-provider", provider: "anthropic" },
],
},
optionalOtherwise: true,
},
description:
'Anthropic API key: bills the base model when modelProvider is "anthropic", an optional deployment fallback otherwise.',
'Anthropic API key: bills the base model when modelProvider is "anthropic" and authenticates the CMA harness, an optional deployment fallback otherwise.',
},
{
name: "CMA_ENVIRONMENT_KEY",
service: "core",
required: {
when: { kind: "env-equals", service: "core", name: "HARNESS", value: "cma" },
optionalOtherwise: true,
},
description:
"Environment key for the self-hosted CMA environment, generated on the environment's page in the Claude console; the CMA harness uses it to service the environment's work queue and run the model's bash tool on each scope's own computer.",
},
{
name: "OPENROUTER_API_KEY",
Expand All @@ -71,7 +90,9 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
{
name: "PUBLIC_API_URL",
service: "core",
required: { when: { kind: "env-in", service: "core", name: "HARNESS", values: ["pi", "opencode", "codex"] } },
required: {
when: { kind: "env-in", service: "core", name: "HARNESS", values: ["pi", "opencode", "codex", "cma"] },
},
description: "Public core self-API URL reachable from agent sandboxes.",
},
{
Expand Down
9 changes: 7 additions & 2 deletions cli/templates/deployment/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,13 @@ deploy time: Anthropic serves `claude-opus-5`, OpenAI `gpt-5.6-sol`, OpenRouter
`openrouter/auto`. Set `model` in `qm.config.jsonc` only to override that, and
only with a model the chosen provider can bill — a mismatch is refused at
startup rather than at the first message. The same rule covers the harness:
`HARNESS` `codex` runs OpenAI models alone, `claude` runs Anthropic models
alone, and `openrouter` needs the default `pi` harness.
`HARNESS` `codex` runs OpenAI models alone, `claude` and `cma` run Anthropic
models alone, and `openrouter` needs the default `pi` harness. `cma` needs one
extra piece of one-time setup: in the Claude console (platform.claude.com),
create an environment with the self-hosted type and generate its environment
key on the environment's page, then put `CMA_ENVIRONMENT_ID` in the core `env`
of `qm.config.jsonc` and set `CMA_ENVIRONMENT_KEY` as a core secret. `qm check`
verifies both against the live environment before deploy.

An operator may still prefer to hold the key centrally and rotate it from the
Admin page. That is a deliberate choice, not the default: drop `modelProvider`
Expand Down
15 changes: 15 additions & 0 deletions cli/test/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ function check(d: { dir: string; config: QmConfig }): ReturnType<typeof runCheck
return runChecks(d.config, d.dir, join(d.dir, "sandbox"), { report: false });
}

test("HARNESS=cma without an environment id fails config checks before any deploy", () => {
const bad = deployment(() => {}, { env: { core: { HARNESS: "cma" } } });
const good = deployment(() => {}, { env: { core: { HARNESS: "cma", CMA_ENVIRONMENT_ID: "env_1" } } });
try {
assert.throws(
() => check(bad),
(e: Error) => /env\.core\.CMA_ENVIRONMENT_ID/.test(e.message),
);
assert.doesNotThrow(() => check(good));
} finally {
rmSync(bad.dir, { recursive: true, force: true });
rmSync(good.dir, { recursive: true, force: true });
}
});

test("a valid sandbox layer passes and returns the parsed tools + skills", () => {
const d = deployment((dir) => {
writeTool(dir, "example-tool", {
Expand Down
Loading