Skip to content
Merged
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
112 changes: 112 additions & 0 deletions __tests__/initQmScaffold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import {
customImageBoots,
dropTrailingCommas,
imageSkipReason,
stripJsonComments,
} from "../src/cli/init-qm.js";

// A Dockerfile that cannot run is worse than no Dockerfile. It reads as the
// supported install path, so when the binary turns out to be missing the
// operator debugs the file rather than learning the image was never used.

describe("stripJsonComments", () => {
test("a URL's // survives", () => {
// The classic failure: naive comment-stripping eats the second slash of
// https:// and turns a valid config into a parse error, in a file the
// operator did not know we read.
const src = '{ "publicUrl": "https://example.com/x", "target": "docker" }';
expect(JSON.parse(stripJsonComments(src))).toEqual({
publicUrl: "https://example.com/x",
target: "docker",
});
});

test("line and block comments go", () => {
const src = `{
// which backend
"target": "fly", /* inline */
"sandbox": { "backend": "sprites" } // trailing
}`;
expect(JSON.parse(stripJsonComments(src))).toEqual({
target: "fly",
sandbox: { backend: "sprites" },
});
});

test("an escaped quote inside a string does not end it", () => {
const src = '{ "note": "a \\" then // not a comment", "target": "docker" }';
expect(JSON.parse(stripJsonComments(src)).target).toBe("docker");
});
});

describe("dropTrailingCommas", () => {
test("a hand-edited config with trailing commas still parses", () => {
// Without this the config is unreadable, which fails open and writes the
// Dockerfile we were trying not to write — the opposite of the intent.
const src = '{ "target": "docker", "sandbox": { "backend": "sprites", }, }';
expect(JSON.parse(dropTrailingCommas(src))).toEqual({
target: "docker",
sandbox: { backend: "sprites" },
});
});

test("a comma inside a string is left alone", () => {
const src = '{ "note": "a, b, ]", "target": "docker" }';
expect(JSON.parse(dropTrailingCommas(src)).note).toBe("a, b, ]");
});
});

describe("the deployment shapes that actually ship", () => {
test("v1: target docker with the sprites backend skips the Dockerfile", () => {
// This is the combination our own guide documents, so it is the one that
// must not regress. `target` says where the control plane runs; `backend`
// says where sandboxes run, and only the latter decides this.
expect(customImageBoots({ target: "docker", backend: "sprites" })).toBe(false);
expect(imageSkipReason({ target: "docker", backend: "sprites" }))
.toBe("sprites-ignores-image");
});

test("an AWS control plane with Sprites sandboxes is NOT MicroVM", () => {
// `backend: "aws"` requires `target: "aws"`, but not the reverse
// (config.js:1106). Reporting this shape as MicroVM would tell the operator
// the CLI can never be installed, which is false — Sprites install it on
// first use.
expect(imageSkipReason({ target: "aws", backend: "sprites" }))
.toBe("sprites-ignores-image");
});

test("Lambda MicroVM is reported as itself", () => {
expect(imageSkipReason({ target: "aws", backend: "aws" })).toBe("aws-microvm");
});

test("a plain local docker deploy still gets a Dockerfile", () => {
expect(imageSkipReason({ target: "docker" })).toBeNull();
});
});

describe("customImageBoots", () => {
test("Sprites cannot boot a custom image", () => {
// yc-software/qm#272 — the published image is ignored, the stock base boots.
expect(customImageBoots({ backend: "sprites" })).toBe(false);
expect(customImageBoots({ target: "fly" })).toBe(false);
});

test("AWS MicroVM cannot install a tool binary", () => {
// yc-software/qm#350 — no install mechanism exists at all.
expect(customImageBoots({ target: "aws" })).toBe(false);
expect(customImageBoots({ backend: "aws" })).toBe(false);
});

test("a local docker target still gets the Dockerfile", () => {
expect(customImageBoots({ target: "docker" })).toBe(true);
});

test("an unreadable or absent config scaffolds as before", () => {
// Guessing wrong in this direction removes a file someone needs, so an
// unknown shape must keep the previous behaviour rather than assume.
expect(customImageBoots(null)).toBe(true);
expect(customImageBoots({})).toBe(true);
expect(customImageBoots({ target: "something-new" })).toBe(true);
});
});
40 changes: 32 additions & 8 deletions qm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,26 @@ Three files that teach a QM agent to use PipesHub:

| File | Job |
| --- | --- |
| `sandbox/Dockerfile` | installs the `pipeshub` command into the agent's sandbox |
| `sandbox/tools/pipeshub/tool.json` | tells QM the command exists, and which uses need approval |
| `sandbox/tools/pipeshub/tool.json` | tells QM the command exists, which hosts it may reach, and which uses need approval |
| `sandbox/skills/pipeshub/SKILL.md` | tells the agent when to reach for it and how to read its results |
| `sandbox/Dockerfile` | installs the `pipeshub` command — **only on deployments that can boot a custom image**, see below |

`init-qm` writes the Dockerfile only where it would actually run. On Fly
Sprites and AWS MicroVM sandboxes it is skipped, because a file that looks
like the install path but never executes sends you debugging the wrong
thing when the command turns up missing. There, `SKILL.md` installs the CLI
on first use.

That reflects how QM behaves today. If
[qm#272](https://github.com/yc-software/qm/issues/272) is fixed so Sprites
boot a published image, the Dockerfile becomes the install path again and
this changes with it.

**If you scaffolded with an earlier version**, you already have a
`sandbox/Dockerfile` on a deployment that cannot use it. Delete it — or
remove just the PipesHub install block if the rest of the file is yours.
Upcoming QM validation rejects that file rather than ignoring it, so
leaving it will fail `qm check`. Re-running `init-qm` points this out.

This is a **deployment-layer folder** — you copy it into your QM deployment
directory and `qm up`. It is *not* a git skill pack. QM supports both, they
Expand Down Expand Up @@ -90,14 +107,15 @@ Dockerfile is the install path and this first-run step can be dropped.
pipeshub init-qm /path/to/your-qm-deployment
```

Use this rather than copying by hand: it stamps the Dockerfile's version pin
from the package you just installed, so the folder and the CLI it describes
cannot end up on different versions.
Use this rather than copying by hand: it reads your `qm.config.jsonc` to
decide whether a Dockerfile is worth writing, and where it is, stamps the
version pin from the package you just installed — so the folder and the CLI
it describes cannot end up on different versions.

Re-running is safe. Files that already exist are kept and listed rather than
overwritten (`--force` overrides). An existing `sandbox/Dockerfile` is
appended to, not replaced — and left alone entirely if it already installs
the CLI.
overwritten (`--force` overrides). Where a Dockerfile does apply, an existing
`sandbox/Dockerfile` is appended to, not replaced — and left alone entirely
if it already installs the CLI.

2. **Deliver the PipesHub origin** as an environment variable that actually
reaches the sandbox. An origin with no path — the CLI appends `/mcp`
Expand Down Expand Up @@ -217,6 +235,12 @@ keychain entry under the wrong service name.

## Air-gapped and locked-down networks

Both install paths reach the public npm registry — `sandbox/Dockerfile` at
build time where one is written, and the skill's first-run step at turn time
on Sprites. On a locked-down network the first-run step is the one to think
about, because it runs inside the sandbox with whatever egress that sandbox
has.

`sandbox/Dockerfile` installs the CLI from the public npm registry at build
time. If your network blocks that, you can point it at an internal registry
mirror or vendor the tarball into the image. All that matters is that a working
Expand Down
10 changes: 8 additions & 2 deletions qm/docs/use-with-qm.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,14 @@ npm install -g @pipeshub-ai/mcp
pipeshub init-qm .
```

That writes the tool, the skill, and a Dockerfile pinned to the same CLI
version. Re-running is safe: existing files are kept.
That writes the tool and the skill. On Sprites it deliberately does **not**
write a `sandbox/Dockerfile`: Sprites cannot boot a custom image
([qm#272](https://github.com/yc-software/qm/issues/272)), so the file would
look like the install path while never running — the skill's first-run step
is what actually installs the CLI. On a deployment that can build its own
image, the Dockerfile is written and pinned to the CLI version you installed.

Re-running is safe: existing files are kept.

Set `egress` in `sandbox/tools/pipeshub/tool.json` to your PipesHub hostname
only — for example `pipeshub.your-company.com` or
Expand Down
Loading
Loading