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
2 changes: 1 addition & 1 deletion adapters/adapter-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs"
"test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs && node test/whoami.test.mjs"
}
}
83 changes: 76 additions & 7 deletions adapters/adapter-core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ import {

export const ADAPTER_CORE_VERSION = "0.1.0";
const PROTOCOL_VERSION = "0.1";

/**
* The default answer to "how do I get a credential?", attached to every 401.
*
* Both challenges send the same text because they are the same question, and a
* caller that met one and then the other should not have to learn the answer
* twice. An app serving anyone but its owner sets `credentialHint` instead: this
* default describes a file on the host, which is an answer only its owner can act
* on.
*/
const DEFAULT_CREDENTIAL_HINT = "Read the app's .agent-token file (mode 0600) in the project directory.";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No changes needed but need to create a todo to place agent context/prompt/hint to somewhere. To prevent context injected without our knowledge.

const TASK_TIMEOUT_MS = 60_000;
const TASK_MAX_DELIVERIES = 5;
const DESCRIBE_PREFIX = "/api/_a2app/describe/";
Expand Down Expand Up @@ -473,7 +484,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {
if (credentialRequired) {
return {
reply: err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "This write requires an agent credential.", {
how: config.credentialHint ?? "Read the app's .agent-token file (mode 0600) in the project directory.",
how: config.credentialHint ?? DEFAULT_CREDENTIAL_HINT,
}),
};
}
Expand Down Expand Up @@ -505,7 +516,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {
function accessFor(req: A2AppRequest): Access {
if (isSameOrigin(req)) return FULL_ACCESS;
const grant = credentialOf(req);
if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : FULL_ACCESS;
if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : ANONYMOUS_ACCESS;
const held = expandScopes(grant);
return {
canRead: (entity) => held.has(`data:${entity}:read`),
Expand All @@ -514,6 +525,43 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {
};
}

/**
* The same reach, written as the scope names a grant would have carried.
*
* whoami must answer a caller that holds no grant, and `scopes` is how that
* answer is shaped. Rendering it from the very {@link Access} describe uses
* means the two cannot drift into disagreeing about the same caller — which
* is the failure this whole change is about.
*/
function reachAsScopes(access: Access): Set<string> {
const scopes = new Set<string>();
for (const name of Object.keys(binding.entities())) {
if (access.canRead(name)) scopes.add(`data:${name}:read`);
if (access.canWrite(name)) scopes.add(`data:${name}:write`);
}
for (const o of operations) if (access.canRun(o.name)) scopes.add(`op:${o.name}`);
return scopes;
}

/**
* What an uncredentialled caller on a single-user app may do — reads, and
* nothing that changes anything.
*
* {@link authorize} requires a credential for every write and for any
* operation not declared `readOnly`, but reaches its anonymous context before
* the scope check, so reads pass unimpeded. Describe used to render that
* caller as FULL_ACCESS, which told it that it could write and then answered
* its write with a 401. An agent reads `access` precisely to know what it may
* plan, so an over-report there is not a cosmetic mismatch: it is a plan built
* against permission that was never held, spent and rejected a round trip
* later.
*/
const ANONYMOUS_ACCESS: Access = {
canRead: () => true,
canWrite: () => false,
canRun: (operation) => operations.find((o) => o.name === operation)?.readOnly === true,
};

function writeAudit(
ctx: { credentialId: string; agentName: string | null; principal: string | null } | null,
target: string,
Expand Down Expand Up @@ -953,14 +1001,35 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App {
return handleDescribe(req, segments);
}
if (path === "/api/_a2app/whoami") {
// whoami answers whoever this app will actually serve, which is what
// `authorize` decides — so it asks the same question, in the same words.
//
// It used to demand a credential of its own. That made it STRICTER than
// the surface it reports on: an uncredentialled caller on a single-user
// app reads records perfectly well, and was told 401 by the one endpoint
// whose whole job is "what may I do here?". An agent that reads its grant
// before planning — which is the documented order — stopped at the gate
// and never learned it could have read everything.
//
// The 401 that remains is the honest one: on a multi-user app, or for a
// write, `authorize` really would refuse, and the challenge still carries
// the `how` the write path sends, because this is the first 401 many
// agents meet and a bare one sends them back to a step whose whole input
// is that field.
const authz = authorize(req, { scope: null, isWrite: false });
if ("reply" in authz) return authz.reply;
const grant = credentialOf(req);
if (!grant) return err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "whoami requires a credential.");
return ok({
a2app: true,
credentialId: grant.credentialId,
agentName: grant.agentName,
principal: grant.principal,
scopes: [...expandScopes(grant)].sort(),
credentialId: authz.ctx.credentialId,
agentName: authz.ctx.agentName,
principal: authz.ctx.principal,
// A credentialled caller reports its grant. A caller that reaches a
// context WITHOUT one was never scope-checked, so it has no scope set to
// report — what it has is the reach `accessFor` renders on describe, and
// the two must not be able to disagree. Deriving the list from that one
// source is what keeps them the same answer.
scopes: grant ? [...expandScopes(grant)].sort() : [...reachAsScopes(accessFor(req))].sort(),
});
}
if (path === "/api/_a2app/context") {
Expand Down
101 changes: 101 additions & 0 deletions adapters/adapter-core/test/whoami.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* whoami answers whoever the app will actually serve.
*
* The conformance suite covers the single-user side, where an uncredentialled
* caller reads records and whoami must say so. The multi-user side cannot live
* there: that harness boots one reference app, and it is `authMode: "none"`.
* This is the other half — an app where refusing an uncredentialled caller is
* the honest answer, so the 401 has to survive, and has to keep carrying the
* `how` that tells the caller where a credential comes from.
*
* Standard library only, run directly.
*/
import { createA2App, MemoryBinding } from "../dist/index.js";

const failures = [];
const check = (label, got, want) => {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
if (g !== w) failures.push(`${label}\n expected: ${w}\n actual: ${g}`);
};
const ok = (label, cond) => {
if (!cond) failures.push(label);
};

const entities = {
notes: { module: "desk", fields: [{ name: "title", type: "string", required: true }], seed: [] },
};
const operations = [
{ name: "count-notes", description: "Count them.", destructive: false, readOnly: true, module: "desk", params: {} },
{ name: "wipe-notes", description: "Delete them all.", destructive: true, module: "desk", params: {} },
];

const build = (authMode) =>
createA2App(new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode }), {
modules: [{ name: "desk", summary: "notes" }],
operations,
credentialHint: "Ask the owner.",
});

const call = (app, path, headers = {}) =>
app.handle({ method: "GET", path, headers, query: {}, body: undefined });

/* ------------------------------------------------- multi-user: 401 stands */

const multi = build("multi-user");
const refused = await call(multi, "/api/_a2app/whoami");
check("multi-user: an uncredentialled whoami is refused", refused.status, 401);
check("multi-user: with the credential code", refused.json.code, "agent_token_required");
check("multi-user: and the hint the write path sends", refused.json.how, "Ask the owner.");

/* ------------------------------------------- single-user: the truthful answer */

const single = build("none");
const answered = await call(single, "/api/_a2app/whoami");
check("single-user: an uncredentialled whoami is answered", answered.status, 200);
check("single-user: the caller is named as anonymous", answered.json.credentialId, "anonymous");
check("single-user: with no agent name to report", answered.json.agentName, null);

// The set matters, not the order: reads and the readOnly operation, and nothing
// that changes anything. Anything else here would be whoami promising a write
// that `authorize` goes on to refuse.
const scopes = answered.json.scopes;
ok("single-user: the entity is readable", scopes.includes("data:notes:read"));
ok("single-user: the readOnly operation is runnable", scopes.includes("op:count-notes"));
ok("single-user: the entity is NOT writable", !scopes.includes("data:notes:write"));
ok("single-user: the state-changing operation is NOT runnable", !scopes.includes("op:wipe-notes"));
check("single-user: nothing else is claimed", scopes.length, 2);

/* ------------------------------ whoami and describe cannot disagree */

// The whole point of deriving whoami's scopes from the same Access describe
// uses: one caller must not be told two different things about itself.
const described = await call(single, "/api/_a2app/describe");
check("describe agrees: the caller is read-only, not full", described.json.modules[0].access, "read-only");

/* ------------------------------------------- a credential still reports itself */

const withGrant = createA2App(
new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode: "none" }),
{
modules: [{ name: "desk", summary: "notes" }],
operations,
credentials: [
{ token: "t_ro", credentialId: "cred_ro", agentName: "reader", principal: "owner", scopes: ["data:notes:read"] },
],
},
);
const asGrant = await call(withGrant, "/api/_a2app/whoami", { "x-a2app-token": "t_ro" });
check("a credentialled caller reports its own grant", asGrant.status, 200);
check("by credential id", asGrant.json.credentialId, "cred_ro");
check("with its agent name", asGrant.json.agentName, "reader");
check("and exactly its granted scopes", asGrant.json.scopes, ["data:notes:read"]);

/* ---------------------------------------------------------------- report */

if (failures.length > 0) {
console.error(`whoami: ${failures.length} failure(s)\n`);
for (const f of failures) console.error(" " + f);
process.exit(1);
}
console.log("whoami: all checks passed");
54 changes: 49 additions & 5 deletions conformance/harness/src/toolkits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,46 @@ interface ToolkitSpec {
/** react-node's real build needs `npm install` of a published adapter, so its
* gate is checked with --no-build (Toolkit class validates the scaffold). */
noBuild?: boolean;
/**
* This blueprint scaffolds a STARTING POINT, not a finished app, so `validate`
* is expected to REFUSE a fresh scaffold and name the spec as unauthored —
* that refusal is the blueprint working, not failing.
*
* Asserting `validate` passes here would demand a spec for an app that does
* not exist yet, and the only way to satisfy it would be to write a fictional
* one. The check is inverted instead, so the suite states the real contract
* rather than a wish: a starting point is not a finished app, and `validate`
* saying so on every app that is not done is the whole point of `validate`.
*
* The string says WHY for each, because "incomplete" is a claim that should
* age badly if someone finishes the blueprint and forgets this line.
*/
incompleteBecause?: string;
}

const TOOLKITS: ToolkitSpec[] = [
{ id: "blueprint-base" },
// Framework files and no runtime code at all: no entities, no operations, a
// pipeline of placeholder echoes. The author picks the stack.
{ id: "blueprint-base", incompleteBecause: "ships framework files only, no runtime code" },
// The one finished starter: a to-do app with a View, a model and operations.
{ id: "blueprint-react-node", noBuild: true },
{ id: "blueprint-python-fastapi", noBuild: true },
{ id: "blueprint-pocketbase-react", noBuild: true },
// Runtime and model are complete, but the spec is the author's to write — the
// blueprint ships the template unfilled on purpose, like the other two
// starting points. Writing one on its behalf would put words in the mouth of
// whoever owns the blueprint.
{
id: "blueprint-python-fastapi",
noBuild: true,
incompleteBecause: "ships the requirements template unfilled; the spec is the author's to write",
},
// Ships adapter hooks only. Its `tasks` collection is created by hand in
// PocketBase, and `install` prints "download the pocketbase binary" — so a
// fresh scaffold has neither data model nor runtime.
{
id: "blueprint-pocketbase-react",
noBuild: true,
incompleteBecause: "ships adapter hooks only; collections and the binary are added by hand",
},
];

/**
Expand Down Expand Up @@ -71,10 +104,21 @@ export async function runToolkitClass(cliEntry: string | null): Promise<SuiteRes

const validateArgs = [appDir, "validate", ...(tk.noBuild ? ["--no-build"] : [])];
const validated = await run(cliEntry, validateArgs);
if (validated.exit !== 0) failures.push(`validate exit ${validated.exit}: ${validated.out.trim().slice(0, 400)}`);
if (tk.incompleteBecause) {
// The refusal IS the contract here: a starting point with no entities and
// no operations must not be able to pass as a finished app, or `validate`
// means nothing on the apps that are.
if (validated.exit === 0) {
failures.push(`validate passed a scaffold that ${tk.incompleteBecause} — an unauthored spec must not pass`);
} else if (!/not authored/.test(validated.out)) {
failures.push(`validate refused for the wrong reason: ${validated.out.trim().slice(0, 300)}`);
}
} else if (validated.exit !== 0) {
failures.push(`validate exit ${validated.exit}: ${validated.out.trim().slice(0, 400)}`);
}
}

results.push({ name: `${tk.id}: scaffold → canon → validate`, ok: failures.length === 0, failures });
results.push({ name: `${tk.id}: scaffold → canon → ${tk.incompleteBecause ? "validate refuses (starting point, not a finished app)" : "validate"}`, ok: failures.length === 0, failures });
}

const passed = results.filter((r) => r.ok).length;
Expand Down
40 changes: 40 additions & 0 deletions conformance/suites/class-a-core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,46 @@ checks:
status: 403
match: { code: insufficient_scope, required: "data:cards:write" }

- name: whoami answers the uncredentialled caller this app actually serves
# whoami reports what a caller may do. On a single-user app an
# uncredentialled caller reads records perfectly well, so refusing it here
# made whoami stricter than the surface it describes -- an agent that reads
# its grant before planning (the documented order) stopped at a gate it did
# not have to.
request: { method: GET, path: /api/_a2app/whoami }
expect:
status: 200
match: { a2app: true, credentialId: anonymous, principal: owner }

- name: that caller's scopes are reads and readOnly operations, nothing more
# `scopes` is sorted, so these ordinals are stable. The point is the SET:
# both entities readable, the readOnly operation runnable, and no write and
# no state-changing operation -- which is exactly what authorize enforces.
request: { method: GET, path: /api/_a2app/whoami }
expect:
status: 200
match:
scopes.0: "data:cards:read"
scopes.1: "data:lists:read"
scopes.2: "op:count-cards"
len: { scopes: 3 }

- name: describe tells that caller read-only, not full
# It used to say "full", then answer the write it had just promised with a
# 401. An agent reads access to know what it may plan; an over-report there
# is a plan built on permission that was never held.
request: { method: GET, path: /api/_a2app/describe }
expect:
status: 200
match: { modules.0.access: read-only }

- name: a state-changing operation is still refused without a credential
request: { method: POST, path: /api/ops/archive-board, body: {} }
expect:
status: 401
match: { code: agent_token_required }
has: [how]

- name: whoami returns the calling credential's grant
request: { method: GET, path: /api/_a2app/whoami, token: readonly }
expect:
Expand Down
Loading