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
14 changes: 9 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions plans/paid-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
> Item 6, priced tools, is built (`POST /public/steps/:type/run`, its allowlist bag with the whole graph's reader, the reserve and the charge around one call, a dead call's hold freed at boot; `src/public-tools.ts`, specs/API.md §17.7).
> Item 10, a visitor's app page, is built (a public app's `run/launch` on a runnable workflow it grants through the public launcher, `{ payment_required }` as the tool result, the host's sheet that pays and retries with the credential on the request, the visit that reads its own runs back; `src/public-surface.ts`, `web/src/visitor-pay.ts`, specs/API.md §17.6).
> Item 7, earnings, is built (the payer for the daemon's `POST /pay` and NWC's `pay_invoice`, `POST /earnings/withdraw` and its Withdraw dialog, `GET /earnings` and `/earnings/entries`, `earnings/read`, the Earnings tab and the earnings shown on a run, a runnable row, the Workflow flyout and a package's page; `src/payments/payer.ts`, `withdrawals.ts`, `earnings.ts`, specs/API.md §19).
> Item 8, agents and the store, is built (`POST /public/mcp`'s `list_public`, `run_workflow`, `call_tool`, `get_run`, `get_credit` over the same doors as the HTTP routes, `payment_required` as a result and `payment` or the connection's L402 header to pay; `GET /llms.txt` for a public view; the index's live items carrying `runnable` with prices; the store's Run form paying through the visitor's sheet and claim keeper; `src/public-agents.ts`, `web/src/store-run.ts`, specs/API.md §17.8).
> Item 9, a strut that buys, is built (`remote/list`, `remote/run-workflow`, `remote/call` spending the credit held at a seller, `entitlements.json`, the admin's `remote/buy-credit` proposal paid through the withdrawals service and claimed until it settles, `GET /remote/credit`; `src/remote.ts`, `src/guarded-http.ts`, specs/API.md §20).
>
> **Revised the same day, three times.**
Expand Down
148 changes: 140 additions & 8 deletions specs/API.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions src/createStrut.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,8 @@ describe("the API key gate", () => {
)].filter((r) => r !== "GET /health" && r !== "POST /auth/session" && r !== "DELETE /auth/session");
// `GET /public/*` is open by design (src/public-routes.ts): it serves only
// what a person made public, and with nothing public it serves nothing.
const publicReads = routes.filter((r) => /^GET \/public(?:\/|$)/.test(r));
// So is `GET /llms.txt` (plans/paid-runs.md §10): the public view as a page.
const publicReads = routes.filter((r) => /^GET \/public(?:\/|$)/.test(r) || r === "GET /llms.txt");
// A visitor's page calls its app here (plans/public-surface.md §5): open,
// and with nothing in the public view it opens no app. And anyone's
// launch and the launcher's cancel (plans/paid-runs.md §3): open, by
Expand All @@ -1427,6 +1428,7 @@ describe("the API key gate", () => {
assert.equal((await strut.app.request(path, { method, headers: { authorization: "Bearer nope" } })).status, 401, route);
}
assert.ok(publicReads.length >= 15, publicReads.join(", "));
assert.ok(publicReads.includes("GET /llms.txt"), "llms.txt is open");
for (const route of publicReads) {
const path = route.slice(4);
const res = await strut.app.request(path);
Expand Down Expand Up @@ -1742,7 +1744,7 @@ describe("the API key gate", () => {
assert.equal(session.role, "member", "no role is a member");
// Open to anyone, a member included, as they are today.
const open = (r: string) =>
r === "GET /health" || r === "POST /auth/session" || r === "DELETE /auth/session" || /^POST \/public\/(?:mcp|credit|credit\/claim|workflows\/x\/(?:run|runs\/x\/cancel)|steps\/x\/run)$/.test(r) || /^GET \/public(?:\/|$)/.test(r);
r === "GET /health" || r === "POST /auth/session" || r === "DELETE /auth/session" || /^POST \/public\/(?:mcp|credit|credit\/claim|workflows\/x\/(?:run|runs\/x\/cancel)|steps\/x\/run)$/.test(r) || /^GET \/public(?:\/|$)/.test(r) || r === "GET /llms.txt";
const routes = everyRoute(strut).filter((r) => !open(r));
// plans/member-rights.md "What a member's session opens", and the brand
// the app is shown in.
Expand Down
90 changes: 75 additions & 15 deletions src/createStrut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ import { HOME_POSTS, homeSyncRole, homeSyncRoutes, seedHomeSyncWorkflow, type Ho
import { VOICE_CALLS, voiceConfig, voiceRoutes, warmSpeech, type VoiceConfig } from "./voice.js";
import { createPublicSurface, normalizeSurface, surfaceProblem, VISIT_HEADER, visitProblem, visitorOfVisit, visitorRefusal, visitorRuns, type PublicSurface } from "./public-surface.js";
import { brandProblem, brandStyles, normalizeBrand, readBrand, writeBrand } from "./brand.js";
import { PUBLIC_PATH, PUBLIC_POST, publicRoutes, visitorAddress } from "./public-routes.js";
import { PUBLIC_PATH, PUBLIC_POST, PUBLIC_REQUESTS_PER_MINUTE, publicRoutes, visitorAddress } from "./public-routes.js";
/** How long `GET /llms.txt` keeps a page it built, per base URL. */
const LLMS_TXT_CACHE_MS = 30_000;
import { createPublicRuns, publicRunsMaxFromEnv, publicRunsPath } from "./public-runs.js";
import { resolveRunCap } from "./run-cap.js";
import { publicViewNeeds } from "./public-view.js";
Expand Down Expand Up @@ -1048,7 +1050,7 @@ export async function createStrut<TServices = unknown>(
* request its seal, the ping nothing (it only makes the index read a
* catalog it already lists). Budgeted per address. */
const INDEX_POSTS = new Set(["/index/requests", "/index/ping"]);
const INDEX_READ = /^(?:\/llms\.txt$|\/index\/(?:catalog|packages(?:\/|$)|concepts(?:\/|$)|live$|mcp$))/;
const INDEX_READ = /^\/index\/(?:catalog|packages(?:\/|$)|concepts(?:\/|$)|live$|mcp$)/;
/** The index's MCP door (src/packages/index-tools.ts): the one POST the
* role opens, the three read tools over the same derived pages. */
const INDEX_MCP = "/index/mcp";
Expand Down Expand Up @@ -1982,11 +1984,13 @@ export async function createStrut<TServices = unknown>(
// gate's mode. With the INDEX role on, `GET /index/catalog`,
// `/index/packages…` and `/index/concepts…` are open the same way — the
// phone book and the search over public catalogs, public by construction
// (§7) — and so are `GET /llms.txt` (the same store as a markdown page
// for an agent that fetches URLs; llmstxt.org) and ONE POST,
// (§7) — and so is ONE POST,
// `/index/mcp`: the same three reads as MCP tools for an agent with no
// credential (src/packages/index-tools.ts); nothing on them installs or
// writes. And a peer may POST one more route,
// writes. `GET /llms.txt` is open whatever the role (llmstxt.org): the
// index's store as a markdown page, else the public view's — what anyone
// may run here and its price (plans/paid-runs.md §10) — and a 404 when
// nothing is public. And a peer may POST one more route,
// `/packages/subscriptions/sync`: it carries nothing, and makes this strut
// read its own subscriptions' catalogs — what the publisher pings.
//
Expand Down Expand Up @@ -2039,7 +2043,7 @@ export async function createStrut<TServices = unknown>(
// from the surface alone, so open to anyone — reads and a browser's
// preflight for them.
const open = read
? path === "/health" || (serveUi && UI_FILE.test(path)) || (!!serveRef && path === SERVE_IMAGE_PATH) || carriesFileToken(c) || !!catalogTokenOf(c) || publicPackageRead(c) || (!!indexConfig && INDEX_READ.test(path)) || PUBLIC_PATH.test(path) || !!channelTicketOf(c)
? path === "/health" || path === "/llms.txt" || (serveUi && UI_FILE.test(path)) || (!!serveRef && path === SERVE_IMAGE_PATH) || carriesFileToken(c) || !!catalogTokenOf(c) || publicPackageRead(c) || (!!indexConfig && INDEX_READ.test(path)) || PUBLIC_PATH.test(path) || !!channelTicketOf(c)
: (path === "/auth/session" && (c.req.method === "POST" || c.req.method === "DELETE")) || (!!indexConfig && (path === INDEX_MCP || INDEX_POSTS.has(path)) && c.req.method === "POST") || (c.req.method === "OPTIONS" && PUBLIC_PATH.test(path)) || (c.req.method === "POST" && PUBLIC_POST.test(path)) || (!!serveRef && c.req.method === "POST" && path === "/serve/sync");
if (open) return next();
if (memberOnly(c)) {
Expand Down Expand Up @@ -4575,6 +4579,29 @@ export async function createStrut<TServices = unknown>(
: undefined,
})
: null;
/** `GET /llms.txt`, one page (the index's store, else the public view):
* built at most every `LLMS_TXT_CACHE_MS` per base URL — it reads the
* ledger and the store — and counted against the visitor address's
* `/public` budget, as the reads it summarizes are. `null`: nothing to
* show, a 404. */
const llmsCache = new Map<string, { at: number; text: string | null }>();
const llmsPage = async (c: Context, build: (publicUrl: string) => Promise<string | null>) => {
if (publicDoors.overBudget(visitorAddress(c))) return c.json({ error: `too many requests: ${PUBLIC_REQUESTS_PER_MINUTE} a minute` }, 429, { "retry-after": "60" });
const publicUrl = publicUrlOf(c);
let hit = llmsCache.get(publicUrl);
if (!hit || Date.now() - hit.at > LLMS_TXT_CACHE_MS) {
if (llmsCache.size > 100) llmsCache.clear();
hit = { at: Date.now(), text: await build(publicUrl) };
llmsCache.set(publicUrl, hit);
}
if (hit.text === null) return c.json({ error: "nothing is public on this strut server" }, 404);
return c.body(hit.text, 200, {
"content-type": "text/markdown; charset=utf-8",
"x-content-type-options": "nosniff",
"cache-control": "public, max-age=30",
"access-control-allow-origin": "*",
});
};
if (index) {
const indexError = (c: Context, err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
Expand Down Expand Up @@ -4736,12 +4763,36 @@ export async function createStrut<TServices = unknown>(
// agent that fetches URLs rather than speaks MCP: what the index is, the
// door above, the open reads, every package and Concept page, each a
// link to its JSON page here. Open like the reads; derived like them.
app.get("/llms.txt", async (c) => {
const { llmsTxt } = await import("./packages/index-tools.js");
const publicUrl = publicUrlOf(c);
const text = await llmsTxt(index, { publicUrl, ...(serveUi ? { store: publicUrl } : {}) });
return c.body(text, 200, { "content-type": "text/markdown; charset=utf-8", "x-content-type-options": "nosniff", "cache-control": "public, no-cache" });
});
app.get("/llms.txt", (c) =>
llmsPage(c, async (publicUrl) => {
const { llmsTxt } = await import("./packages/index-tools.js");
let text = await llmsTxt(index, { publicUrl, ...(serveUi ? { store: publicUrl } : {}) });
// An index that also sells (plans/paid-runs.md §10): what anyone may
// run HERE, after the store.
const runnable = await publicDoors.runnable().catch(() => []);
if (runnable.length) {
const { runnableSection } = await import("./public-agents.js");
const how = enableMcp
? `over MCP at ${publicUrl}/public/mcp (list_public, run_workflow, call_tool, get_run, get_credit) or plain HTTP`
: "over plain HTTP";
text += `\n# Run on this server\n\nAnyone may run these here, with no account, ${how}.\n\n${runnableSection(runnable, publicUrl).join("\n")}\n`;
}
return text;
}),
);
} else {
// The public view as ONE markdown page (llmstxt.org, plans/paid-runs.md
// §10): what anyone may run here and its price, how an agent runs and
// pays for it, what else anyone may read. Open to anyone, like
// `/public`; nothing public, nothing here.
app.get("/llms.txt", (c) =>
llmsPage(c, async (publicUrl) => {
const listing = await publicDoors.listing();
if (!listing.runnable.length && !listing.workflows.length && !listing.apps.length && !listing.concepts.length && !listing.steps.length) return null;
const { publicLlmsTxt } = await import("./public-agents.js");
return publicLlmsTxt(listing, { publicUrl, mcp: enableMcp });
}),
);
}

const envPoll = Number(bootEnv()["STRUT_DISTRO_POLL_MINUTES"] ?? 10);
Expand Down Expand Up @@ -8501,9 +8552,18 @@ export async function createStrut<TServices = unknown>(
viewer: `visitor ${who}`,
},
});
const instructions =
"A public app's one tool, step_run, for a visitor's page: reads held to this strut server's public view, and run/launch of a workflow anyone may run that the app grants — a priced one answers { payment_required } until its host pays.";
return createMcpDoor(() => toolsetServer({ tools, instructions, meta: APP_TOOL_META })).handle(c.req.raw);
// An AGENT with no account (plans/paid-runs.md §10, work item 8): the
// five tools beside the page's `step_run` — list_public, run_workflow,
// call_tool, get_run, get_credit — over the SAME doors as the HTTP
// routes (`publicDoors`): the address's budgets, the drain, the caps,
// the price and the key are one code path. A priced one with no
// credit answers `{ payment_required }` as its result; the agent pays
// and calls again with `payment`, or this request's own
// `Authorization: L402`.
const { buildPublicAgentTools, publicMcpInstructions } = await import("./public-agents.js");
const publicUrl = publicUrlOf(c);
const agentTools = buildPublicAgentTools({ doors: publicDoors, who, authorization: c.req.header("authorization"), publicUrl });
return createMcpDoor(() => toolsetServer({ tools: { ...agentTools, ...tools }, instructions: publicMcpInstructions(publicUrl), meta: APP_TOOL_META })).handle(c.req.raw);
});
/** The builder deps both doors spread: public links, and a run that
* outlives the wait window goes on detached, tracked like any launch —
Expand Down
53 changes: 50 additions & 3 deletions src/packages/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,9 @@ describe("the package index", () => {
// The MCP door is the role's too: without it the gate answers, and with the key there is no route.
assert.equal((await plain.app.request("/index/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 401);
assert.equal((await plain.app.request("/index/mcp", { method: "POST", headers: { ...auth, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: "{}" })).status, 404);
// So is llms.txt: the role's page, nothing without it.
assert.equal((await plain.app.request("/llms.txt")).status, 401);
// llms.txt is the role's page, or the public view's (plans/paid-runs.md
// §10): open to anyone, and with neither there is nothing to read.
assert.equal((await plain.app.request("/llms.txt")).status, 404);
assert.equal((await plain.app.request("/llms.txt", { headers: auth })).status, 404);
} finally {
await plain.close();
Expand Down Expand Up @@ -354,7 +355,7 @@ describe("the package index", () => {
const txt = await ui.app.request("/llms.txt");
assert.equal(txt.status, 200);
assert.ok((await txt.text()).includes("- [The store page](http://localhost/?store=1)"), "the store page, for a person");
assert.equal((await plain.app.request("/llms.txt")).status, 401);
assert.equal((await plain.app.request("/llms.txt")).status, 404, "nothing public there: no page");
} finally {
await ui.close();
await plain.close();
Expand Down Expand Up @@ -413,6 +414,52 @@ describe("a live row's Concept trees, and a read that fails", () => {
assert.equal(kept.apps, 1, "the view read before is kept");
assert.equal((await index.live()).length, 2);
});

it("carries what anyone may RUN there with its price (plans/paid-runs.md §10): beside a public item, an item of its own when only runnable, a stranger's fields checked", async () => {
const served: Record<string, unknown> = {
"https://pub.example/public": {
workflows: [{ name: "cve/scan", description: "Scan a package", runs: true }],
steps: [],
types: [],
apps: [],
concepts: [],
runnable: [
{ kind: "workflow", name: "cve/scan", base: 0, markup: 0, capUsd: null, reserve: 0, results: "output", input: { pkg: { type: "string" } }, recent: { days: 30, runs: 12, average: 0 } },
{ kind: "workflow", name: "voxel/character", description: "A rigged character", base: 0.1, markup: 1.5, capUsd: 5, reserve: 7.6, results: "run", recent: { days: 30, runs: 3, average: 2.25 } },
{ kind: "step", name: "cve/lookup", base: 0.02, markup: 0, capUsd: null, reserve: 0.02, results: "output" },
// A stranger's answer: what the index cannot show is left out.
{ kind: "workflow", name: "bad", base: "free", markup: 0, reserve: 0 },
{ kind: "workflow", name: "negative", base: -1, markup: 0, reserve: 0 },
{ kind: "app", name: "not-runnable", base: 0, markup: 0, reserve: 0 },
"not an item",
],
},
};
const fakeFetch = (async (input: string | URL | Request) => {
const body = served[String(input)];
return body === undefined ? new Response("missing", { status: 404 }) : new Response(JSON.stringify(body), { status: 200 });
}) as typeof fetch;
const index = createPackageIndex({ secrets: new MemorySecretStore(), dir: join(root, "index"), cache: new PackageCache(join(root, "cache")), fetch: fakeFetch });
const info = await index.setLive("acme", { url: "https://pub.example" });
assert.equal(info.liveCrawled[0]!.error, undefined);
assert.equal(info.liveCrawled[0]!.runnable, 3);
const items = await index.live();
const byName = Object.fromEntries(items.map((i) => [`${i.kind}:${i.name}`, i]));
assert.deepEqual(Object.keys(byName).sort(), ["step:cve/lookup", "workflow:cve/scan", "workflow:voxel/character"]);
// Public AND runnable: one item, its price beside it.
assert.deepEqual(byName["workflow:cve/scan"]!.run, { base: 0, markup: 0, capUsd: null, reserve: 0, results: "output", recent: { days: 30, runs: 12, average: 0 } });
assert.equal(byName["workflow:cve/scan"]!.definition, undefined);
assert.equal(byName["workflow:cve/scan"]!.runs, true);
// Runnable alone: an item of its own, its definition not public, no input kept (the store reads it from the publisher).
const voxel = byName["workflow:voxel/character"]!;
assert.equal(voxel.definition, false);
assert.equal(voxel.description, "A rigged character");
assert.deepEqual(voxel.run, { base: 0.1, markup: 1.5, capUsd: 5, reserve: 7.6, results: "run", recent: { days: 30, runs: 3, average: 2.25 } });
assert.equal((voxel.run as Record<string, unknown>)["input"], undefined);
assert.deepEqual(byName["step:cve/lookup"]!.run, { base: 0.02, markup: 0, capUsd: null, reserve: 0.02, results: "output" });
// Searchable by its words like any live item.
assert.deepEqual((await index.live("rigged")).map((i) => i.name), ["voxel/character"]);
});
});

describe("the concept tree across the crawled packages", () => {
Expand Down
Loading
Loading