Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
318f40d
Surface named environments in admin (#360)
16francej Aug 13, 2026
2e07954
Serve textual files with an explicit UTF-8 charset (#381)
ReganBell Aug 13, 2026
4f71cfb
Isolate cron fire failures so one failing cron cannot starve later du…
ReganBell Aug 13, 2026
c0888bd
Give each local byte-store write a unique temp path (#383)
ReganBell Aug 13, 2026
165c04b
web-ui: stop showing zero-second work folds (#384)
ReganBell Aug 13, 2026
dc9510e
Only restart a reattached background job on a confirmed missing proce…
ReganBell Aug 13, 2026
37f4475
Reject a run attempt whose lease was lost before completion (#386)
ReganBell Aug 13, 2026
db7149c
Record a consent-withheld cron delivery as refused and tell the owner…
ReganBell Aug 13, 2026
1aefd55
web-ui: only offer session-drop targets that will really happen (#388)
ReganBell Aug 13, 2026
3b97314
web-ui: projects page as a single-column grouped list (#390)
ReganBell Aug 13, 2026
b84f678
deploy: show a warming page while a cold-started app wakes (#392)
ReganBell Aug 13, 2026
3e2bb81
monitors: watching an already-exited (#393)
ReganBell Aug 13, 2026
20110b3
memory: coerce miscalled remember calls into facts (#394)
ReganBell Aug 13, 2026
e38303d
web-ui: session top bar with project crumb, status pill, and quick to…
ReganBell Aug 13, 2026
81751a9
Secretless brokered CLI execution (#396)
ReganBell Aug 13, 2026
5d2a1cd
Let the guidance tool read and set a channel's ambient-replies mode (…
ReganBell Aug 13, 2026
467dc50
egress: unconditional loopback deny, Envoy admin (#399)
ReganBell Aug 13, 2026
2f6d203
Forward the session scope ref when spawning a shared-scope conversati…
ReganBell Aug 13, 2026
c0fded9
policy: treat heredoc bodies fed to non-interpreter commands as data …
ReganBell Aug 13, 2026
76f8632
web-ui: close a session's open panes when it is archived (#406)
ReganBell Aug 13, 2026
45404b5
ci: reject GitHub noreply addresses in Co-Authored-By trailers (#412)
ReganBell Aug 13, 2026
75d0ed5
Reject unsupported Sprites customization
time-attack Aug 13, 2026
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
31 changes: 31 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,37 @@ jobs:
*) echo "cli/package.json version must be semver, got $head_version" >&2; exit 1 ;;
esac

coauthor-trailers:
name: Co-author trailers
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
fetch-depth: 0
persist-credentials: false
- name: Reject GitHub noreply addresses in Co-Authored-By trailers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
base=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
offenders=""
for sha in $(git rev-list "$base..$HEAD_SHA"); do
if git log -1 --format='%(trailers:key=Co-authored-by,valueonly)' "$sha" \
| grep -qi '@users\.noreply\.github\.com'; then
offenders="$offenders $sha"
fi
done
[ -n "$offenders" ] || exit 0
echo "$offenders" | xargs git show -s --format='%h %s' >&2
echo "Co-Authored-By trailers must not use @users.noreply.github.com addresses:" >&2
echo "GitHub credits them to whichever account owns that username, which may be a stranger." >&2
echo "This repo bans all such addresses, including your own privacy address;" >&2
echo "use the contributor's real email, or drop the trailer." >&2
exit 1

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@ Two habits that keep task-focused changes from scarring the rest of the repo:
Slack Mac app, and don't ask permission first — do it on your own; don't wait to be
asked. Skip it for trivial refactors, docs, config, or pure-logic changes already
covered by tests.
- **Screenshot every front-end change in the PR.** Anything an operator or user sees
rendered — admin/web/portal UI, Slack surfaces, emails — ships with a screenshot of the
after state (before/after when it's a change to something that already existed) in the PR
description, so a reviewer sees the result without booting it. Can't reach the surface
live? Render it against realistic data and say so.
- **Demo every front-end change in the PR.** Anything an operator or user sees
rendered — admin/web/portal UI, Slack surfaces, emails — ships with a way for a
reviewer to see the result without booting it. Prefer a link to a live demo app
(e.g. the built UI served against a small mock API, published internally) so the
reviewer can click around the real thing; note in the PR what's mocked. Fall back
to screenshots only when a live demo isn't practical (e.g. Slack surfaces, emails),
and then show the after state (before/after for changes to something that existed),
rendered against realistic data.

## Private forks

Expand Down
15 changes: 7 additions & 8 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,11 @@ so it prints that snapshot as the matching data restore point
(`aws rds restore-db-instance-from-db-snapshot`). Pre-deploy snapshots are
pruned to a bounded count; `aws.predeployDbSnapshot: false` opts out.

`sandbox build` is a local validation build. `sandbox publish` pushes through the
configured OCI registry, resolves the image and base digests, records the base pin in
the config and the image pin in the config (docker/fly) or the durable AWS deployment
manifest, syncs the durable deployment layer when core is reachable, and repoints a
running Fly or AWS core. On AWS it requires `sandbox.backend: "sprites"` and, before
building anything, an existing deployment manifest and no `sandbox.image` override —
that override only seeds the first `qm up` and must be removed afterwards. Every
ordinary `up` also syncs the layer.
`sandbox build` and `sandbox publish` build and pin the Docker target's OCI sandbox
image. AWS uses `infra build-image` for its Lambda MicroVM guest. Fly uses the stock
Sprites runtime exposed by the installed SDK: custom sandbox images, Dockerfiles,
deployment-layer tools, and resident environment are rejected. Text skills remain
supported through the durable deployment layer.

Auto uses its built-in model classifier unless `qm.config.jsonc` declares one
`securityScreen` proxy with a provider label, HTTPS endpoint, and `shadow` or
Expand Down Expand Up @@ -106,6 +103,8 @@ sandbox build [--from image] [--tag tag] [--dry-run]
sandbox publish [--from image] [--app registry/repo] [--tag tag] [--dry-run]
```

Rollback is available on AWS. The sandbox image commands apply to Docker.

All deploy commands accept `--config`, `--env-file`, and `--sandbox-dir`. `dev` remains
the contributor worktree loop and is separate from the portable deployment contract.

Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yc-software/qm",
"version": "0.1.6",
"version": "0.1.7",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
2 changes: 2 additions & 0 deletions cli/src/backends/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export async function doctorCommon(
}
if (config.target === "aws") {
step("AWS Lambda MicroVM sandbox: configured");
} else if (config.target === "fly") {
step("Fly Sprites stock sandbox: configured");
} else if (config.sandbox?.app) {
requireFlyAuth();
try {
Expand Down
87 changes: 6 additions & 81 deletions cli/src/backends/fly.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { CliError, bold, die, dim, errMessage, header, note, ok, step, warn } from "../log.ts";
import {
Expand Down Expand Up @@ -36,7 +36,6 @@ import {
} from "../config.ts";
import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts";
import { computedSecrets, runtimeSecretNames, secretDestinations, secretsForService } from "../secrets.ts";
import { flySandboxRepository, imageRepository, pinnedByDigest, recordSandboxPin } from "../commands/sandbox.ts";
import { manifestRef } from "../manifest.ts";
import { doctorCommon, localDoctorSecrets, requireFlyAuth } from "./doctor.ts";

Expand Down Expand Up @@ -192,13 +191,7 @@ function deriveToml(ctx: FlyCtx, service: ServiceName): string {
delete configuredEnv.FLY_ORG;
delete configuredEnv.FLY_DEPLOY_BASE_IMAGE;
}
const deploymentEnv =
service === "core"
? {
...(ctx.flyOrg ? { FLY_ORG: ctx.flyOrg } : {}),
...(sandboxEnv.FLY_BASE_IMAGE ? { FLY_DEPLOY_BASE_IMAGE: sandboxEnv.FLY_BASE_IMAGE } : {}),
}
: {};
const deploymentEnv: Record<string, string> = service === "core" && ctx.flyOrg ? { FLY_ORG: ctx.flyOrg } : {};
const overrides: Record<string, string> = {
...spec.managed(ctx.serviceCtx),
...sandboxEnv,
Expand Down Expand Up @@ -454,16 +447,6 @@ function ensureApp(app: string, flyOrg: string, orgId: string, appPrefix: string
note(`app ${app}: created`);
}

function assertOwnedApp(app: string, flyOrg: string, orgId: string, appPrefix: string): void {
if (!flyOrgApps(flyOrg).has(app)) {
throw new CliError(`app ${app} is not present in configured Fly organization ${flyOrg}`);
}
const marker = flyOwnershipMarker(flyOrg, orgId, appPrefix);
if (!secretNames(app)?.has(marker)) {
throw new CliError(`app ${app} is not marked as owned by deployment ${flyDeploymentId(flyOrg, orgId, appPrefix)}`);
}
}

function ensurePostgres(ctx: FlyCtx): void {
const app = `${ctx.appPrefix}-core`;
const hasDatabaseUrl = secretNames(app)?.has("DATABASE_URL");
Expand Down Expand Up @@ -1317,67 +1300,12 @@ export function flyDown(config: QmConfig, configDir: string): void {
ok("down — all apps scaled to 0.");
}

export function flyPinSandbox(config: QmConfig, image: string, configDir = process.cwd()): void {
const appPrefix = appPrefixOf(config);
const app = `${appPrefix}-core`;
const flyOrg = config.flyOrg ?? "";
if (!flyOrgApps(flyOrg).has(app)) {
note(
`${app} is not deployed in Fly organization ${flyOrg} — no live core to roll; the pin only takes effect from the config's sandbox.image on the next \`qm up\``,
);
return;
}
assertOwnedApp(app, flyOrg, config.orgId, appPrefix);
let running: string;
try {
running = currentImage(app);
} catch {
note(
`${app} is not running — no live core to roll; the pin only takes effect from the config's sandbox.image on the next \`qm up\``,
);
return;
}
const pinned: QmConfig = { ...config, sandbox: { ...config.sandbox, image } };
const cfgPath = writeDerived(buildCtx(pinned, configDir, {}), "core");
if (secretNames(app)?.has("FLY_BASE_IMAGE")) {
fly(["secrets", "unset", "--stage", "-a", app, "FLY_BASE_IMAGE"]);
note(`removed the stale FLY_BASE_IMAGE secret on ${app}; the derived [env] pin is authoritative`);
}
fly(["deploy", "--yes", "-c", cfgPath, "--image", running, ...serviceDef("core").fly!.deployFlags]);
ok(`${app} now boots sandboxes from ${image}`);
export function flyPinSandbox(_config: QmConfig, _image: string, _configDir = process.cwd()): void {
throw new CliError("Fly Sprites use the stock runtime and do not support sandbox image pins");
}

export function flyRollback(config: QmConfig, configPath: string, to?: string): void {
if (!to) throw new CliError("Fly rollback requires --to <sandbox-sha-or-image>");
if (to.startsWith("sha256:") && !/^sha256:[a-f0-9]{64}$/.test(to)) {
throw new CliError(`rollback --to must resolve to an image tag or sha256 digest (got ${JSON.stringify(to)})`);
}
let image: string;
if (to.includes("/")) {
image = to;
} else {
let repository: string | undefined;
if (config.sandbox?.image) repository = imageRepository(config.sandbox.image);
else if (config.sandbox?.app) repository = flySandboxRepository(config.sandbox.app);
if (!repository) {
throw new CliError(
"rollback cannot derive an image repository: the config has no sandbox.app or sandbox.image — " +
"pass a full ref instead (--to <registry/repository@sha256:…>)",
);
}
image = to.startsWith("sha256:") ? `${repository}@${to}` : `${repository}:${to}`;
}
const digestRef = /^\S+@sha256:[a-f0-9]{64}$/;
const slash = image.lastIndexOf("/");
const colon = image.lastIndexOf(":");
const taggedRef = colon > slash && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(image.slice(colon + 1));
if (image.includes("@") ? !digestRef.test(image) : !taggedRef) {
throw new CliError(`rollback --to must resolve to an image tag or sha256 digest (got ${JSON.stringify(image)})`);
}
const pinned = pinnedByDigest(image);
recordSandboxPin(configPath, pinned);
note(`recorded sandbox.image = ${pinned} in ${configPath}`);
flyPinSandbox(config, pinned, dirname(configPath));
export function flyRollback(_config: QmConfig, _configPath: string, _to?: string): void {
throw new CliError("Fly rollback is unavailable because Sprites do not support sandbox image pins");
}

export async function flyDoctor(config: QmConfig, configDir: string, envFile?: string): Promise<void> {
Expand Down Expand Up @@ -1437,9 +1365,6 @@ export function verifyLocalFlyTokens(config: QmConfig, secrets: ReadonlyMap<stri
}
step(`${name}: live authorization ok`);
};
if (config.sandbox?.app) {
verify("FLY_SANDBOX_API_TOKEN", ["machine", "list", "-a", config.sandbox.app, "--json"], config.sandbox.app);
}
if (config.flyOrg && config.env.core?.DEPLOY_PROVIDER === "fly") {
verify("FLY_DEPLOY_API_TOKEN", ["apps", "list", "-o", config.flyOrg, "--json"], `organization ${config.flyOrg}`);
}
Expand Down
26 changes: 10 additions & 16 deletions cli/src/backends/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { awsScaffold, dockerScaffold, flyScaffold, type ProviderScaffold } from
import type { ResolvedPlugin } from "../plugins.ts";
import { runnableServices } from "../services.ts";
import {
assertAwsSandboxPinRecordable,
awsCheckLive,
awsDoctor,
awsDown,
Expand Down Expand Up @@ -214,8 +213,12 @@ const fly: HostingProvider = {
...(config.flyOrg ? { accountOrOrganization: config.flyOrg } : {}),
...(config.region ? { region: config.region } : {}),
}),
requiresSandboxApp: true,
publishSandbox: (ctx, opts) => publishFlySandbox(ctx, opts, true),
requiresSandboxApp: false,
publishSandbox: async () => {
throw new CliError(
"Fly Sprites use the stock runtime; the installed SDK cannot materialize images from `qm sandbox publish`",
);
},
validateConfig: (config) => {
const errors: Array<{ clause: string; message: string }> = [...sandboxImagePinErrors(config)];
if (!config.region?.trim())
Expand Down Expand Up @@ -293,19 +296,10 @@ const aws: HostingProvider = {
coordinates: (config) =>
config.aws ? { accountOrOrganization: config.aws.accountId, region: config.aws.region } : {},
requiresSandboxApp: false,
publishSandbox: async (ctx, opts) => {
if (ctx.config.sandbox?.backend !== "sprites") {
throw new CliError(
`this AWS deployment runs Lambda MicroVM sandboxes (sandbox.backend is not "sprites"); use \`qm sandbox build\` to validate the layer and \`qm infra build-image\` to publish the runtime — or set "sandbox.backend": "sprites" with "sandbox.app" to host sandboxes in an operator-published layer image`,
);
}
if (!opts.dryRun) assertAwsSandboxPinRecordable(ctx.config);
const published = runSandboxPublish(opts);
if (!published || opts.dryRun) return;
const config = loadConfigAt(ctx.configPath, { target: ctx.target }).config;
await hostingProvider(ctx.target)
.createBackend({ ...ctx, config })
.pinSandbox(published.image);
publishSandbox: async () => {
throw new CliError(
'AWS Lambda MicroVM sandboxes use `qm infra build-image`; "sandbox.backend": "sprites" custom images are unsupported',
);
},
validateConfig: (config, plugins) => {
if (!config.aws) return [];
Expand Down
7 changes: 3 additions & 4 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,11 @@ ${bold("DEPLOY (operator)")} ${dim("— runs in the deployment directory")}
status show what's running
logs [<service>] [-f] [--tail <n>] tail service logs (omit <service> for all, interleaved)
down [--purge] stop the deployment (--purge drops docker volumes)
rollback [--to <target>] roll back workloads (AWS: prior deployment manifest,
or manifest id/release label; Fly: sandbox image/tag)
rollback [--to <target>] roll back an AWS deployment manifest by id or release label
sandbox build [--from <img>] [--tag <t>] [--dry-run]
build and validate the sandbox image locally
build and validate the Docker target's sandbox image locally
sandbox publish [--from <img>] [--app <registry/repo>] [--tag <t>] [--dry-run]
build, push, resolve digest, and record the immutable pin
publish and pin the Docker target's sandbox image

${dim("Options (apply to all deploy commands):")}
--config <path> path to deploy config (default: qm.config.jsonc in deploy dir)
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ export function runChecks(
opts: { report?: boolean } = {},
): ChecksResult {
const report = opts.report ?? true;
const layer = validateSandboxLayer(sandboxDir);
const layer = validateSandboxLayer(sandboxDir, config);
const { plugins, errors: pluginErrors } = discoverPlugins(configDir, config);
const configErrors: Array<{ clause: string; message: string }> = [];
const configError = (message: string, clause = "config.v1"): void => void configErrors.push({ clause, message });
const provider = hostingProvider(config.target);
configErrors.push(...provider.validateConfig(config, plugins));
if (provider.requiresSandboxApp && !config.sandbox?.app?.trim()) {
configError("contract sandbox.app: a Fly agent-computer app is required for docker and fly targets");
configError("contract sandbox.app: a sandbox image registry app is required for the docker target");
}
for (const skill of config.skills) {
const path = resolve(configDir, skill);
Expand Down
26 changes: 18 additions & 8 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,20 @@ the scaffolded \`.gitignore\`.

## Customizing the sandbox

\`sandbox/\` defines what the agent gets in its execution environment:
\`sandbox/\` defines supported additions to the agent's execution environment:

- A skill is \`sandbox/skills/<id>/SKILL.md\`: markdown with \`name\` and
\`description\` frontmatter that teaches the agent a workflow and when to use it.
- A tool is \`sandbox/tools/<id>/tool.json\`: a descriptor whose minimal form is
- On image-customizable targets, a tool is \`sandbox/tools/<id>/tool.json\`: a descriptor whose minimal form is
\`{ "id": ..., "advertise": ..., "install": { "binary": ... } }\`, with the
executable next to it when the binary is not already in the base image.
- \`sandbox/Dockerfile\` is optional and only needed for system packages or
- On those targets, \`sandbox/Dockerfile\` is optional and only needed for system packages or
runtimes.

The scaffold ships a working example, the \`greet\` skill and \`example-tool\`.
Copy its shape, then replace or delete it.
Fly uses the stock Sprites runtime exposed by the installed SDK, so it accepts
text skills and rejects tool descriptors, binaries, and Dockerfiles. The
scaffold always ships the \`greet\` skill; image-customizable targets also ship
\`example-tool\`. Copy the supported examples, then replace or delete them.

## The workflow

Expand Down Expand Up @@ -94,6 +96,13 @@ description: Greet a teammate by name. Use whenever asked to say hello to someon
Run \`example-tool <name>\` to greet someone, e.g. \`example-tool Ada\`.
`;

const SPRITES_GREET_SKILL = `---
name: greet
description: Greet a teammate by name. Use whenever asked to say hello to someone.
---
Reply with a friendly greeting addressed to the requested person.
`;

const EXAMPLE_TOOL_DESCRIPTOR =
JSON.stringify({ id: "example-tool", advertise: "example-tool", install: { binary: "example-tool" } }, null, 2) +
"\n";
Expand All @@ -116,8 +125,9 @@ function writeIfAbsent(dir: string, segments: string[], content: string, mode?:
ok(`wrote ${rel}`);
}

function scaffoldSandbox(dir: string): void {
writeIfAbsent(dir, ["sandbox", "skills", "greet", "SKILL.md"], GREET_SKILL);
function scaffoldSandbox(dir: string, target: Target): void {
writeIfAbsent(dir, ["sandbox", "skills", "greet", "SKILL.md"], target === "fly" ? SPRITES_GREET_SKILL : GREET_SKILL);
if (target === "fly") return;
writeIfAbsent(dir, ["sandbox", "tools", "example-tool", "tool.json"], EXAMPLE_TOOL_DESCRIPTOR);
writeIfAbsent(dir, ["sandbox", "tools", "example-tool", "example-tool"], EXAMPLE_TOOL_BIN, 0o755);
}
Expand Down Expand Up @@ -295,7 +305,7 @@ export function runInit(opts: {
const manifests = renderSlackManifests(config);
writeIfAbsent(dir, ["slack-app-manifest.yml"], manifests.bot);
if (usesSlackOidc(config)) writeIfAbsent(dir, ["slack-sso-manifest.yml"], manifests.sso);
scaffoldSandbox(dir);
scaffoldSandbox(dir, target);
for (const file of provider.scaffold.files(config)) writeIfAbsent(dir, file.segments, file.content);

note("");
Expand Down
Loading