From 31e3a4a2880c05b670c2194d6b5a994dacfa297e Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 10:08:46 +0900 Subject: [PATCH 1/2] feat: admit channel-scoped Agents with private credential handoff Refs #4 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- README.md | 2 + apps/cli/lib/agent.ts | 47 +++ apps/cli/lib/commands.ts | 33 +- apps/cli/lib/store.ts | 6 +- .../migrations/0002_grey_strong_guy.sql | 11 + .../migrations/meta/0002_snapshot.json | 302 ++++++++++++++++++ .../db/channel/migrations/meta/_journal.json | 7 + .../db/channel/migrations/migrations.js | 13 +- apps/service/db/channel/schema.ts | 12 + apps/service/src/agents.ts | 76 +++++ apps/service/src/auth/admission.ts | 15 +- apps/service/src/channel.ts | 47 ++- apps/service/src/index.ts | 3 +- docs/channel-agents.md | 29 ++ docs/multi-user-collaboration.md | 4 + docs/spec.md | 4 + examples/echo-agent.mjs | 37 +++ packages/contracts/agent.ts | 7 + tests/cli.node.test.ts | 37 ++- tests/service.test.ts | 81 ++++- tools/deployment/smoke.mjs | 2 +- 21 files changed, 757 insertions(+), 18 deletions(-) create mode 100644 apps/cli/lib/agent.ts create mode 100644 apps/service/db/channel/migrations/0002_grey_strong_guy.sql create mode 100644 apps/service/db/channel/migrations/meta/0002_snapshot.json create mode 100644 apps/service/src/agents.ts create mode 100644 docs/channel-agents.md create mode 100644 examples/echo-agent.mjs create mode 100644 packages/contracts/agent.ts diff --git a/README.md b/README.md index 490564b..7764a78 100644 --- a/README.md +++ b/README.md @@ -161,3 +161,5 @@ Keep the same operation key and body after an uncertain response. Channel creati Organization member invitations (UAT slice): [API, CLI, recovery and verification status](docs/organization-invitations.md). Use `invite send/list/revoke` from the selected owner's workspace, and `invite inbox/accept` from the recipient's own authenticated account. After acceptance, select the organization with `org use`. Direct channel guests (next UAT candidate): [isolated guest grants, CLI and acceptance](docs/channel-guests.md). `channel-invite` manages invitations; `guest-channel` opens only accepted channels without joining their organization. This initial path uses the existing user's in-app inbox, with no email delivery claim. + +Agent interaction contract and CLI: [Channel Agents](docs/channel-agents.md). diff --git a/apps/cli/lib/agent.ts b/apps/cli/lib/agent.ts new file mode 100644 index 0000000..4d7d3f5 --- /dev/null +++ b/apps/cli/lib/agent.ts @@ -0,0 +1,47 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { ClientError } from "../../../packages/contracts/instance.ts"; +import { parseAgentCredential } from "../../../packages/contracts/agent.ts"; +import { checkDiscovery, request, responseJson } from "../../../packages/client/connection.ts"; + +export function readAgentFile(path: string) { + if (!isAbsolute(path) || typeof process.getuid !== "function") throw new ClientError("PRIVATE_CREDENTIAL_FILE_REQUIRED"); + for (let p = resolve(path);; p = dirname(p)) { + if (lstatSync(p).isSymbolicLink()) throw new ClientError("SYMLINK_REFUSED"); + if (p === dirname(p)) break; + } + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const s = fstatSync(fd); + if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o077) || s.size > 20000) throw new ClientError("PRIVATE_CREDENTIAL_FILE_REQUIRED"); + return parseAgentCredential(JSON.parse(readFileSync(fd, "utf8"))); + } finally { closeSync(fd); } +} +export async function agentCommand(args: string[], signal?: AbortSignal) { + const command = args[1]; const options: Record = {}; + if (!["history", "send"].includes(command)) throw new ClientError("USAGE"); + for (let n = 2; n < args.length; n += 2) { + if (!["--credential-file", "--after", "--text-file", "--operation"].includes(args[n]) || args[n+1] === undefined || options[args[n]] !== undefined) throw new ClientError("USAGE"); + options[args[n]] = args[n+1]; + } + if (!options["--credential-file"]) throw new ClientError("USAGE"); + const credential = readAgentFile(options["--credential-file"]); + const { config, actorId, channelId, secret } = credential; + await checkDiscovery(config, signal); + const headers = { Authorization: "Bearer tob_agent_" + actorId + "." + secret, "Content-Type": "application/json" }; + const base = config.server + "/api/agent-channels/" + channelId + "/messages"; + let response: Response; + if (command === "history") response = await request(base + "?after=" + encodeURIComponent(options["--after"] ?? "0"), { headers }, signal); + else { + if (!options["--text-file"] || !/^[A-Za-z0-9_-]{16,100}$/.test(options["--operation"] ?? "")) throw new ClientError("USAGE"); + let text: string; try { text = readFileSync(options["--text-file"], "utf8"); } catch { throw new ClientError("MESSAGE_FILE_UNREADABLE"); } + if (!text.trim() || text.length > 4000) throw new ClientError("INVALID_CHANNEL_INPUT"); + response = await request(base, { method: "POST", headers: { ...headers, "Idempotency-Key": options["--operation"] }, body: JSON.stringify({ text }) }, signal); + } + const value = await responseJson(response); + if (!response.ok) { + const code = (value.error as { code?: unknown })?.code; + throw new ClientError(typeof code === "string" && /^[A-Z_]{1,80}$/.test(code) ? code : "AGENT_UNAVAILABLE"); + } + return value; +} diff --git a/apps/cli/lib/commands.ts b/apps/cli/lib/commands.ts index aff5ce2..535e361 100644 --- a/apps/cli/lib/commands.ts +++ b/apps/cli/lib/commands.ts @@ -1,3 +1,5 @@ +import { randomBytes } from "node:crypto"; +import { agentCommand } from "./agent.ts"; import { setTimeout as sleep } from "node:timers/promises"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -26,6 +28,7 @@ export async function execute(args: string[], rt: Runtime): Promise { for (const path of ["/healthz", "/readyz"]) { const r = await request(new URL(path, url).href); if (!r.ok) throw new ClientError("HEALTH_CHECK_FAILED"); outputs.push(await responseJson(r)); } return { health: outputs[0], readiness: outputs[1] }; } + if (args[0] === "agent-channel") return agentCommand(args, rt.signal); const store = new Store(rt.directory ?? join(homedir(), ".config/tob/cli")); if (args[0] === "instance" && args[1] === "import" && (args.length === 3 || (args.length === 4 && args[3] === "--replace"))) { const c = readImport(args[2]); @@ -48,11 +51,12 @@ export async function execute(args: string[], rt: Runtime): Promise { const isInvitation = args[0] === "invite"; const isGuest = args[0] === "guest-channel"; const isGuestInvite = args[0] === "channel-invite"; - const scoped = isOrg || isChannel || isInvitation || isGuest || isGuestInvite; + const isAgent = args[0] === "agent"; + const scoped = isAgent || isOrg || isChannel || isInvitation || isGuest || isGuestInvite; const orgCommand = scoped ? args[1] : undefined; const options: Record = {}; if (scoped) { - if (!(isOrg ? ["list", "create", "use", "show", "update"] : (isInvitation || isGuestInvite) ? ["inbox", "list", "send", "accept", "revoke"] : isGuest ? ["list", "show", "history", "send"] : ["list", "create", "show", "history", "send"]).includes(orgCommand ?? "")) throw new ClientError("USAGE"); + if (!(isAgent ? ["list", "create", "revoke"] : isOrg ? ["list", "create", "use", "show", "update"] : (isInvitation || isGuestInvite) ? ["inbox", "list", "send", "accept", "revoke"] : isGuest ? ["list", "show", "history", "send"] : ["list", "create", "show", "history", "send"]).includes(orgCommand ?? "")) throw new ClientError("USAGE"); for (let n = 2; n < args.length; n += 2) { if (!["--server", "--id", "--name", "--operation", "--description", "--version", "--after", "--org", "--text-file", "--email", "--channel"].includes(args[n]) || args[n+1] === undefined || options[args[n]] !== undefined) throw new ClientError("USAGE"); options[args[n]] = args[n+1]; @@ -145,6 +149,31 @@ export async function execute(args: string[], rt: Runtime): Promise { } return value; }; + if (isAgent) { + const orgId = options["--org"] ?? i.organizationId; + const channelId = options["--channel"]; + if (!orgId || !/^org_[A-Za-z0-9]+$/.test(orgId) || orgId !== i.organizationId) throw new ClientError("ORGANIZATION_TOKEN_REQUIRED"); + if (!channelId || !/^ch_[a-f0-9]{64}$/.test(channelId)) throw new ClientError("INVALID_CHANNEL"); + const base = "/" + orgId + "/channels/" + channelId + "/agents"; + if (orgCommand === "list") return call(base + (options["--after"] ? "?after=" + encodeURIComponent(options["--after"]) : "")); + if (orgCommand === "revoke") { + if (!/^ag_[a-f0-9]{64}$/.test(options["--id"] ?? "")) throw new ClientError("INVALID_AGENT_INPUT"); + return call(base + "/" + options["--id"] + "/revoke", { method: "POST" }); + } + const name = options["--name"]?.trim(); const operationId = options["--operation"]; + if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name) || !/^[A-Za-z0-9_-]{16,100}$/.test(operationId ?? "")) throw new ClientError("INVALID_AGENT_INPUT"); + const actorId = "ag_" + digest(JSON.stringify([channelId, i.userId, operationId])); + const localKey = JSON.stringify([binding, i.userId, channelId, operationId]); + let credential = store.agent(localKey); + if (credential && (credential.name !== name || credential.actorId !== actorId || credential.ownerUserId !== i.userId || credential.channelId !== channelId || credential.organizationId !== orgId || connectionKey(credential.config) !== connectionKey(c))) throw new ClientError("OPERATION_CONFLICT"); + if (!credential) { + credential = { version: 1, config: c, organizationId: orgId, channelId, ownerUserId: i.userId, actorId, name, operationId: operationId!, secret: randomBytes(32).toString("hex") }; + store.putAgent(localKey, credential); + } + const value = await call(base, { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": operationId! }, body: JSON.stringify({ name, secretHash: digest(credential.secret) }) }); + if (value.actorId !== actorId || value.ownerUserId !== i.userId || value.name !== name || !["active", "revoked", "expired"].includes(String(value.state))) throw new ClientError("INVALID_AGENT_RESPONSE"); + return { actorId, name, ownerUserId: i.userId, state: value.state, credentialFile: store.agentFile(localKey) }; + } if (isGuestInvite) { const id = options["--id"]; if (orgCommand === "inbox") return call(options["--after"] ? "?after=" + encodeURIComponent(options["--after"]) : "", {}, "/api/channel-invitations"); diff --git a/apps/cli/lib/store.ts b/apps/cli/lib/store.ts index 5d58a8e..5b85b6f 100644 --- a/apps/cli/lib/store.ts +++ b/apps/cli/lib/store.ts @@ -2,6 +2,7 @@ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdi import { dirname, isAbsolute, join, resolve } from "node:path"; import { createHash, randomUUID } from "node:crypto"; import { ClientError, parseInstance, type InstanceConfig } from "../../../packages/contracts/instance.ts"; +import { parseAgentCredential, type AgentCredential } from "../../../packages/contracts/agent.ts"; export type Session = { version: 1; binding: string; userId: string | null; accessToken: string; refreshToken: string }; export const digest = (text: string) => createHash("sha256").update(text).digest("hex"); export class Store { @@ -16,7 +17,7 @@ export class Store { } mkdirSync(this.root, { recursive: true, mode: 0o700 }); this.check(this.root, true); - for (const d of ["instances", "sessions", "locks"]) { mkdirSync(join(this.root, d), { recursive: true, mode: 0o700 }); this.check(join(this.root, d), true); } + for (const d of ["instances", "sessions", "locks", "agents"]) { mkdirSync(join(this.root, d), { recursive: true, mode: 0o700 }); this.check(join(this.root, d), true); } } private check(path: string, directory = false) { const s = lstatSync(path); @@ -60,6 +61,9 @@ export class Store { try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd); return await action(); } finally { closeSync(fd); unlinkSync(path); } } + agentFile(id: string) { return this.file("agents", id); } + agent(id: string) { const value = this.read(this.agentFile(id)); return value === undefined ? undefined : parseAgentCredential(value); } + putAgent(id: string, credential: AgentCredential) { this.write(this.agentFile(id), credential); } configs(): InstanceConfig[] { return readdirSync(join(this.root, "instances")).filter(n => /^[a-f0-9]{64}\.json$/.test(n)).map(n => parseInstance(this.read(join(this.root, "instances", n)))); } config(id: string) { const v = this.read(this.file("instances", id)); return v === undefined ? undefined : parseInstance(v); } putConfig(c: InstanceConfig) { this.write(this.file("instances", c.instanceId), c); } diff --git a/apps/service/db/channel/migrations/0002_grey_strong_guy.sql b/apps/service/db/channel/migrations/0002_grey_strong_guy.sql new file mode 100644 index 0000000..442054c --- /dev/null +++ b/apps/service/db/channel/migrations/0002_grey_strong_guy.sql @@ -0,0 +1,11 @@ +CREATE TABLE `channel_agent` ( + `actor_id` text PRIMARY KEY NOT NULL, + `owner_user_id` text NOT NULL, + `owner_membership_id` text NOT NULL, + `name` text NOT NULL, + `secret_hash` text NOT NULL, + `active` integer NOT NULL, + `history_after` integer NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL +); diff --git a/apps/service/db/channel/migrations/meta/0002_snapshot.json b/apps/service/db/channel/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..268c35a --- /dev/null +++ b/apps/service/db/channel/migrations/meta/0002_snapshot.json @@ -0,0 +1,302 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c4d38e41-6ca3-4240-848e-c0bef841f48e", + "prevId": "4520c47d-8c65-45be-bc0c-d95db1f9dc48", + "tables": { + "channel_agent": { + "name": "channel_agent", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_membership_id": { + "name": "owner_membership_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_after": { + "name": "history_after", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_config": { + "name": "channel_config", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_type": { + "name": "creator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_guest": { + "name": "channel_guest", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_after": { + "name": "history_after", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_invitation": { + "name": "channel_invitation", + "columns": { + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_user_id": { + "name": "accepted_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "channel_message": { + "name": "channel_message", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "message_author_operation": { + "name": "message_author_operation", + "columns": [ + "author_type", + "author_id", + "operation_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/service/db/channel/migrations/meta/_journal.json b/apps/service/db/channel/migrations/meta/_journal.json index 1973acb..7765abb 100644 --- a/apps/service/db/channel/migrations/meta/_journal.json +++ b/apps/service/db/channel/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1792282207676, "tag": "0001_aromatic_karma", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1792285033378, + "tag": "0002_grey_strong_guy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/service/db/channel/migrations/migrations.js b/apps/service/db/channel/migrations/migrations.js index b7c2493..9aa896a 100644 --- a/apps/service/db/channel/migrations/migrations.js +++ b/apps/service/db/channel/migrations/migrations.js @@ -1,8 +1,13 @@ import journal from './meta/_journal.json'; import m0000 from './0000_superb_madame_masque.sql'; import m0001 from './0001_aromatic_karma.sql'; +import m0002 from './0002_grey_strong_guy.sql'; -export default { - journal, - migrations: { m0000, m0001 }, -}; + export default { + journal, + migrations: { + m0000, +m0001, +m0002 + } + } diff --git a/apps/service/db/channel/schema.ts b/apps/service/db/channel/schema.ts index d043228..44787e0 100644 --- a/apps/service/db/channel/schema.ts +++ b/apps/service/db/channel/schema.ts @@ -32,3 +32,15 @@ export const channelGuest = sqliteTable("channel_guest", { active: integer("active", { mode: "boolean" }).notNull(), historyAfter: integer("history_after").notNull(), }); + +export const channelAgent = sqliteTable("channel_agent", { + actorId: text("actor_id").primaryKey(), + ownerUserId: text("owner_user_id").notNull(), + ownerMembershipId: text("owner_membership_id").notNull(), + name: text("name").notNull(), + secretHash: text("secret_hash").notNull(), + active: integer("active", { mode: "boolean" }).notNull(), + historyAfter: integer("history_after").notNull(), + createdAt: text("created_at").notNull(), + expiresAt: text("expires_at").notNull(), +}); diff --git a/apps/service/src/agents.ts b/apps/service/src/agents.ts new file mode 100644 index 0000000..5a69a58 --- /dev/null +++ b/apps/service/src/agents.ts @@ -0,0 +1,76 @@ +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { AccessError, admitUser } from "./auth/admission"; +import { channelContext, body, operation } from "./channels"; +import { access } from "./organizations"; + +const validActor = (value: string) => /^ag_[a-f0-9]{64}$/.test(value); +async function hash(value: string) { + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))), b => b.toString(16).padStart(2, "0")).join(""); +} +const limit = bodyLimit({ maxSize: 20000, onError: c => c.json({ error: { code: "INVALID_AGENT_INPUT" } }, 413) }); +export const ownerAgents = new Hono<{ Bindings: Env }>() + .use("*", limit) + .get("/:orgId/channels/:channelId/agents", async c => { + const { actor, channel } = await channelContext(c.env, c.req.header("Authorization"), c.req.param("orgId"), c.req.param("channelId")); + const after = c.req.query("after") ?? ""; + if (after && !validActor(after)) throw new AccessError("INVALID_CURSOR", 400); + return c.json(await channel.agents(c.req.param("channelId"), actor, after)); + }) + .post("/:orgId/channels/:channelId/agents", async c => { + const { member, actor, channel } = await channelContext(c.env, c.req.header("Authorization"), c.req.param("orgId"), c.req.param("channelId")); + if (member.role.slug !== "owner") throw new AccessError("ORGANIZATION_OWNER_REQUIRED", 403); + const key = operation(c.req.header("Idempotency-Key")); + let input; + try { input = await c.req.json(); } catch { throw new AccessError("INVALID_AGENT_INPUT", 400); } + // Client saves its random secret before issuing. Only its digest crosses this + // endpoint, so a lost response is safely retried without recovering a secret. + if (!input || Object.keys(input).some(k => !["name", "secretHash"].includes(k)) || typeof input.name !== "string" || !input.name.trim() || input.name.length > 80 || /[\u0000-\u001f]/.test(input.name) || typeof input.secretHash !== "string" || !/^[a-f0-9]{64}$/.test(input.secretHash)) throw new AccessError("INVALID_AGENT_INPUT", 400); + const actorId = "ag_" + await hash(JSON.stringify([c.req.param("channelId"), actor.id, key])); + const result = await channel.issueAgent(c.req.param("channelId"), actor, member.id, actorId, input.name.trim(), input.secretHash); + if (!result) throw new AccessError("OPERATION_CONFLICT", 409); + return c.json(result); + }) + .post("/:orgId/channels/:channelId/agents/:actorId/revoke", async c => { + const { member, actor, channel } = await channelContext(c.env, c.req.header("Authorization"), c.req.param("orgId"), c.req.param("channelId")); + if (member.role.slug !== "owner") throw new AccessError("ORGANIZATION_OWNER_REQUIRED", 403); + if (!validActor(c.req.param("actorId"))) throw new AccessError("INVALID_AGENT_INPUT", 400); + const result = await channel.revokeAgent(c.req.param("channelId"), actor, c.req.param("actorId")); + if (!result) throw new AccessError("AGENT_ACCESS_DENIED", 403); + return c.json(result); + }); + +async function authenticatedAgent(env: Env, header: string | undefined, channelId: string) { + const parsed = /^Bearer tob_agent_(ag_[a-f0-9]{64})\.([a-f0-9]{64})$/.exec(header ?? ""); + if (!parsed || !/^ch_[a-f0-9]{64}$/.test(channelId)) throw new AccessError("AGENT_ACCESS_DENIED", 403); + const [, actorId, secret] = parsed; + const secretHash = await hash(secret); + const channel = env.CHANNELS.getByName(channelId); + const ctx = await channel.agentContext(channelId, actorId, secretHash); + if (!ctx) throw new AccessError("AGENT_ACCESS_DENIED", 403); + await admitUser(ctx.ownerUserId, env); + const member = await access(env, ctx.ownerUserId, ctx.organizationId); + if (member.id !== ctx.ownerMembershipId || member.role.slug !== "owner") throw new AccessError("AGENT_OWNER_ACCESS_DENIED", 403); + return { channel, actorId, secretHash }; +} +export const agentMessages = new Hono<{ Bindings: Env }>() + .use("*", limit) + .get("/:channelId/messages", async c => { + const raw = c.req.query("after") ?? "0"; + if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) throw new AccessError("INVALID_CURSOR", 400); + const { channel, actorId, secretHash } = await authenticatedAgent(c.env, c.req.header("Authorization"), c.req.param("channelId")); + const result = await channel.agentHistory(c.req.param("channelId"), actorId, secretHash, Number(raw)); + if (!result.allowed) throw new AccessError("AGENT_ACCESS_DENIED", 403); + if (!result.result) throw new AccessError("INVALID_CURSOR", 400); + return c.json(result.result); + }) + .post("/:channelId/messages", async c => { + const text = await body(c.req.raw, "text", 4000); + const key = operation(c.req.header("Idempotency-Key")); + const { channel, actorId, secretHash } = await authenticatedAgent(c.env, c.req.header("Authorization"), c.req.param("channelId")); + // External membership verification can yield; local grant is rechecked here. + const result = await channel.agentSend(c.req.param("channelId"), actorId, secretHash, key, text); + if (!result.allowed) throw new AccessError("AGENT_ACCESS_DENIED", 403); + if (!result.result) throw new AccessError("OPERATION_CONFLICT", 409); + return c.json(result.result); + }); diff --git a/apps/service/src/auth/admission.ts b/apps/service/src/auth/admission.ts index 790dcc6..5ba6d28 100644 --- a/apps/service/src/auth/admission.ts +++ b/apps/service/src/auth/admission.ts @@ -9,13 +9,18 @@ export async function admit(header: string | undefined, env: Env) { let identity; try { identity = await verifyHumanToken(header, env.WORKOS_CLIENT_ID, env.WORKOS_ISSUER); } catch (e) { throw new AccessError(e instanceof AuthenticationError ? "UNAUTHENTICATED" : "IDENTITY_UNAVAILABLE", e instanceof AuthenticationError ? 401 : 503); } + return { ...identity, instanceRole: await admitUser(identity.userId, env) }; +} +// Internal canonical user check: callers must first authenticate a Human token +// or obtain the owner ID from a verified, channel-local Agent credential. +export async function admitUser(userId: string, env: Env) { const db = drizzle(env.INSTANCE_DB); const policy = await db.select().from(instanceConfig).where(eq(instanceConfig.singletonId, 1)).get(); const receipt = await db.select().from(managementOperation).where(eq(managementOperation.operationId, "instance.initialize")).get(); if (!policy || !["admin_only", "authenticated"].includes(policy.registrationMode) || !["admin_only", "all_users"].includes(policy.orgCreationMode) || receipt?.state !== "succeeded" || receipt.kind !== "instance.initialize") throw new AccessError("INSTANCE_NOT_INITIALIZED", 503); - const admin = await db.select().from(instanceAdmin).where(eq(instanceAdmin.workosUserId, identity.userId)).get(); + const admin = await db.select().from(instanceAdmin).where(eq(instanceAdmin.workosUserId, userId)).get(); // A revoked bootstrap administrator must not fall back to ordinary access. - if (admin && (admin.status !== "active" || receipt.targetId !== identity.userId)) throw new AccessError("INSTANCE_ACCESS_DENIED", 403); + if (admin && (admin.status !== "active" || receipt.targetId !== userId)) throw new AccessError("INSTANCE_ACCESS_DENIED", 403); const instanceRole = admin ? "admin" as const : "user" as const; if (instanceRole === "user" && policy.registrationMode !== "authenticated") throw new AccessError("INSTANCE_ACCESS_DENIED", 403); if (policy.registrationMode === "authenticated") { @@ -23,15 +28,15 @@ export async function admit(header: string | undefined, env: Env) { let response: Response; try { if (!env.WORKOS_API_KEY) throw new Error(); - response = await fetch("https://api.workos.com/user_management/users/" + encodeURIComponent(identity.userId), { headers: { Authorization: "Bearer " + env.WORKOS_API_KEY }, redirect: "manual", signal: AbortSignal.timeout(10000) }); + response = await fetch("https://api.workos.com/user_management/users/" + encodeURIComponent(userId), { headers: { Authorization: "Bearer " + env.WORKOS_API_KEY }, redirect: "manual", signal: AbortSignal.timeout(10000) }); } catch { throw new AccessError("IDENTITY_UNAVAILABLE", 503); } if (response.status === 404) throw new AccessError("INSTANCE_ACCESS_DENIED", 403); if (!response.ok) throw new AccessError("IDENTITY_UNAVAILABLE", 503); let user: { id?: string; email_verified?: boolean }; try { user = await response.json(); } catch { throw new AccessError("IDENTITY_UNAVAILABLE", 503); } - if (user?.id !== identity.userId || user.email_verified !== true) throw new AccessError("INSTANCE_ACCESS_DENIED", 403); + if (user?.id !== userId || user.email_verified !== true) throw new AccessError("INSTANCE_ACCESS_DENIED", 403); } - return { ...identity, instanceRole }; + return instanceRole; } export async function requireOrganizationCreation(identity: { instanceRole: "admin" | "user" }, env: Env) { diff --git a/apps/service/src/channel.ts b/apps/service/src/channel.ts index 87c6603..bff0797 100644 --- a/apps/service/src/channel.ts +++ b/apps/service/src/channel.ts @@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/durable-sqlite"; import { migrate } from "drizzle-orm/durable-sqlite/migrator"; import { and, eq, gt, gte, asc, desc } from "drizzle-orm"; import migrations from "../db/channel/migrations/migrations"; -import { channelConfig, channelMessage, channelInvitation, channelGuest } from "../db/channel/schema"; +import { channelConfig, channelMessage, channelInvitation, channelGuest, channelAgent } from "../db/channel/schema"; export type ChannelIdentity = { channelId: string; organizationId: string; name: string; creatorId: string; creatorType: "human" | "agent"; createdAt: string }; export type ChannelActor = { id: string; type: "human" | "agent"; organizationId: string; historyFrom: string; historyAfter?: number }; @@ -14,7 +14,7 @@ export class Channel extends DurableObject { this.db = drizzle(ctx.storage); ctx.blockConcurrencyWhile(async () => { await migrate(this.db, migrations); }); } - schemaStatus() { this.db.select().from(channelConfig).limit(1).all(); return { schemaVersion: 2 }; } + schemaStatus() { this.db.select().from(channelConfig).limit(1).all(); return { schemaVersion: 3 }; } initialize(value: ChannelIdentity) { const old = this.db.select().from(channelConfig).get(); if (old && Object.keys(value).some(k => old[k as keyof ChannelIdentity] !== value[k as keyof ChannelIdentity])) throw new Error("CHANNEL_STATE_CONFLICT"); @@ -106,4 +106,47 @@ export class Channel extends DurableObject { return value ? { allowed: true as const, result: this.history(channelId, value.actor, after) } : { allowed: false as const }; } + private agentView(row: typeof channelAgent.$inferSelect) { + return { actorId: row.actorId, kind: "agent" as const, name: row.name, ownerUserId: row.ownerUserId, scopes: ["messages:read", "messages:send"], state: !row.active ? "revoked" : Date.parse(row.expiresAt) <= Date.now() ? "expired" : "active", historyAfter: row.historyAfter, expiresAt: row.expiresAt }; + } + issueAgent(channelId: string, actor: ChannelActor, ownerMembershipId: string, actorId: string, name: string, secretHash: string) { + this.authorize(channelId, actor); + const old = this.db.select().from(channelAgent).where(eq(channelAgent.actorId, actorId)).get(); + if (old) return old.ownerUserId === actor.id && old.ownerMembershipId === ownerMembershipId && old.name === name && old.secretHash === secretHash ? this.agentView(old) : null; + const historyAfter = this.db.select({ sequence: channelMessage.sequence }).from(channelMessage).orderBy(desc(channelMessage.sequence)).limit(1).get()?.sequence ?? 0; + const row = this.db.insert(channelAgent).values({ actorId, ownerUserId: actor.id, ownerMembershipId, name, secretHash, active: true, historyAfter, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }).returning().get(); + return this.agentView(row); + } + agents(channelId: string, actor: ChannelActor, after: string) { + this.authorize(channelId, actor); + const rows = this.db.select().from(channelAgent).where(gt(channelAgent.actorId, after)).orderBy(asc(channelAgent.actorId)).limit(51).all(); + return { agents: rows.slice(0, 50).map(row => this.agentView(row)), nextCursor: rows.length > 50 ? rows[49].actorId : null }; + } + revokeAgent(channelId: string, actor: ChannelActor, actorId: string) { + this.authorize(channelId, actor); + const row = this.db.update(channelAgent).set({ active: false }).where(eq(channelAgent.actorId, actorId)).returning().get(); + return row ? this.agentView(row) : null; + } + private agentActor(channelId: string, actorId: string, secretHash: string) { + const config = this.db.select().from(channelConfig).get(); + const grant = this.db.select().from(channelAgent).where(eq(channelAgent.actorId, actorId)).get(); + if (!config || config.channelId !== channelId || !grant?.active || Date.parse(grant.expiresAt) <= Date.now() || grant.secretHash.length !== secretHash.length) return null; + let different = 0; + for (let n = 0; n < secretHash.length; n++) different |= secretHash.charCodeAt(n) ^ grant.secretHash.charCodeAt(n); + if (different) return null; + return { config, grant, actor: { id: actorId, type: "agent" as const, organizationId: config.organizationId, historyFrom: new Date(0).toISOString(), historyAfter: grant.historyAfter } }; + } + agentContext(channelId: string, actorId: string, secretHash: string) { + const value = this.agentActor(channelId, actorId, secretHash); + return value ? { organizationId: value.config.organizationId, ownerUserId: value.grant.ownerUserId, ownerMembershipId: value.grant.ownerMembershipId, agent: this.agentView(value.grant) } : null; + } + agentHistory(channelId: string, actorId: string, secretHash: string, after: number) { + const value = this.agentActor(channelId, actorId, secretHash); + return value ? { allowed: true as const, result: this.history(channelId, value.actor, after) } : { allowed: false as const }; + } + agentSend(channelId: string, actorId: string, secretHash: string, operationId: string, text: string) { + const value = this.agentActor(channelId, actorId, secretHash); + return value ? { allowed: true as const, result: this.send(channelId, value.actor, operationId, text) } : { allowed: false as const }; + } + } diff --git a/apps/service/src/index.ts b/apps/service/src/index.ts index e7209fb..b436ce8 100644 --- a/apps/service/src/index.ts +++ b/apps/service/src/index.ts @@ -10,6 +10,7 @@ export { Channel } from "./channel"; import { channels } from "./channels"; import { invitations, organizationInvitations } from "./invitations"; +import { ownerAgents, agentMessages } from "./agents"; import { ownerGuestInvitations, guestInvitations, guestChannels } from "./guests"; const app = new Hono<{ Bindings: Env }>(); @@ -71,7 +72,7 @@ const withIdentity = api.get("/api/me", async (c) => { // reserved so an ordinary organization.create request cannot occupy it. return c.json(await createOrganization(c.env, identity.userId, "default-workspace-v1", "My workspace", "user.default-organization")); }); -const withOrganizations = withIdentity.route("/api/organizations", organizations).route("/api/organizations", channels).route("/api/organizations", organizationInvitations).route("/api/invitations", invitations).route("/api/organizations", ownerGuestInvitations).route("/api/channel-invitations", guestInvitations).route("/api/guest-channels", guestChannels); +const withOrganizations = withIdentity.route("/api/organizations", organizations).route("/api/organizations", channels).route("/api/organizations", organizationInvitations).route("/api/invitations", invitations).route("/api/organizations", ownerGuestInvitations).route("/api/channel-invitations", guestInvitations).route("/api/guest-channels", guestChannels).route("/api/organizations", ownerAgents).route("/api/agent-channels", agentMessages); // No public bootstrap route; unrelated business commands remain closed. app.all("/api/*", (c) => c.json({ error: { code: "FEATURE_NOT_AVAILABLE" } }, 503)); app.notFound((c) => c.json({ error: { code: "NOT_FOUND" } }, 404)); diff --git a/docs/channel-agents.md b/docs/channel-agents.md new file mode 100644 index 0000000..1a745c0 --- /dev/null +++ b/docs/channel-agents.md @@ -0,0 +1,29 @@ +# Channel Agents — first interaction slice + +An owner provisions an independently authenticated Agent in one channel. This is an application-defined Agent identity, not a WorkOS Human or organization membership. All identity types remain separate from future management roles. + +The first sub-slice is phone owner → named Agent credential → independent HTTP reference Agent → text reply in the same channel → owner revoke. It executes no local work. WebSocket, structured fixed health-check requests, organization-wide Agent admission and two isolated DeepSeek Hosts are subsequent increments; this does not close T3 or the autonomous goal. + +## Credential and authority + +The owner client generates 32 random bytes, saves the secret privately before network I/O, and sends only SHA-256 of its hex representation. A stable operation identifies the actor. Same operation and payload returns the original actor; changing name/key conflicts. Revocation cannot be undone by replay. Two identical display names with different operations are separate actors. + +Channel DO stores one initial credential hash per actor, issuer user and membership ID, name, expiry (30 days), active status and join sequence floor. Scope is fixed to message read/send in that channel. Rotation is revoke plus a new actor in this slice; no secret recovery endpoint. Additive Channel schema 3 leaves D1/Org at 5/4. Reverting the code disables Agent routes but retains records. + +Every Agent HTTP call validates the channel-local credential, current instance admission and issuer organization-owner membership, then rechecks the credential inside the Channel DO read/write after external verification yields. Deleted/unverified users, disabled organizations, changed membership IDs, expired or revoked credentials are denied. Provider failure fails closed. This per-request check does not claim event-driven revocation or established WebSocket handling. + +Agent tokens cannot authenticate as Humans or call organization-management routes. Author identity is derived from the credential, never message text/body. Joining establishes a sequence floor; no earlier history is inherited. Sending uses the existing canonical message deduplication and sequence allocator. Messages do not grant local execution authority. + +## Public surface + +Owner/member: `GET /api/organizations/:org/channels/:channel/agents` lists safe metadata. Owner-only POST to that path issues; POST `/:actor/revoke` revokes. Lists are bounded/cursor-paginated. + +Agent: `GET/POST /api/agent-channels/:channel/messages`, with `Authorization: Bearer tob_agent_.`. Credentials never go in a URL. History is paginated; POST requires a stable Idempotency-Key and only `{text}`. Ordinary text cannot claim Human authorship. + +CLI: `agent create/list/revoke --server ... --channel ...`, creation additionally takes `--name` and `--operation`; returns a private credential-file path, never the secret. `agent-channel history/send --credential-file ...` uses the exported trusted configuration, checks discovery before sending the bearer, refuses symlinks/world-readable credentials and never uses Human refresh tokens. Send takes `--text-file` and `--operation`. + +## Validation gates + +Workerd tests cover same-name identity distinction, post-join floor, cross-channel/forged-author refusal, current owner qualification, expiry, revoke and eviction/idempotent recovery. CLI tests cover persist-before-network, stable retry, secret-free output, unsafe file refusal and discovery drift. Real UAT, native handoff/reply/revoke and reference-client restart evidence remain pending. No Prod or merge. + +`node examples/echo-agent.mjs --credential-file /private/path.json [--after N]` processes one bounded history page and replies only to Human `echo: ...` text. It never evaluates text as commands and ignores Agent messages. Stable sequence-derived reply keys survive process restart/replay without duplicate messages. Continue with returned nextCursor for later pages; a one-shot sample is not a live WebSocket or a local execution ledger. diff --git a/docs/multi-user-collaboration.md b/docs/multi-user-collaboration.md index 72e2e9d..4766c22 100644 --- a/docs/multi-user-collaboration.md +++ b/docs/multi-user-collaboration.md @@ -52,3 +52,7 @@ This completes the narrow HTTP channel sub-slice, not T1/T2 or the overall goal. ## Next: direct channel guest candidate [Channel guest contract and acceptance](channel-guests.md) now defines existing-user inbox acceptance without organization membership. Source tests cover per-channel isolation, sequence-based history, revoke/rejoin and DO→D1 recovery. Mobile, real UAT and cross-Host Agent proof remain separate gates; do not infer them from this service preparation. + +## Channel guests qualified; next Agent interaction + +[Channel guest native continuation](channel-guests.md#native-uat-verification--2026-10-18-jst) passed phone invite/accept/send, independent CLI boundaries, refresh/restart, revoked old-session refusal and owner revoke. [Agent interaction](channel-agents.md) is now the active sub-slice: independent channel-scoped identity and harmless reply, with credentials separate from local work authorization. Agent organization-wide membership, WebSocket, fixed work, two-Host communication and group-channel clarification remain outstanding. diff --git a/docs/spec.md b/docs/spec.md index 0c8e7eb..f16760a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -333,3 +333,7 @@ The organization invitation sub-slice now has [native UAT evidence](organization ### Direct channel guest implementation candidate See [channel guests](channel-guests.md) for the bounded next slice. Channel DO owns guest invitations and user-ID-bound grants with an exact message-sequence join floor; D1 holds a recipient lookup projection only. Accepting does not create WorkOS organization membership. Current organization owners issue/revoke; accepted guests can read/send only through their active channel grant. Organization members retain the existing all-channels model. Mobile and real UAT acceptance remain pending; this is not a completed invitation or Agent slice. + +### Channel Agent interaction sub-slice + +The autonomous UAT goal now proceeds through [channel-scoped Agent credentials and reply](channel-agents.md), after [direct channel guest native verification](channel-guests.md#native-uat-verification--2026-10-18-jst). One initial credential hash per Channel Agent, fixed read/send scopes and a join sequence floor are the implemented narrow model; separate rotating credentials, organization-wide Agents and management roles remain target extensions. This increment does not claim work execution, WebSocket, two-Host completion or T3 closure. diff --git a/examples/echo-agent.mjs b/examples/echo-agent.mjs new file mode 100644 index 0000000..ce4021e --- /dev/null +++ b/examples/echo-agent.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// No DSH, model, shell execution, listening port or Human session is required. +import { readAgentFile } from '../apps/cli/lib/agent.ts'; +import { checkDiscovery, request, responseJson } from '../packages/client/connection.ts'; +import { ClientError } from '../packages/contracts/instance.ts'; +export async function echoOnce(file, after = 0) { + if (!Number.isSafeInteger(after) || after < 0) throw new ClientError('INVALID_CURSOR'); + const {config, actorId, channelId, secret} = readAgentFile(file); + await checkDiscovery(config); + const url = config.server + '/api/agent-channels/' + channelId + '/messages'; + const headers = {Authorization: 'Bearer tob_agent_' + actorId + '.' + secret, 'Content-Type':'application/json'}; + const call = async(init, suffix = '') => { + const r = await request(url + suffix, {...init, headers:{...headers, ...init?.headers}}); + const value = await responseJson(r); + if (!r.ok) throw new ClientError(typeof value.error?.code === 'string' && /^[A-Z_]+$/.test(value.error.code) ? value.error.code : 'AGENT_UNAVAILABLE'); + return value; + }; + const page = await call({}, '?after=' + after); + if (!Array.isArray(page.messages) || page.messages.length > 50 || !Number.isSafeInteger(page.nextCursor) || page.nextCursor < after || typeof page.hasMore !== 'boolean') throw new ClientError('INVALID_RESPONSE'); + let replies = 0; + for (const message of page.messages) { + if (!Number.isSafeInteger(message.sequence) || message.sequence <= after || typeof message.text !== 'string' || !['human','agent'].includes(message.authorType)) throw new ClientError('INVALID_RESPONSE'); + // Only an explicit harmless echo request from a Human can trigger a reply. + // Agent results never trigger other Agents. This is not work authorization. + if (message.authorType !== 'human' || !message.text.startsWith('echo: ')) continue; + const reply = await call({method:'POST', headers:{'Idempotency-Key':'echo-message-' + String(message.sequence).padStart(16,'0')}, body:JSON.stringify({text:'Echo: ' + message.text.slice(6,3990)})}); + if (reply.message?.authorId !== actorId || reply.message.authorType !== 'agent' || typeof reply.replayed !== 'boolean') throw new ClientError('INVALID_RESPONSE'); + if (!reply.replayed) replies++; + } + return {actorId, replies, nextCursor:page.nextCursor, hasMore:page.hasMore}; +} +if (import.meta.url === new URL(process.argv[1], 'file:').href) { + try { + if (process.argv[2] !== '--credential-file' || !process.argv[3] || (process.argv.length !== 4 && !(process.argv.length === 6 && process.argv[4] === '--after' && /^(0|[1-9][0-9]*)$/.test(process.argv[5])))) throw new ClientError('USAGE'); + console.log(JSON.stringify(await echoOnce(process.argv[3], Number(process.argv[5] ?? 0)))); + } catch(e) { console.log(JSON.stringify({error:{code:e instanceof ClientError ? e.code : 'AGENT_OPERATION_FAILED'}}));process.exitCode=1; } +} diff --git a/packages/contracts/agent.ts b/packages/contracts/agent.ts new file mode 100644 index 0000000..4aa65e3 --- /dev/null +++ b/packages/contracts/agent.ts @@ -0,0 +1,7 @@ +import { ClientError, parseInstance, type InstanceConfig } from "./instance.ts"; +export type AgentCredential = { version: 1; config: InstanceConfig; organizationId: string; channelId: string; actorId: string; ownerUserId: string; name: string; operationId: string; secret: string }; +export function parseAgentCredential(value: unknown): AgentCredential { + const v = value as AgentCredential; + if (!v || v.version !== 1 || [v.organizationId, v.channelId, v.actorId, v.ownerUserId, v.name, v.operationId, v.secret].some(s => typeof s !== "string") || !/^org_[A-Za-z0-9]+$/.test(v.organizationId) || !/^ch_[a-f0-9]{64}$/.test(v.channelId) || !/^ag_[a-f0-9]{64}$/.test(v.actorId) || !/^user_[A-Za-z0-9]+$/.test(v.ownerUserId) || !v.name.trim() || v.name.length > 80 || !/^[A-Za-z0-9_-]{16,100}$/.test(v.operationId) || !/^[a-f0-9]{64}$/.test(v.secret)) throw new ClientError("INVALID_AGENT_CREDENTIAL"); + return { ...v, config: parseInstance(v.config) }; +} diff --git a/tests/cli.node.test.ts b/tests/cli.node.test.ts index 0891c36..5453d89 100644 --- a/tests/cli.node.test.ts +++ b/tests/cli.node.test.ts @@ -1,6 +1,6 @@ import { test, before, after, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, writeFileSync, statSync, readdirSync, chmodSync, symlinkSync, realpathSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync, readdirSync, chmodSync, symlinkSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { generateKeyPair, exportJWK, SignJWT } from 'jose'; @@ -242,3 +242,38 @@ test('channel guests use independent public routes without selecting or joining await fails(run('guest-channel','history','--id',channel),'CHANNEL_ACCESS_DENIED'); assert.equal(orgSelected,'org_owner'); }); + + +test('Agent create persists a private stable credential before network and retries without disclosing it', async()=>{ + await setup(); orgSelected='org_fixture'; await command('refresh'); + const original=globalThis.fetch; let lost=true; let hashes:string[]=[]; + const channelId='ch_'+'a'.repeat(64); + globalThis.fetch=async(url,init)=>{ + if(!String(url).endsWith('/agents'))return original(url,init); + const files=readdirSync(join(rt.directory!,'agents')); assert.equal(files.length,1); + const credential=JSON.parse(readFileSync(join(rt.directory!,'agents',files[0]),'utf8')); + assert.equal(statSync(join(rt.directory!,'agents',files[0])).mode&0o777,0o600); + const body=JSON.parse(String(init?.body));hashes.push(body.secretHash);assert.notEqual(body.secretHash,credential.secret); + if(lost){lost=false;throw Error('response lost');} + return Response.json({actorId:credential.actorId,name:credential.name,ownerUserId:'user_fixture',state:'active',secret:credential.secret}); + }; + const run=(name='Test agent')=>execute(['agent','create','--server',config.server,'--channel',channelId,'--name',name,'--operation','agent-create-stable-0001'],rt); + await fails(run(),'NETWORK_UNAVAILABLE'); + const result=await run() as {credentialFile:string}; + const credential=JSON.parse(readFileSync(result.credentialFile,'utf8')); + assert.equal(new Set(hashes).size,1);assert.equal(JSON.stringify(result).includes(credential.secret),false); + await fails(run('Changed agent'),'OPERATION_CONFLICT');assert.equal(hashes.length,2); + const before=state(); + globalThis.fetch=async(url,init)=>{ + if(String(url).includes('/api/agent-channels/')){assert.equal(new Headers(init?.headers).get('Authorization'),'Bearer tob_agent_'+credential.actorId+'.'+credential.secret);return Response.json({messages:[],nextCursor:4,hasMore:false});} + return original(url,init); + }; + assert.deepEqual(await execute(['agent-channel','history','--credential-file',result.credentialFile],rt),{messages:[],nextCursor:4,hasMore:false}); + assert.deepEqual(state(),before); + discovery.auth.clientId='client_changed';discovery.auth.issuer='https://api.workos.com/user_management/client_changed';discovery.auth.jwksUri='https://api.workos.com/sso/jwks/client_changed'; + await fails(execute(['agent-channel','history','--credential-file',result.credentialFile],rt),'INSTANCE_CONFIG_CHANGED'); + chmodSync(result.credentialFile,0o644); + await fails(execute(['agent-channel','history','--credential-file',result.credentialFile],rt),'PRIVATE_CREDENTIAL_FILE_REQUIRED'); + chmodSync(result.credentialFile,0o600);const link=join(root,'agent-link.json');symlinkSync(result.credentialFile,link); + await fails(execute(['agent-channel','history','--credential-file',link],rt),'SYMLINK_REFUSED'); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 8f65c8a..4796ba3 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -23,7 +23,7 @@ it("migrates both real SQLite bindings and repeated migrations keep data", async await applyD1Migrations(env.INSTANCE_DB, env.TEST_MIGRATIONS); const ready = await request("/readyz"); expect(ready.status).toBe(200); - expect(await ready.json()).toMatchObject({ ready: true, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 2, businessEnabled: true }); + expect(await ready.json()).toMatchObject({ ready: true, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 3, businessEnabled: true }); expect(await env.INSTANCE_DB.prepare("SELECT config_version FROM instance_config").first("config_version")).toBe(7); await expect(env.INSTANCE_DB.prepare("INSERT INTO instance_config VALUES (2,'admin_only','admin_only',1)").run()).rejects.toThrow(); }); @@ -689,3 +689,82 @@ it('guest grant accepts once under concurrency, survives eviction, and rejects d expect(await (await f.guest('/guest-channels')).json()).toMatchObject({ channels: [] }); expect((await f.guest('/guest-channels/' + f.channel.channelId + '/messages')).status).toBe(403); }); + +async function agentFixture() { + const f = await guestFixture(); + const secret = 'b'.repeat(64); + const secretHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))), b => b.toString(16).padStart(2, '0')).join(''); + const issue = (name = 'Independent agent', key = 'agent-issue-operation-0001', hash = secretHash) => f.call(f.base + '/agents', 'user_a', 'POST', { name, secretHash: hash }, key); + const call = (actorId: string, method = 'GET', data?: unknown, channelId = f.channel.channelId, credential = secret, key = 'agent-message-operation-0001') => request('/api/agent-channels/' + channelId + '/messages', { method, headers: { Authorization: 'Bearer tob_agent_' + actorId + '.' + credential, 'Content-Type': 'application/json', 'Idempotency-Key': key }, ...(data === undefined ? {} : { body: JSON.stringify(data) }) }); + return { ...f, secret, secretHash, issueAgent: issue, agentCall: call }; +} +it('channel Agent has independent identity, post-join history and no organization or cross-channel authority', async () => { + const f = await agentFixture(); + await f.call(f.base + '/messages', 'user_a', 'POST', { text: 'Before agent joined' }, 'before-agent-message'); + const r = await f.issueAgent(); expect(r.status).toBe(200); + const a = await r.json() as { actorId: string }; + expect(JSON.stringify(a)).not.toContain(f.secretHash); expect(JSON.stringify(a)).not.toContain(f.secret); + expect(a).toMatchObject({ kind: 'agent', historyAfter: 1, scopes: ['messages:read', 'messages:send'], state: 'active' }); + expect(await (await f.agentCall(a.actorId)).json()).toMatchObject({ messages: [], nextCursor: 1 }); + expect((await f.agentCall(a.actorId, 'GET', undefined, f.sibling.channelId)).status).toBe(403); + expect((await f.agentCall(a.actorId, 'GET', undefined, f.channel.channelId, 'c'.repeat(64))).status).toBe(403); + expect((await f.agentCall(a.actorId, 'POST', { text: 'forged', authorId: 'user_a' })).status).toBe(400); + const send = () => f.agentCall(a.actorId, 'POST', { text: 'Agent reply' }); + expect(await (await send()).json()).toMatchObject({ replayed: false, message: { authorId: a.actorId, authorType: 'agent', sequence: 2 } }); + expect(await (await send()).json()).toMatchObject({ replayed: true, message: { sequence: 2 } }); + expect((await f.agentCall(a.actorId, 'POST', { text: 'conflict' })).status).toBe(409); + expect(await (await f.call(f.base + '/messages')).json()).toMatchObject({ messages: [{ text: 'Before agent joined' }, { authorType: 'agent', text: 'Agent reply' }] }); + expect((await request('/api/organizations', { headers: { Authorization: 'Bearer tob_agent_' + a.actorId + '.' + f.secret } })).status).toBe(401); + expect(f.members).toHaveLength(1); +}); +it('Agent issuance recovers after eviction, same-name identities differ, no secret in listings and revoked keys stay revoked', async () => { + const f = await agentFixture(); + const a = await (await f.issueAgent()).json() as { actorId: string }; + await evictDurableObject(env.CHANNELS.getByName(f.channel.channelId)); + expect(await (await f.issueAgent()).json()).toEqual(a); + expect((await f.issueAgent('Changed')).status).toBe(409); + expect((await f.issueAgent('Independent agent', 'agent-issue-operation-0001', 'a'.repeat(64))).status).toBe(409); + const b = await (await f.issueAgent('Independent agent', 'agent-issue-operation-0002')).json() as { actorId: string }; + expect(a.actorId).not.toBe(b.actorId); + const list = await (await f.call(f.base + '/agents')).json(); + expect(list).toMatchObject({ agents: expect.arrayContaining([{ ...a }, { ...b }]) }); + expect(JSON.stringify(list)).not.toContain('secretHash'); + await f.call(f.base + '/agents/' + a.actorId + '/revoke', 'user_a', 'POST'); + expect((await f.agentCall(a.actorId)).status).toBe(403); + expect((await f.agentCall(a.actorId, 'POST', { text: 'blocked' })).status).toBe(403); + expect(await (await f.issueAgent()).json()).toMatchObject({ state: 'revoked' }); + expect((await f.agentCall(b.actorId)).status).toBe(200); +}); +it('current issuer qualification, instance admission and credential expiry gate Agent access', async () => { + const f = await agentFixture(); + const a = await (await f.issueAgent()).json() as { actorId: string }; + f.members[0].role.slug = 'member'; + expect((await f.issueAgent('Another', 'agent-issue-operation-0003')).status).toBe(403); + expect((await f.call(f.base + '/agents/' + a.actorId + '/revoke', 'user_a', 'POST')).status).toBe(403); + expect((await f.agentCall(a.actorId)).status).toBe(403); + f.members[0].role.slug = 'owner'; f.members[0].id = 'om_rejoined'; + expect((await f.agentCall(a.actorId)).status).toBe(403); + f.members[0].id = 'om_owner'; f.controls.verified = false; + expect((await f.agentCall(a.actorId)).status).toBe(403); + f.controls.verified = true; + await env.INSTANCE_DB.prepare("UPDATE instance_config SET registration_mode='admin_only'").run(); + expect((await f.agentCall(a.actorId)).status).toBe(403); + await enableUsers(); + await runInDurableObject(env.CHANNELS.getByName(f.channel.channelId), (_instance, state) => { state.storage.sql.exec("UPDATE channel_agent SET expires_at='2000-01-01T00:00:00Z'"); }); + expect((await f.agentCall(a.actorId)).status).toBe(403); +}); +it('credential revoked while issuer verification yields cannot commit a later Agent send', async () => { + const f = await agentFixture(); + const a = await (await f.issueAgent()).json() as { actorId: string }; + const provider = vi.mocked(globalThis.fetch).getMockImplementation()!; + let revoked = false; + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + if (String(input).includes('/organization_memberships?') && !revoked) { + revoked = true; + await env.CHANNELS.getByName(f.channel.channelId).revokeAgent(f.channel.channelId, { id:'user_a',type:'human',organizationId:'org_invites',historyFrom:'1970-01-01T00:00:00.000Z' }, a.actorId); + } + return provider(input, init); + }); + expect((await f.agentCall(a.actorId, 'POST', { text:'Must never commit' })).status).toBe(403); + expect(await (await f.call(f.base + '/messages')).json()).toMatchObject({messages:[]}); +}); diff --git a/tools/deployment/smoke.mjs b/tools/deployment/smoke.mjs index c893e53..31addc4 100644 --- a/tools/deployment/smoke.mjs +++ b/tools/deployment/smoke.mjs @@ -24,7 +24,7 @@ for (let attempt = 1; attempt <= attempts; attempt++) { assert.equal((await get('/api/auth/logout', 'POST')).status, 401); const ready = await get('/readyz'); assert.equal(ready.status, 200); - assert.deepEqual(ready.body, { ready: true, releaseSha: target.releaseSha, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 2, businessEnabled: true }); + assert.deepEqual(ready.body, { ready: true, releaseSha: target.releaseSha, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 3, businessEnabled: true }); const refusal = await get('/api/organizations', 'POST'); assert.equal(refusal.status, 401); assert.equal(refusal.body.error.code, 'UNAUTHENTICATED'); From 21ff587655b52d6e17026612994d1605bd2fc507 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 10:27:44 +0900 Subject: [PATCH 2/2] docs: record first channel Agent UAT verification (#4) --- docs/channel-agents.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/channel-agents.md b/docs/channel-agents.md index 1a745c0..f964b65 100644 --- a/docs/channel-agents.md +++ b/docs/channel-agents.md @@ -24,6 +24,6 @@ CLI: `agent create/list/revoke --server ... --channel ...`, creation additionall ## Validation gates -Workerd tests cover same-name identity distinction, post-join floor, cross-channel/forged-author refusal, current owner qualification, expiry, revoke and eviction/idempotent recovery. CLI tests cover persist-before-network, stable retry, secret-free output, unsafe file refusal and discovery drift. Real UAT, native handoff/reply/revoke and reference-client restart evidence remain pending. No Prod or merge. +Workerd tests cover same-name identity distinction, post-join floor, cross-channel/forged-author refusal, current owner qualification, expiry, revoke and eviction/idempotent recovery. CLI tests cover persist-before-network, stable retry, secret-free output, unsafe file refusal and discovery drift. Real UAT runtime31e3a4a (schema5/4/3) passed independent credential issuance/retry, prejoin/cross-channel/forged-author refusal, separate-process replay and revoke denial. Mobile Batch17 runtime dc3ee2f09064de7e96169199c93a5cc68da400ea passed actual phone creation, private clipboard handoff, independent Agent reply with exact CLI readback, cold restart/refresh with identical handoff, maximum-text revoke and phone logout isolation. Six native form size/theme cases retained draft/focus. [Native artifacts and limits](https://github.com/BotHarness/DeepSeekBot/pull/2152). These are synthetic UAT/API/native checks, not new Hosted AuthKit, email delivery, Human QA, local execution or two-Host evidence. No Prod or merge. `node examples/echo-agent.mjs --credential-file /private/path.json [--after N]` processes one bounded history page and replies only to Human `echo: ...` text. It never evaluates text as commands and ignores Agent messages. Stable sequence-derived reply keys survive process restart/replay without duplicate messages. Continue with returned nextCursor for later pages; a one-shot sample is not a live WebSocket or a local execution ledger.