From 864b16d85a055397e255d012042bec0b07a9964e Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 11:18:04 +0900 Subject: [PATCH 1/7] feat: preserve Human authority in one-hop Agent work forwarding Issue: #5 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- README.md | 2 + apps/cli/lib/agent.ts | 118 ++++-- .../channel/migrations/0004_rich_marvex.sql | 3 + .../migrations/meta/0004_snapshot.json | 391 ++++++++++++++++++ .../db/channel/migrations/meta/_journal.json | 9 +- .../db/channel/migrations/migrations.js | 20 +- apps/service/db/channel/schema.ts | 2 + apps/service/src/channel.ts | 298 +++++++++++-- apps/service/src/work.ts | 81 +++- docs/agent-health-check.md | 2 +- docs/work-forwarding.md | 39 ++ packages/contracts/work.ts | 41 ++ packages/reference/health.ts | 10 +- tests/forward.node.test.ts | 210 ++++++++++ tests/service.test.ts | 103 ++++- tools/deployment/smoke.mjs | 2 +- 16 files changed, 1250 insertions(+), 81 deletions(-) create mode 100644 apps/service/db/channel/migrations/0004_rich_marvex.sql create mode 100644 apps/service/db/channel/migrations/meta/0004_snapshot.json create mode 100644 docs/work-forwarding.md create mode 100644 tests/forward.node.test.ts diff --git a/README.md b/README.md index 0e2ed7f..077f0dd 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,5 @@ Direct channel guests (next UAT candidate): [isolated guest grants, CLI and acce Agent interaction contract and CLI: [Channel Agents](docs/channel-agents.md). - [Fixed Agent health-check request and local execution contract](docs/agent-health-check.md) — T3 continuation, UAT qualification pending. + +- [One-hop work forwarding](docs/work-forwarding.md): original-Human authority, fixed capability, durable execution/forwarding choice and CLI. T4 protocol preparation; two-Host qualification pending. diff --git a/apps/cli/lib/agent.ts b/apps/cli/lib/agent.ts index f87f59d..caf183a 100644 --- a/apps/cli/lib/agent.ts +++ b/apps/cli/lib/agent.ts @@ -1,48 +1,116 @@ -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"; +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 { parseWorkMessage, validRequestId } from '../../../packages/contracts/work.ts'; +import { parseAgentCredential } from '../../../packages/contracts/agent.ts'; +import { checkDiscovery, request, responseJson } from '../../../packages/client/connection.ts'; export function readPrivateJson(path: string): unknown { - 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 (!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 JSON.parse(readFileSync(fd, "utf8")); - } finally { closeSync(fd); } + if (!s.isFile() || s.uid !== process.getuid() || s.mode & 0o077 || s.size > 20000) + throw new ClientError('PRIVATE_CREDENTIAL_FILE_REQUIRED'); + return JSON.parse(readFileSync(fd, 'utf8')); + } finally { + closeSync(fd); + } } export const readAgentFile = (path: string) => parseAgentCredential(readPrivateJson(path)); export async function agentCommand(args: string[], signal?: AbortSignal) { - const command = args[1]; const options: Record = {}; - if (!["history", "send"].includes(command)) throw new ClientError("USAGE"); + const command = args[1]; + const options: Record = {}; + if (!['history', 'send', 'forward'].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 ( + !( + command === 'forward' + ? ['--credential-file', '--request', '--target'] + : command === 'history' + ? ['--credential-file', '--after'] + : ['--credential-file', '--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"]); + if (!options['--credential-file']) throw new ClientError('USAGE'); + const credential = readAgentFile(options['--credential-file']); const { config, actorId, channelId, secret } = credential; + if ( + command === 'forward' && + (!validRequestId(options['--request']) || !/^ag_[a-f0-9]{64}$/.test(options['--target'] ?? '')) + ) + throw new ClientError('USAGE'); 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"; + 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); + if (command === 'forward') + response = await request( + config.server + + '/api/agent-channels/' + + channelId + + '/work-requests/' + + options['--request'] + + '/forward', + { method: 'POST', headers, body: JSON.stringify({ targetActorId: options['--target'] }) }, + signal, + ); + else 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); + 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"); + throw new ClientError( + typeof code === 'string' && /^[A-Z_]{1,80}$/.test(code) ? code : 'AGENT_UNAVAILABLE', + ); + } + if (command === 'forward') { + const r = parseWorkMessage(value.message); + if ( + r.channelId !== channelId || + r.targetActorId !== options['--target'] || + r.delegation?.actorId !== actorId || + r.delegation.parentRequestId !== options['--request'] || + typeof value.replayed !== 'boolean' + ) + throw new ClientError('INVALID_WORK_MESSAGE'); } return value; } diff --git a/apps/service/db/channel/migrations/0004_rich_marvex.sql b/apps/service/db/channel/migrations/0004_rich_marvex.sql new file mode 100644 index 0000000..ac48a11 --- /dev/null +++ b/apps/service/db/channel/migrations/0004_rich_marvex.sql @@ -0,0 +1,3 @@ +ALTER TABLE `channel_work` ADD `parent_request_id` text;--> statement-breakpoint +ALTER TABLE `channel_work` ADD `execution_authorized` integer DEFAULT false NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `channel_work_parent_request_id_unique` ON `channel_work` (`parent_request_id`); \ No newline at end of file diff --git a/apps/service/db/channel/migrations/meta/0004_snapshot.json b/apps/service/db/channel/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..bf1d6d0 --- /dev/null +++ b/apps/service/db/channel/migrations/meta/0004_snapshot.json @@ -0,0 +1,391 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a8105d4b-0f67-4eb6-ab5d-528214396695", + "prevId": "55a5f62f-04fb-4fd6-91a3-e7660013d915", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "channel_work": { + "name": "channel_work", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requester_membership_id": { + "name": "requester_membership_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_request_id": { + "name": "parent_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_authorized": { + "name": "execution_authorized", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "requester_user_id": { + "name": "requester_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_actor_id": { + "name": "target_actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "channel_work_sequence_unique": { + "name": "channel_work_sequence_unique", + "columns": ["sequence"], + "isUnique": true + }, + "channel_work_parent_request_id_unique": { + "name": "channel_work_parent_request_id_unique", + "columns": ["parent_request_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/service/db/channel/migrations/meta/_journal.json b/apps/service/db/channel/migrations/meta/_journal.json index 1ded396..c617211 100644 --- a/apps/service/db/channel/migrations/meta/_journal.json +++ b/apps/service/db/channel/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1792287189376, "tag": "0003_steep_brood", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1792289590415, + "tag": "0004_rich_marvex", + "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 35c7dd6..d8bd664 100644 --- a/apps/service/db/channel/migrations/migrations.js +++ b/apps/service/db/channel/migrations/migrations.js @@ -3,13 +3,15 @@ import m0000 from './0000_superb_madame_masque.sql'; import m0001 from './0001_aromatic_karma.sql'; import m0002 from './0002_grey_strong_guy.sql'; import m0003 from './0003_steep_brood.sql'; +import m0004 from './0004_rich_marvex.sql'; - export default { - journal, - migrations: { - m0000, -m0001, -m0002, -m0003 - } - } +export default { + journal, + migrations: { + m0000, + m0001, + m0002, + m0003, + m0004, + }, +}; diff --git a/apps/service/db/channel/schema.ts b/apps/service/db/channel/schema.ts index 6344903..d45e443 100644 --- a/apps/service/db/channel/schema.ts +++ b/apps/service/db/channel/schema.ts @@ -23,6 +23,8 @@ export const channelWork = sqliteTable("channel_work", { requestId: text("request_id").primaryKey(), sequence: integer("sequence").notNull().unique(), requesterMembershipId: text("requester_membership_id").notNull(), + parentRequestId: text("parent_request_id").unique(), + executionAuthorized: integer("execution_authorized", { mode: "boolean" }).notNull().default(false), requesterUserId: text("requester_user_id").notNull(), targetActorId: text("target_actor_id").notNull(), expiresAt: text("expires_at").notNull(), diff --git a/apps/service/src/channel.ts b/apps/service/src/channel.ts index 5fb5f6d..7f7d43b 100644 --- a/apps/service/src/channel.ts +++ b/apps/service/src/channel.ts @@ -15,7 +15,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: 4 }; } + schemaStatus() { this.db.select().from(channelConfig).limit(1).all(); return { schemaVersion: 5 }; } 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"); @@ -153,46 +153,294 @@ export class Channel extends DurableObject { return value ? { allowed: true as const, result: this.send(channelId, value.actor, operationId, text) } : { allowed: false as const }; } - requestWork(channelId: string, actor: ChannelActor, membershipId: string, operationId: string, requestId: string, targetActorId: string, expiresAt: string) { + requestWork( + channelId: string, + actor: ChannelActor, + membershipId: string, + operationId: string, + requestId: string, + targetActorId: string, + expiresAt: string, + ) { this.authorize(channelId, actor); - if (actor.type !== "human") return { error: "WORK_ACCESS_DENIED" as const }; - const existing = this.db.select().from(channelWork).where(eq(channelWork.requestId, requestId)).get(); + if (actor.type !== 'human') return { error: 'WORK_ACCESS_DENIED' as const }; + const existing = this.db + .select() + .from(channelWork) + .where(eq(channelWork.requestId, requestId)) + .get(); if (existing) { - const message = this.db.select().from(channelMessage).where(eq(channelMessage.sequence, existing.sequence)).get(); - return message && message.operationId === operationId && message.authorId === actor.id && message.createdAt >= actor.historyFrom && existing.requesterMembershipId === membershipId && existing.targetActorId === targetActorId && existing.expiresAt === expiresAt ? { message, replayed:true } : { error:"OPERATION_CONFLICT" as const }; + const message = this.db + .select() + .from(channelMessage) + .where(eq(channelMessage.sequence, existing.sequence)) + .get(); + return message && + message.operationId === operationId && + message.authorId === actor.id && + message.createdAt >= actor.historyFrom && + existing.requesterMembershipId === membershipId && + existing.targetActorId === targetActorId && + existing.expiresAt === expiresAt + ? { message, replayed: true } + : { error: 'OPERATION_CONFLICT' as const }; } - const agent = this.db.select().from(channelAgent).where(eq(channelAgent.actorId, targetActorId)).get(); - if (!agent?.active || Date.parse(agent.expiresAt) <= Date.now()) return { error:"AGENT_ACCESS_DENIED" as const }; + const agent = this.db + .select() + .from(channelAgent) + .where(eq(channelAgent.actorId, targetActorId)) + .get(); + if (!agent?.active || Date.parse(agent.expiresAt) <= Date.now()) + return { error: 'AGENT_ACCESS_DENIED' as const }; const now = Date.now(); - if (Date.parse(expiresAt) <= now || Date.parse(expiresAt) - now > 600000) return { error:"INVALID_WORK_EXPIRY" as const }; - const payload: WorkRequest = { version:1,requestId,channelId,requesterUserId:actor.id,targetActorId,capability:"health-check",createdAt:new Date(now).toISOString(),expiresAt }; + if (Date.parse(expiresAt) <= now || Date.parse(expiresAt) - now > 600000) + return { error: 'INVALID_WORK_EXPIRY' as const }; + const payload: WorkRequest = { + version: 1, + requestId, + channelId, + requesterUserId: actor.id, + targetActorId, + capability: 'health-check', + createdAt: new Date(now).toISOString(), + expiresAt, + }; return this.ctx.storage.transactionSync(() => { - const result = this.writeMessage(channelId, actor, operationId, "Health check requested for " + agent.name + ". Expires " + expiresAt, "work_request", payload); - if (!result) return { error:"OPERATION_CONFLICT" as const }; - this.db.insert(channelWork).values({requestId,sequence:result.message.sequence,requesterMembershipId:membershipId,requesterUserId:actor.id,targetActorId,expiresAt}).run(); + const result = this.writeMessage( + channelId, + actor, + operationId, + 'Health check requested for ' + agent.name + '. Expires ' + expiresAt, + 'work_request', + payload, + ); + if (!result) return { error: 'OPERATION_CONFLICT' as const }; + this.db + .insert(channelWork) + .values({ + requestId, + sequence: result.message.sequence, + requesterMembershipId: membershipId, + requesterUserId: actor.id, + targetActorId, + expiresAt, + }) + .run(); return result; }); } agentWorkContext(channelId: string, actorId: string, secretHash: string, requestId: string) { const value = this.agentActor(channelId, actorId, secretHash); - const work = this.db.select().from(channelWork).where(eq(channelWork.requestId, requestId)).get(); - if (!value || !work || work.targetActorId !== actorId || work.sequence <= value.grant.historyAfter) return null; - const message = this.db.select().from(channelMessage).where(eq(channelMessage.sequence, work.sequence)).get(); - return message?.kind === "work_request" ? { request:message.payload as WorkRequest,requesterMembershipId:work.requesterMembershipId,organizationId:value.config.organizationId } : null; + const work = this.db + .select() + .from(channelWork) + .where(eq(channelWork.requestId, requestId)) + .get(); + if ( + !value || + !work || + work.targetActorId !== actorId || + work.sequence <= value.grant.historyAfter + ) + return null; + const message = this.db + .select() + .from(channelMessage) + .where(eq(channelMessage.sequence, work.sequence)) + .get(); + let forwarder: { ownerUserId: string; ownerMembershipId: string } | undefined; + if (work.parentRequestId) { + const parent = this.db + .select() + .from(channelWork) + .where(eq(channelWork.requestId, work.parentRequestId)) + .get(); + const grant = + parent && + this.db + .select() + .from(channelAgent) + .where(eq(channelAgent.actorId, parent.targetActorId)) + .get(); + if (!grant?.active || Date.parse(grant.expiresAt) <= Date.now()) return null; + forwarder = { ownerUserId: grant.ownerUserId, ownerMembershipId: grant.ownerMembershipId }; + } + return message?.kind === 'work_request' + ? { + request: message.payload as WorkRequest, + requesterMembershipId: work.requesterMembershipId, + organizationId: value.config.organizationId, + ...(forwarder ? { forwarder } : {}), + } + : null; } authorizeWork(channelId: string, actorId: string, secretHash: string, requestId: string) { const ctx = this.agentWorkContext(channelId, actorId, secretHash, requestId); - if (!ctx) return { error:"WORK_ACCESS_DENIED" as const }; - if (Date.parse(ctx.request.expiresAt) <= Date.now()) return { error:"REQUEST_EXPIRED" as const }; - return { request:ctx.request }; + if (!ctx) return { error: 'WORK_ACCESS_DENIED' as const }; + if (Date.parse(ctx.request.expiresAt) <= Date.now()) + return { error: 'REQUEST_EXPIRED' as const }; + if (this.db.select().from(channelWork).where(eq(channelWork.parentRequestId, requestId)).get()) + return { error: 'WORK_ALREADY_FORWARDED' as const }; + if ( + this.db + .select() + .from(channelMessage) + .where( + and( + eq(channelMessage.authorId, actorId), + eq(channelMessage.authorType, 'agent'), + eq(channelMessage.operationId, 'result-' + requestId), + ), + ) + .get() + ) + return { error: 'WORK_ALREADY_FINISHED' as const }; + // Persist the execution/forwarding choice before returning permission to start. + this.db + .update(channelWork) + .set({ executionAuthorized: true }) + .where(eq(channelWork.requestId, requestId)) + .run(); + return { request: ctx.request }; + } + forwardWork( + channelId: string, + actorId: string, + secretHash: string, + parentRequestId: string, + requestId: string, + targetActorId: string, + ) { + const value = this.agentActor(channelId, actorId, secretHash); + const context = this.agentWorkContext(channelId, actorId, secretHash, parentRequestId); + if (!value || !context) return { error: 'WORK_ACCESS_DENIED' as const }; + const parent = this.db + .select() + .from(channelWork) + .where(eq(channelWork.requestId, parentRequestId)) + .get()!; + if (parent.parentRequestId || actorId === targetActorId) + return { error: 'FORWARD_LIMIT' as const }; + const existing = this.db + .select() + .from(channelWork) + .where(eq(channelWork.parentRequestId, parentRequestId)) + .get(); + if (existing) { + const message = this.db + .select() + .from(channelMessage) + .where(eq(channelMessage.sequence, existing.sequence)) + .get(); + return message && existing.targetActorId === targetActorId + ? { message, replayed: true } + : { error: 'OPERATION_CONFLICT' as const }; + } + if ( + parent.executionAuthorized || + this.db + .select() + .from(channelMessage) + .where( + and( + eq(channelMessage.authorId, actorId), + eq(channelMessage.authorType, 'agent'), + eq(channelMessage.operationId, 'result-' + parentRequestId), + ), + ) + .get() + ) + return { error: 'WORK_ALREADY_STARTED' as const }; + if (Date.parse(context.request.expiresAt) <= Date.now()) + return { error: 'REQUEST_EXPIRED' as const }; + const target = this.db + .select() + .from(channelAgent) + .where(eq(channelAgent.actorId, targetActorId)) + .get(); + if (!target?.active || Date.parse(target.expiresAt) <= Date.now()) + return { error: 'AGENT_ACCESS_DENIED' as const }; + const payload: WorkRequest = { + ...context.request, + requestId, + targetActorId, + delegation: { parentRequestId, actorId, hops: 1 }, + }; + return this.ctx.storage.transactionSync(() => { + const result = this.writeMessage( + channelId, + value.actor, + 'forward-' + parentRequestId, + 'Health check forwarded to ' + + target.name + + '. Original requester ' + + payload.requesterUserId + + '. Expires ' + + payload.expiresAt, + 'work_request', + payload, + ); + if (!result) return { error: 'OPERATION_CONFLICT' as const }; + this.db + .insert(channelWork) + .values({ + requestId, + parentRequestId, + sequence: result.message.sequence, + requesterMembershipId: parent.requesterMembershipId, + requesterUserId: parent.requesterUserId, + targetActorId, + expiresAt: parent.expiresAt, + }) + .run(); + return result; + }); } + agentWorkResult(channelId: string, actorId: string, secretHash: string, result: WorkResult) { const value = this.agentActor(channelId, actorId, secretHash); const ctx = this.agentWorkContext(channelId, actorId, secretHash, result.requestId); - if (!value || !ctx) return { error:"WORK_ACCESS_DENIED" as const }; - const text = "Health check " + result.state + " · " + result.environment + " · " + result.reason + " · execution count " + result.executionCount + (result.diagnostics ? " · " + result.diagnostics.platform + "/" + result.diagnostics.architecture + " · " + result.diagnostics.runtime : ""); - const written = this.writeMessage(channelId,value.actor,"result-"+result.requestId,text,"work_result",result); - return written ?? {error:"OPERATION_CONFLICT" as const}; + if (!value || !ctx) return { error: 'WORK_ACCESS_DENIED' as const }; + if ( + this.db + .select() + .from(channelWork) + .where(eq(channelWork.parentRequestId, result.requestId)) + .get() + ) + return { error: 'WORK_ALREADY_FORWARDED' as const }; + const work = this.db + .select() + .from(channelWork) + .where(eq(channelWork.requestId, result.requestId)) + .get(); + if (result.state === 'completed' && !work?.executionAuthorized) + return { error: 'WORK_NOT_AUTHORIZED' as const }; + const text = + 'Health check ' + + result.state + + ' · ' + + result.environment + + ' · ' + + result.reason + + ' · execution count ' + + result.executionCount + + (result.diagnostics + ? ' · ' + + result.diagnostics.platform + + '/' + + result.diagnostics.architecture + + ' · ' + + result.diagnostics.runtime + : ''); + const written = this.writeMessage( + channelId, + value.actor, + 'result-' + result.requestId, + text, + 'work_result', + result, + ); + return written ?? { error: 'OPERATION_CONFLICT' as const }; } - } diff --git a/apps/service/src/work.ts b/apps/service/src/work.ts index 971f5e3..1a7cb3d 100644 --- a/apps/service/src/work.ts +++ b/apps/service/src/work.ts @@ -4,7 +4,12 @@ import { AccessError, admitUser } from './auth/admission'; import { channelContext, operation } from './channels'; import { authenticatedAgent } from './agents'; import { access } from './organizations'; -import { parseWorkInput, parseWorkResult, validRequestId } from '../../../packages/contracts/work'; +import { + parseWorkInput, + parseWorkResult, + parseForwardInput, + validRequestId, +} from '../../../packages/contracts/work'; const limit = bodyLimit({ maxSize: 8000, @@ -61,6 +66,32 @@ export const humanWork = new Hono<{ Bindings: Env }>() ); return c.json(result); }); +async function verifyWorkAccess( + env: Env, + ctx: { + request: { requesterUserId: string }; + requesterMembershipId: string; + organizationId: string; + forwarder?: { ownerUserId: string; ownerMembershipId: string }; + }, +) { + try { + await admitUser(ctx.request.requesterUserId, env); + const member = await access(env, ctx.request.requesterUserId, ctx.organizationId); + if (member.id !== ctx.requesterMembershipId) + throw new AccessError('REQUESTER_ACCESS_DENIED', 403); + if (ctx.forwarder) { + await admitUser(ctx.forwarder.ownerUserId, env); + const issuer = await access(env, ctx.forwarder.ownerUserId, ctx.organizationId); + if (issuer.id !== ctx.forwarder.ownerMembershipId || issuer.role.slug !== 'owner') + throw new AccessError('REQUESTER_ACCESS_DENIED', 403); + } + } catch (e) { + if (e instanceof AccessError && e.status === 403) + throw new AccessError('REQUESTER_ACCESS_DENIED', 403); + throw e; + } +} export const agentWork = new Hono<{ Bindings: Env }>() .use('*', limit) .post('/:channelId/work-requests/:requestId/authorize', async (c) => { @@ -72,20 +103,48 @@ export const agentWork = new Hono<{ Bindings: Env }>() ); const ctx = await channel.agentWorkContext(c.req.param('channelId'), actorId, secretHash, id); if (!ctx) throw new AccessError('WORK_ACCESS_DENIED', 403); - try { - await admitUser(ctx.request.requesterUserId, c.env); - const member = await access(c.env, ctx.request.requesterUserId, ctx.organizationId); - if (member.id !== ctx.requesterMembershipId) - throw new AccessError('REQUESTER_ACCESS_DENIED', 403); - } catch (e) { - if (e instanceof AccessError && e.status === 403) - throw new AccessError('REQUESTER_ACCESS_DENIED', 403); - throw e; - } + await verifyWorkAccess(c.env, ctx); const result = await channel.authorizeWork(c.req.param('channelId'), actorId, secretHash, id); if ('error' in result && result.error) throw new AccessError(result.error, 403); return c.json(result); }) + .post('/:channelId/work-requests/:requestId/forward', async (c) => { + const parentId = checkId(c.req.param('requestId')); + const input = await parse(c.req.raw, parseForwardInput); + const { channel, actorId, secretHash } = await authenticatedAgent( + c.env, + c.req.header('Authorization'), + c.req.param('channelId'), + ); + const ctx = await channel.agentWorkContext( + c.req.param('channelId'), + actorId, + secretHash, + parentId, + ); + if (!ctx) throw new AccessError('WORK_ACCESS_DENIED', 403); + await verifyWorkAccess(c.env, ctx); + const hash = Array.from( + new Uint8Array( + await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(JSON.stringify([c.req.param('channelId'), parentId, 'forward'])), + ), + ), + (b) => b.toString(16).padStart(2, '0'), + ).join(''); + const result = await channel.forwardWork( + c.req.param('channelId'), + actorId, + secretHash, + parentId, + 'wr_' + hash, + input.targetActorId, + ); + if ('error' in result) + throw new AccessError(result.error, result.error === 'OPERATION_CONFLICT' ? 409 : 403); + return c.json(result); + }) .post('/:channelId/work-requests/:requestId/result', async (c) => { const id = checkId(c.req.param('requestId')); const result = await parse(c.req.raw, parseWorkResult); diff --git a/docs/agent-health-check.md b/docs/agent-health-check.md index 3a7be1d..66b0b64 100644 --- a/docs/agent-health-check.md +++ b/docs/agent-health-check.md @@ -6,7 +6,7 @@ A Human organization member selects an active channel Agent and requests one fix `POST /api/organizations/:org/channels/:channel/work-requests` takes an Idempotency-Key and `{targetActorId, capability:"health-check", expiresAt}`. The server derives requester identity from the authenticated Human, binds the current membership ID, and atomically stores the typed message and request in Channel DO SQLite. The expiry must be within ten minutes; the phone chooses five. The operation produces a stable request ID. Identical retry returns the existing request even after expiry; it does not extend its execution deadline. Conflicting payloads fail. -An Agent polls its existing post-join channel history, then calls `POST /api/agent-channels/:channel/work-requests/:request/authorize`. Current Agent credential/issuer qualification, target, requesting Human admission and exact current organization membership are checked. The DO rechecks credential/expiry after provider waits. Removing and rejoining the requester does not reactivate an old request. +An Agent polls its existing post-join channel history, then calls `POST /api/agent-channels/:channel/work-requests/:request/authorize`. Current Agent credential/issuer qualification, target, requesting Human admission and exact current organization membership are checked. The DO rechecks credential/expiry after provider waits. Schema 5 also persists the execution choice; an already forwarded or finished request cannot obtain another execution authorization. See [one-hop forwarding](work-forwarding.md). Removing and rejoining the requester does not reactivate an old request. `POST .../:request/result` accepts a bounded typed result only from the target Agent. It uses a stable server-derived operation and the existing sequence/deduplication authority. A result may report completed work after request expiry; it does not authorize new work. Revoked Agent credentials cannot publish. The Agent remains responsible for truthful results: this is authenticated reporting, not remote hardware attestation. diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md new file mode 100644 index 0000000..30debe7 --- /dev/null +++ b/docs/work-forwarding.md @@ -0,0 +1,39 @@ +# One-hop work forwarding + +T4 begins with a bounded public protocol: an authenticated Agent A can forward a pending health-check request to Agent B in the same channel. This protocol increment does not yet complete the DSH Host/phone tracer bullet. + +## Authority and persistence + +`POST /api/agent-channels/:channel/work-requests/:request/forward` accepts only `{targetActorId}`. The caller must be the original target. Channel DO constructs the child from its canonical parent: original Human user and membership ID, channel, capability, creation time and expiry are unchanged. The child records `delegation: {parentRequestId, actorId, hops: 1}` and its visible author is Agent A. A child cannot forward again or return to A. + +One unique parent column and a server-derived child ID allow only one child. The message and child row commit in the same synchronous SQLite transaction. Identical retries return that child; selecting another target conflicts. Acknowledgement recovery does not extend the deadline. Current issuer/requester checks still apply before reading back a retry. + +Execution authorization now durably selects execution before returning to an Agent. Forwarding and execution cannot both win the same parent. Finished results cannot obtain a later execution authorization. A rejected result also closes the forwarding option. A forwarding parent cannot publish its own execution result. This is a protocol fence, not a claim that arbitrary local effects execute exactly once. + +B must check the original Human's current admission and exact membership, B's current credential/owner, and the forwarding Agent/owner. The DO rechecks local credentials and expiry after external identity waits. B's operator must independently permit the original Human in B's local policy. A's ownership or local permissions do not grant B permission. A's eventual DSH forwarding tool also needs its own explicit local target policy; this increment does not install that tool. + +The reference health runner accepts the typed child only when its visible Agent author matches its delegation. Ordinary messages remain non-executable. B's result cites the child request; the child points to the original parent. Existing mobile history renders the server's readable request/result text; a dedicated forwarding control is not added in this increment. + +## Public CLI + +```sh +node apps/cli/tob.mjs agent-channel forward \ + --credential-file /private/agent-a.json \ + --request wr_... --target ag_... +``` + +The credential is read from an owner-only file. Discovery must match trusted configuration before a bearer is sent. The CLI never accepts a replacement Human, capability, deadline or hop count and checks the returned Agent author/target/parent. Calling it is an explicit operator forwarding action; it is not an automatic unrestricted forwarding loop. + +Configure and run B with the existing `agent-health configure|run` commands and B's original-Human allow-list. Preserve B's private ledger across restarts; replaying a completed child resends no work. Deleting the ledger is not a retry strategy. + +## Migration and rollback + +Channel schema 5 adds nullable unique `parent_request_id` and default-false `execution_authorized`. Existing work and text rows remain. D1/Org schemas stay 5/4. + +After accepting forwarded work, do not downgrade to an older Worker that ignores these fields: it could authorize execution of a forwarded parent. Prefer a forward fix. An emergency downgrade requires stopping the affected Agents and quiescing work requests first; preserving SQLite columns alone is insufficient. This is an authorization/data-contract rollout boundary, even though the SQL migration is additive. + +## Verification boundary + +Local Worker tests exercise one-hop/author/expiry restrictions, canonical replay after DO eviction, execution-versus-forward races, conflicting concurrent targets, atomic rollback, original membership loss/rejoin and revocation during provider waits. Node tests exercise the public CLI/discovery boundary, visible-author parsing, original-Human local policy and local ledger replay. + +Real UAT forwarding and independent-process execution are pending. DSH Messaging/Runtime integration, model-driven A, two isolated Hosts, automatic WebSocket delivery and the complete mobile-to-A-to-B scenario remain pending. Keep T4 open. No Prod or merge. diff --git a/packages/contracts/work.ts b/packages/contracts/work.ts index 85f04a8..279503e 100644 --- a/packages/contracts/work.ts +++ b/packages/contracts/work.ts @@ -7,6 +7,7 @@ export type WorkRequest = { requesterUserId: string; targetActorId: string; capability: 'health-check'; + delegation?: { parentRequestId: string; actorId: string; hops: 1 }; createdAt: string; expiresAt: string; }; @@ -70,6 +71,7 @@ export function parseWorkRequest(value: unknown): WorkRequest { 'capability', 'createdAt', 'expiresAt', + 'delegation', ]) || v.version !== 1 || !validRequestId(v.requestId) || @@ -82,6 +84,21 @@ export function parseWorkRequest(value: unknown): WorkRequest { Date.parse(input.expiresAt) - Date.parse(v.createdAt) > 600000 ) throw new ClientError('INVALID_WORK_INPUT'); + let delegation: WorkRequest['delegation']; + if (v.delegation !== undefined) { + const d = record(v.delegation); + if ( + !exact(d, ['parentRequestId', 'actorId', 'hops']) || + !validRequestId(d.parentRequestId) || + d.parentRequestId === v.requestId || + typeof d.actorId !== 'string' || + !/^ag_[a-f0-9]{64}$/.test(d.actorId) || + d.actorId === input.targetActorId || + d.hops !== 1 + ) + throw new ClientError('INVALID_WORK_INPUT'); + delegation = { parentRequestId: d.parentRequestId, actorId: d.actorId, hops: 1 }; + } return { version: 1, requestId: v.requestId, @@ -89,6 +106,7 @@ export function parseWorkRequest(value: unknown): WorkRequest { requesterUserId: v.requesterUserId, ...input, createdAt: v.createdAt, + ...(delegation ? { delegation } : {}), }; } export function parseWorkResult(value: unknown): WorkResult { @@ -144,3 +162,26 @@ export function parseWorkResult(value: unknown): WorkResult { ...(diagnostics ? { diagnostics } : {}), }; } + +export function parseForwardInput(value: unknown) { + const v = record(value); + if ( + !exact(v, ['targetActorId']) || + typeof v.targetActorId !== 'string' || + !/^ag_[a-f0-9]{64}$/.test(v.targetActorId) + ) + throw new ClientError('INVALID_WORK_INPUT'); + return { targetActorId: v.targetActorId }; +} +export function parseWorkMessage(value: unknown): WorkRequest { + const m = record(value), + r = parseWorkRequest(m.payload); + if ( + m.kind !== 'work_request' || + (r.delegation + ? m.authorType !== 'agent' || m.authorId !== r.delegation.actorId + : m.authorType !== 'human' || m.authorId !== r.requesterUserId) + ) + throw new ClientError('INVALID_WORK_MESSAGE'); + return r; +} diff --git a/packages/reference/health.ts b/packages/reference/health.ts index 66e579e..ecb0bb1 100644 --- a/packages/reference/health.ts +++ b/packages/reference/health.ts @@ -17,6 +17,7 @@ import { readAgentFile, readPrivateJson } from '../../apps/cli/lib/agent.ts'; import { ClientError, connectionKey } from '../contracts/instance.ts'; import { parseWorkRequest, + parseWorkMessage, parseWorkResult, type WorkRequest, type WorkResult, @@ -317,13 +318,8 @@ export async function runHealthOnce(credentialFile: string, directory: string, a processed = 0; for (const m of page.messages) { if (m.kind !== 'work_request') continue; - const r = parseWorkRequest(m.payload); - if ( - m.authorType !== 'human' || - m.authorId !== r.requesterUserId || - r.channelId !== c.channelId - ) - throw new ClientError('INVALID_WORK_MESSAGE'); + const r = parseWorkMessage(m); + if (r.channelId !== c.channelId) throw new ClientError('INVALID_WORK_MESSAGE'); if (r.targetActorId !== c.actorId) continue; const result = await processHealthRequest(ledger, r, policy, { authorize: async (req) => diff --git a/tests/forward.node.test.ts b/tests/forward.node.test.ts new file mode 100644 index 0000000..a2b728f --- /dev/null +++ b/tests/forward.node.test.ts @@ -0,0 +1,210 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { publicInstance } from '../packages/contracts/instance.ts'; +import { + parseWorkMessage, + parseWorkRequest, + type WorkRequest, +} from '../packages/contracts/work.ts'; +import { agentCommand } from '../apps/cli/lib/agent.ts'; +import { HealthLedger, processHealthRequest } from '../packages/reference/health.ts'; +const a = 'ag_' + 'a'.repeat(64), + b = 'ag_' + 'b'.repeat(64), + channel = 'ch_' + 'c'.repeat(64), + parent = 'wr_' + 'd'.repeat(64); +const request: WorkRequest = { + version: 1, + requestId: 'wr_' + 'e'.repeat(64), + channelId: channel, + requesterUserId: 'user_original', + targetActorId: b, + capability: 'health-check', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300000).toISOString(), + delegation: { parentRequestId: parent, actorId: a, hops: 1 }, +}; +const message = { kind: 'work_request', authorType: 'agent', authorId: a, payload: request }; +test('forwarded work distinguishes remote Agent authorship from the original Human', () => { + assert.deepEqual(parseWorkMessage(message), request); + for (const m of [ + { ...message, authorType: 'human' }, + { ...message, authorId: b }, + { ...message, kind: 'text' }, + { ...message, payload: { ...request, delegation: undefined } }, + ]) + assert.throws(() => parseWorkMessage(m)); + for (const d of [ + { ...request.delegation, hops: 2 }, + { ...request.delegation, parentRequestId: request.requestId }, + { ...request.delegation, actorId: b }, + { ...request.delegation, scope: 'shell' }, + ]) + assert.throws(() => parseWorkRequest({ ...request, delegation: d })); +}); +test('local health policy checks the original user, not the forwarding Agent owner', async () => { + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'tob-forward-policy-')); + const ledger = new HealthLedger(dir, 'a'.repeat(64)); + let auth = 0; + const sent: unknown[] = []; + try { + const policy = { + version: 1 as const, + binding: 'a'.repeat(64), + actorId: b, + channelId: channel, + environment: 'b', + allowedUsers: ['user_forwarderOwner'], + }; + const result = await processHealthRequest(ledger, request, () => policy, { + authorize: async (r) => { + auth++; + return r; + }, + send: async (r) => { + sent.push(r); + }, + }); + assert.equal(result.executed, 0); + assert.equal(auth, 0); + assert.deepEqual(sent, [ + { + version: 1, + requestId: request.requestId, + state: 'rejected', + environment: 'b', + executionCount: 0, + reason: 'LOCAL_POLICY_DENIED', + }, + ]); + } finally { + ledger.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); +test('forwarded work authorized for its original user executes once across a fresh local ledger process', async () => { + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'tob-forward-execute-')); + let ledger = new HealthLedger(dir, 'a'.repeat(64)); + let authorizations = 0, + sends = 0; + const policy = { + version: 1 as const, + binding: 'a'.repeat(64), + actorId: b, + channelId: channel, + environment: 'b', + allowedUsers: ['user_original'], + }; + const transport = { + authorize: async (r: WorkRequest) => { + authorizations++; + return r; + }, + send: async () => { + sends++; + }, + }; + try { + assert.equal( + (await processHealthRequest(ledger, request, () => policy, transport)).executed, + 1, + ); + ledger.close(); + ledger = new HealthLedger(dir, 'a'.repeat(64)); + assert.equal( + (await processHealthRequest(ledger, request, () => policy, transport)).executed, + 0, + ); + assert.equal(authorizations, 1); + assert.equal(sends, 1); + } finally { + ledger.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); +test('CLI forwards only target and parent through trusted discovery and validates the returned author', async () => { + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'tob-forward-cli-')); + const file = join(dir, 'credential.json'); + const original = globalThis.fetch; + const config = publicInstance({ + instanceId: 'test-instance', + environment: 'uat', + server: 'https://groups.example', + clientId: 'client_fixture', + issuer: 'https://api.workos.com/user_management/client_fixture', + authkitDomain: 'fixture.authkit.app', + }); + writeFileSync( + file, + JSON.stringify({ + version: 1, + config, + organizationId: 'org_fixture', + channelId: channel, + actorId: a, + ownerUserId: 'user_owner', + name: 'A', + operationId: 'agent-operation-0001', + secret: 'f'.repeat(64), + }), + { mode: 0o600 }, + ); + const args = [ + 'agent-channel', + 'forward', + '--credential-file', + file, + '--request', + parent, + '--target', + b, + ]; + let calls = 0, + badAuthor = false, + changedDiscovery = false; + globalThis.fetch = async (input, init) => { + calls++; + assert.equal(init?.redirect, 'error'); + if (String(input).endsWith('/.well-known/open-cloud-groups')) { + assert.equal(new Headers(init?.headers).has('Authorization'), false); + return Response.json({ + ...config, + ...(changedDiscovery ? { instanceId: 'changed-instance' } : {}), + }); + } + assert.equal( + String(input), + config.server + '/api/agent-channels/' + channel + '/work-requests/' + parent + '/forward', + ); + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { targetActorId: b }); + assert.equal( + new Headers(init?.headers).get('Authorization'), + 'Bearer tob_agent_' + a + '.' + 'f'.repeat(64), + ); + return Response.json({ + message: badAuthor ? { ...message, authorId: b } : message, + replayed: false, + }); + }; + try { + assert.deepEqual(await agentCommand(args), { message, replayed: false }); + assert.equal(calls, 2); + badAuthor = true; + await assert.rejects(agentCommand(args), { code: 'INVALID_WORK_MESSAGE' }); + calls = 0; + changedDiscovery = true; + await assert.rejects(agentCommand(args), { code: 'INSTANCE_CONFIG_CHANGED' }); + assert.equal(calls, 1); + calls = 0; + await assert.rejects(agentCommand([...args, '--expires-at', request.expiresAt]), { + code: 'USAGE', + }); + assert.equal(calls, 0); + } finally { + globalThis.fetch = original; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index ff4c44c..e1cf7b8 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: 4, businessEnabled: true }); + expect(await ready.json()).toMatchObject({ ready: true, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 5, 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(); }); @@ -821,3 +821,104 @@ it('revocation during work authorization yields no new start approval or result' expect((await f.workAgent(id)).status).toBe(403); expect((await f.workAgent(id,'result',{version:1,requestId:id,state:'rejected',environment:'reference',executionCount:0,reason:'LOCAL_POLICY_DENIED'})).status).toBe(403); }); + +async function forwardingFixture() { + const f = await workFixture(); + f.members.push({id:'om_member',user_id:'user_b',organization_id:'org_invites',status:'active',role:{slug:'member'},created_at:'2026-01-01T00:00:00Z'}); + const b = await (await f.issueAgent('Target B','forward-target-agent-0001')).json() as {actorId:string}; + const c = await (await f.issueAgent('Target C','forward-target-agent-0002')).json() as {actorId:string}; + const root = await (await f.create(f.input,'forward-root-operation-0001','user_b')).json() as {message:{payload:import('../packages/contracts/work').WorkRequest}}; + const rootId = root.message.payload.requestId; + const forward = (targetActorId=b.actorId, id=rootId, actorId=f.a.actorId, extra={})=> f.workAgent(id,'forward',{targetActorId,...extra},actorId); + return {...f,b,c,rootId,root:root.message.payload,forward}; +} +it('forwarding preserves the original member and deadline, survives eviction and commits one correlated child',async()=>{ + const f=await forwardingFixture(); + const response=await f.forward();expect(response.status).toBe(200); + const value=await response.json() as {message:{payload:import('../packages/contracts/work').WorkRequest}}; + const child=value.message.payload; + expect(value).toMatchObject({replayed:false,message:{sequence:2,authorType:'agent',authorId:f.a.actorId,kind:'work_request'}}); + expect(child).toEqual({...f.root,requestId:child.requestId,targetActorId:f.b.actorId,delegation:{parentRequestId:f.rootId,actorId:f.a.actorId,hops:1}}); + expect(child.requestId).not.toBe(f.rootId); + await evictDurableObject(env.CHANNELS.getByName(f.channel.channelId)); + expect(await(await f.forward()).json()).toMatchObject({replayed:true,message:{sequence:2,payload:child}}); + expect((await f.forward(f.c.actorId)).status).toBe(409); + expect(await(await f.workAgent(f.rootId)).json()).toEqual({error:{code:'WORK_ALREADY_FORWARDED'}}); + expect(await(await f.workAgent(child.requestId,'authorize',undefined,f.b.actorId)).json()).toEqual({request:child}); + expect(await(await f.forward(f.c.actorId,child.requestId,f.b.actorId)).json()).toEqual({error:{code:'FORWARD_LIMIT'}}); + const result={version:1,requestId:child.requestId,state:'completed',environment:'target-b',executionCount:1,reason:'OK',diagnostics:{platform:'linux',architecture:'x64',runtime:'v24.21.0'}}; + expect(await(await f.workAgent(child.requestId,'result',result,f.b.actorId)).json()).toMatchObject({message:{sequence:3,authorId:f.b.actorId,payload:result}}); + expect((await f.workAgent(f.rootId,'result',{...result,requestId:f.rootId})).status).toBe(403); + expect(await(await f.workAgent(child.requestId,'result',result,f.b.actorId)).json()).toMatchObject({replayed:true,message:{sequence:3}}); +}); +it('forwarding refuses fabricated source or deadlines, self loops and non-target Agents',async()=>{ + const f=await forwardingFixture(); + for(const extra of [{requesterUserId:'user_admin'},{expiresAt:new Date(Date.now()+600000).toISOString()},{capability:'shell'},{delegation:{hops:0}}])expect((await f.forward(f.b.actorId,f.rootId,f.a.actorId,extra)).status).toBe(400); + expect((await f.forward(f.b.actorId,f.rootId,f.c.actorId)).status).toBe(403); + expect(await(await f.forward(f.a.actorId)).json()).toEqual({error:{code:'FORWARD_LIMIT'}}); + expect((await f.forward('ag_'+'f'.repeat(64))).status).toBe(403); + expect(await(await f.call(f.base+'/messages')).json()).toMatchObject({messages:[{sequence:1}]}); +}); +it('execution authorization wins against forwarding, while forwarding wins against execution authorization',async()=>{ + const f=await forwardingFixture(); + const [authorization,forward]=await Promise.all([f.workAgent(f.rootId),f.forward()]); + expect([authorization.status,forward.status].sort()).toEqual([200,403]); + await evictDurableObject(env.CHANNELS.getByName(f.channel.channelId)); + if(authorization.status===200)expect(await(await f.forward()).json()).toEqual({error:{code:'WORK_ALREADY_STARTED'}}); + else expect(await(await f.workAgent(f.rootId)).json()).toEqual({error:{code:'WORK_ALREADY_FORWARDED'}}); +}); +it('an Agent rejection is terminal for forwarding even without an execution authorization',async()=>{ + const f=await forwardingFixture(); + expect((await f.workAgent(f.rootId,'result',{version:1,requestId:f.rootId,state:'rejected',environment:'a',executionCount:0,reason:'LOCAL_POLICY_DENIED'})).status).toBe(200); + expect(await(await f.forward()).json()).toEqual({error:{code:'WORK_ALREADY_STARTED'}}); +}); +it('the original membership stays authoritative after forwarding and cannot be replaced by a rejoin',async()=>{ + const f=await forwardingFixture(); + const v=await(await f.forward()).json() as {message:{payload:{requestId:string}}}; + f.members[1].status='inactive'; + expect(await(await f.workAgent(v.message.payload.requestId,'authorize',undefined,f.b.actorId)).json()).toEqual({error:{code:'REQUESTER_ACCESS_DENIED'}}); + f.members[1].status='active';f.members[1].id='om_new'; + expect((await f.workAgent(v.message.payload.requestId,'authorize',undefined,f.b.actorId)).status).toBe(403); +}); +it('revoking the forwarding Agent while the target checks identity prevents execution authorization',async()=>{ + const f=await forwardingFixture(); + const v=await(await f.forward()).json() as {message:{payload:{requestId:string}}}; + const provider=vi.mocked(globalThis.fetch).getMockImplementation()!;let checks=0; + vi.mocked(globalThis.fetch).mockImplementation(async(input,init)=>{ + if(String(input).includes('/organization_memberships?')&&++checks===2) 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'},f.a.actorId); + return provider(input,init); + }); + expect((await f.workAgent(v.message.payload.requestId,'authorize',undefined,f.b.actorId)).status).toBe(403); +}); +it('expiration during forwarding identity checks commits no child',async()=>{ + const f=await forwardingFixture();const provider=vi.mocked(globalThis.fetch).getMockImplementation()!;let checks=0; + vi.mocked(globalThis.fetch).mockImplementation(async(input,init)=>{ + if(String(input).includes('/organization_memberships?')&&++checks===2)await runInDurableObject(env.CHANNELS.getByName(f.channel.channelId),(_i,s)=>{s.storage.sql.exec("UPDATE channel_message SET payload=json_set(payload,'$.expiresAt','2000-01-01T00:00:00.000Z') WHERE kind='work_request'");}); + return provider(input,init); + }); + expect(await(await f.forward()).json()).toEqual({error:{code:'REQUEST_EXPIRED'}}); + expect(await(await f.call(f.base+'/messages')).json()).toMatchObject({messages:[{sequence:1}]}); +}); +it('a finished result cannot obtain a later execution authorization',async()=>{ + const f=await workFixture();const v=await(await f.create()).json() as {message:{payload:{requestId:string}}};const id=v.message.payload.requestId; + expect((await f.workAgent(id,'result',{version:1,requestId:id,state:'rejected',environment:'a',executionCount:0,reason:'LOCAL_POLICY_DENIED'})).status).toBe(200); + expect(await(await f.workAgent(id)).json()).toEqual({error:{code:'WORK_ALREADY_FINISHED'}}); +}); +it('competing target selections produce only one child and a failed child write leaves no message',async()=>{ + const f=await forwardingFixture(); + await runInDurableObject(env.CHANNELS.getByName(f.channel.channelId),(_i,s)=>{s.storage.sql.exec("CREATE TRIGGER fail_forward BEFORE INSERT ON channel_work WHEN NEW.parent_request_id IS NOT NULL BEGIN SELECT RAISE(ABORT,'INJECTED_FORWARD_FAILURE'); END");}); + expect((await f.forward()).status).toBe(503); + expect(await(await f.call(f.base+'/messages')).json()).toMatchObject({messages:[{sequence:1}]}); + await runInDurableObject(env.CHANNELS.getByName(f.channel.channelId),(_i,s)=>{s.storage.sql.exec('DROP TRIGGER fail_forward');}); + const responses=await Promise.all([f.forward(f.b.actorId),f.forward(f.c.actorId)]); + expect(responses.map(r=>r.status).sort()).toEqual([200,409]); + const page=await(await f.call(f.base+'/messages')).json() as {messages:unknown[]};expect(page.messages).toHaveLength(2); +}); +it('a target owned by another active owner cannot borrow a revoked forwarding owner membership',async()=>{ + const f=await forwardingFixture(); + f.members.push({id:'om_other_owner',user_id:'user_c',organization_id:'org_invites',status:'active',role:{slug:'owner'},created_at:'2026-01-01T00:00:00Z'}); + const other=await(await f.call(f.base+'/agents','user_c','POST',{name:'C owner target',secretHash:f.secretHash},'other-owner-agent-0001')).json() as {actorId:string}; + const v=await(await f.forward(other.actorId)).json() as {message:{payload:{requestId:string}}}; + f.members[0].role.slug='member'; + expect(await(await f.workAgent(v.message.payload.requestId,'authorize',undefined,other.actorId)).json()).toEqual({error:{code:'REQUESTER_ACCESS_DENIED'}}); +}); diff --git a/tools/deployment/smoke.mjs b/tools/deployment/smoke.mjs index 46ded42..12fab56 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: 4, businessEnabled: true }); + assert.deepEqual(ready.body, { ready: true, releaseSha: target.releaseSha, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 5, businessEnabled: true }); const refusal = await get('/api/organizations', 'POST'); assert.equal(refusal.status, 401); assert.equal(refusal.body.error.code, 'UNAUTHENTICATED'); From e7ee2aeef6b5ec9ab1741e6a8bf63e5043574547 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 11:20:57 +0900 Subject: [PATCH 2/7] docs: record UAT one-hop forwarding and independent execution proof Issue: #5 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- docs/evidence/work-forwarding-uat.json | 13 +++++++++++++ docs/multi-user-collaboration.md | 4 ++++ docs/work-forwarding.md | 8 ++++++++ 3 files changed, 25 insertions(+) create mode 100644 docs/evidence/work-forwarding-uat.json diff --git a/docs/evidence/work-forwarding-uat.json b/docs/evidence/work-forwarding-uat.json new file mode 100644 index 0000000..65cb501 --- /dev/null +++ b/docs/evidence/work-forwarding-uat.json @@ -0,0 +1,13 @@ +{ + "releaseSha": "864b16d85a055397e255d012042bec0b07a9964e", + "originalHumanPreserved": true, + "deadlineAndCapabilityUnchanged": true, + "singleChildReplay": true, + "independentProcessCompletedOnce": true, + "freshProcessReplayExecutedZero": true, + "parentExecutionRefused": true, + "secondHopRefused": true, + "localPolicyCheckedOriginalHuman": true, + "exactSixMessages": true, + "bothTestCredentialsRevoked": true +} \ No newline at end of file diff --git a/docs/multi-user-collaboration.md b/docs/multi-user-collaboration.md index 2b12ee2..399fc74 100644 --- a/docs/multi-user-collaboration.md +++ b/docs/multi-user-collaboration.md @@ -60,3 +60,7 @@ This completes the narrow HTTP channel sub-slice, not T1/T2 or the overall goal. ## Fixed work request continuation The channel credential/echo sub-slice is qualified in UAT and the phone. Next, [fixed health checks](agent-health-check.md) separate authenticated message requests from operator-granted local work. The initial check has a durable execution/result ledger and no arbitrary shell command. Phone/CLI and native UAT gates precede extending this owner into the two-isolated-Host path; WebSocket and group-channel bridge semantics remain separate. + +## One-hop protocol qualified; DSH Host integration next + +[Draft #24](https://github.com/BotHarness/tob/pull/24) adds [bounded forwarding](work-forwarding.md). UAT runtime864b16d preserved the original member/deadline/capability across A→B, returned B's result to the same channel, rejected local permission loss and second hops, and replayed without another execution. The cloud durably chooses execution or forwarding per parent. Public CLI/independent processes are qualified; DSH Messaging/Runtime binding, real model A and two isolated Hosts remain next. Do not count this preparation as completed T4 or the overall goal. diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md index 30debe7..3f67390 100644 --- a/docs/work-forwarding.md +++ b/docs/work-forwarding.md @@ -37,3 +37,11 @@ After accepting forwarded work, do not downgrade to an older Worker that ignores Local Worker tests exercise one-hop/author/expiry restrictions, canonical replay after DO eviction, execution-versus-forward races, conflicting concurrent targets, atomic rollback, original membership loss/rejoin and revocation during provider waits. Node tests exercise the public CLI/discovery boundary, visible-author parsing, original-Human local policy and local ledger replay. Real UAT forwarding and independent-process execution are pending. DSH Messaging/Runtime integration, model-driven A, two isolated Hosts, automatic WebSocket delivery and the complete mobile-to-A-to-B scenario remain pending. Keep T4 open. No Prod or merge. + +## Real UAT protocol qualification — 2026-10-18 JST + +Runtime `864b16d85a055397e255d012042bec0b07a9964e`, Worker `bc7ed0f3-6409-4a1e-8680-a56bb1a594c2`, schemas D1/Org/Channel 5/4/5 passed guarded deployment smoke. Exact-runtime CI [39089599338](https://github.com/BotHarness/tob/actions/runs/39089599338) passed. + +Using the existing synthetic ordinary users and public CLI, the member submitted a request to A; a separate CLI process forwarded it to B; another Node process executed B's fixed check and returned its result. Original Human, capability and deadline matched the parent. An identical forward returned the same child. A fresh B process replay executed zero additional checks. A's parent execution and B's second hop were refused. A second request whose original Human was excluded by B's local policy produced `LOCAL_POLICY_DENIED` with zero execution. CLI readback found exactly six correlated messages (two parents, two children, two results). Both test credentials were revoked and confirmed unusable; the users' prior organization selections were restored. + +[Sanitized assertions](evidence/work-forwarding-uat.json) record this bounded result. No WorkOS registration/settings changes, Human account changes, new native window, Prod or merge. This is public-protocol/independent-process evidence, **not** two DSH Hosts, model-driven forwarding or a new mobile UI run. T4 remains active for those owning-module/runtime seams. From 3eeed5b05782276b1a02cc2412dd04ab0ec60bca Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 11:34:34 +0900 Subject: [PATCH 3/7] Validate Agent connection identity through public API and CLI Issue: #5 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- apps/cli/lib/agent.ts | 11 +++++++---- apps/service/src/agents.ts | 7 +++++++ docs/work-forwarding.md | 4 ++++ tests/cli.node.test.ts | 7 +++++++ tests/service.test.ts | 11 +++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/cli/lib/agent.ts b/apps/cli/lib/agent.ts index caf183a..bc19d71 100644 --- a/apps/cli/lib/agent.ts +++ b/apps/cli/lib/agent.ts @@ -26,14 +26,16 @@ export const readAgentFile = (path: string) => parseAgentCredential(readPrivateJ export async function agentCommand(args: string[], signal?: AbortSignal) { const command = args[1]; const options: Record = {}; - if (!['history', 'send', 'forward'].includes(command)) throw new ClientError('USAGE'); + if (!['history', 'send', 'forward', 'identity'].includes(command)) throw new ClientError('USAGE'); for (let n = 2; n < args.length; n += 2) { if ( !( command === 'forward' ? ['--credential-file', '--request', '--target'] - : command === 'history' - ? ['--credential-file', '--after'] + : command === 'identity' + ? ['--credential-file'] + : command === 'history' + ? ['--credential-file', '--after'] : ['--credential-file', '--text-file', '--operation'] ).includes(args[n]) || args[n + 1] === undefined || @@ -57,7 +59,8 @@ export async function agentCommand(args: string[], signal?: AbortSignal) { }; const base = config.server + '/api/agent-channels/' + channelId + '/messages'; let response: Response; - if (command === 'forward') + if (command === 'identity') response = await request(config.server + '/api/agent-channels/' + channelId + '/identity', { headers }, signal); + else if (command === 'forward') response = await request( config.server + '/api/agent-channels/' + diff --git a/apps/service/src/agents.ts b/apps/service/src/agents.ts index 0e5dbd1..c70b92d 100644 --- a/apps/service/src/agents.ts +++ b/apps/service/src/agents.ts @@ -55,6 +55,13 @@ export async function authenticatedAgent(env: Env, header: string | undefined, c } export const agentMessages = new Hono<{ Bindings: Env }>() .use("*", limit) + .get("/:channelId/identity", async c => { + const { channel, actorId, secretHash } = await authenticatedAgent(c.env, c.req.header("Authorization"), c.req.param("channelId")); + // Issuer checks may yield. Re-read the local grant before exposing its identity. + const ctx = await channel.agentContext(c.req.param("channelId"), actorId, secretHash); + if (!ctx) throw new AccessError("AGENT_ACCESS_DENIED", 403); + return c.json({ channelId: c.req.param("channelId"), organizationId: ctx.organizationId, agent: ctx.agent }); + }) .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); diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md index 3f67390..805f7cc 100644 --- a/docs/work-forwarding.md +++ b/docs/work-forwarding.md @@ -14,6 +14,10 @@ B must check the original Human's current admission and exact membership, B's cu The reference health runner accepts the typed child only when its visible Agent author matches its delegation. Ordinary messages remain non-executable. B's result cites the child request; the child points to the original parent. Existing mobile history renders the server's readable request/result text; a dedicated forwarding control is not added in this increment. +## Connection identity + +`GET /api/agent-channels/:channel/identity` authenticates the Agent and its current issuing owner, then rechecks the local grant. It returns the canonical channel, organization and public Agent identity (including owner, history floor and expiry), never the credential or its hash. Host import must compare this with the selected credential before binding; caller-authored file labels alone are not identity evidence. + ## Public CLI ```sh diff --git a/tests/cli.node.test.ts b/tests/cli.node.test.ts index 8aa703c..3e8e5ed 100644 --- a/tests/cli.node.test.ts +++ b/tests/cli.node.test.ts @@ -269,6 +269,13 @@ test('Agent create persists a private stable credential before network and retri return original(url,init); }; assert.deepEqual(await execute(['agent-channel','history','--credential-file',result.credentialFile],rt),{messages:[],nextCursor:4,hasMore:false}); + const identity={channelId,organizationId:credential.organizationId,agent:{actorId:credential.actorId,ownerUserId:credential.ownerUserId,kind:'agent',state:'active',name:credential.name,historyAfter:0}}; + globalThis.fetch=async(url,init)=>{ + if(String(url).endsWith('/identity')){assert.equal(String(url),config.server+'/api/agent-channels/'+channelId+'/identity');assert.equal(new Headers(init?.headers).get('Authorization'),'Bearer tob_agent_'+credential.actorId+'.'+credential.secret);return Response.json(identity);} + return original(url,init); + }; + assert.deepEqual(await execute(['agent-channel','identity','--credential-file',result.credentialFile],rt),identity); + await fails(execute(['agent-channel','identity','--credential-file',result.credentialFile,'--after','1'],rt),'USAGE'); 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'); diff --git a/tests/service.test.ts b/tests/service.test.ts index e1cf7b8..2834b2f 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -922,3 +922,14 @@ it('a target owned by another active owner cannot borrow a revoked forwarding ow f.members[0].role.slug='member'; expect(await(await f.workAgent(v.message.payload.requestId,'authorize',undefined,other.actorId)).json()).toEqual({error:{code:'REQUESTER_ACCESS_DENIED'}}); }); + +it('Agent identity discovery reports canonical channel and actor without exposing credentials or accepting another channel',async()=>{ + const f=await agentFixture();const a=await(await f.issueAgent()).json() as {actorId:string}; + const call=(channel=f.channel.channelId)=>request('/api/agent-channels/'+channel+'/identity',{headers:{Authorization:'Bearer tob_agent_'+a.actorId+'.'+f.secret}}); + const response=await call();expect(response.status).toBe(200); + const identity=await response.json();expect(identity).toMatchObject({channelId:f.channel.channelId,organizationId:'org_invites',agent:{actorId:a.actorId,ownerUserId:'user_a',state:'active',kind:'agent'}}); + expect(JSON.stringify(identity)).not.toContain(f.secret);expect(JSON.stringify(identity)).not.toContain(f.secretHash); + expect((await call(f.sibling.channelId)).status).toBe(403); + await f.call(f.base+'/agents/'+a.actorId+'/revoke','user_a','POST'); + expect((await call()).status).toBe(403); +}); From 7037e43cf88fb92e2133f781554c787105b84bb6 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 11:52:36 +0900 Subject: [PATCH 4/7] Record real UAT Agent identity qualification Issue: #5 Agent-Task: codex/local/01a148ae-ec7f-74b2-8157-961411ce54c9 --- docs/evidence/agent-identity-uat.json | 9 +++++++++ docs/work-forwarding.md | 2 ++ 2 files changed, 11 insertions(+) create mode 100644 docs/evidence/agent-identity-uat.json diff --git a/docs/evidence/agent-identity-uat.json b/docs/evidence/agent-identity-uat.json new file mode 100644 index 0000000..ec065ee --- /dev/null +++ b/docs/evidence/agent-identity-uat.json @@ -0,0 +1,9 @@ +{ + "releaseSha": "3eeed5b05782276b1a02cc2412dd04ab0ec60bca", + "independentChannelAgents": 2, + "canonicalIdentityVerified": true, + "ownerAndOrganizationVerified": true, + "cliCredentialFilesRemainPrivate": true, + "hostImportAndCommunication": "pending", + "credentials": "retained only for upcoming isolated UAT Hosts" +} diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md index 805f7cc..d05ec1a 100644 --- a/docs/work-forwarding.md +++ b/docs/work-forwarding.md @@ -49,3 +49,5 @@ Runtime `864b16d85a055397e255d012042bec0b07a9964e`, Worker `bc7ed0f3-6409-4a1e-8 Using the existing synthetic ordinary users and public CLI, the member submitted a request to A; a separate CLI process forwarded it to B; another Node process executed B's fixed check and returned its result. Original Human, capability and deadline matched the parent. An identical forward returned the same child. A fresh B process replay executed zero additional checks. A's parent execution and B's second hop were refused. A second request whose original Human was excluded by B's local policy produced `LOCAL_POLICY_DENIED` with zero execution. CLI readback found exactly six correlated messages (two parents, two children, two results). Both test credentials were revoked and confirmed unusable; the users' prior organization selections were restored. [Sanitized assertions](evidence/work-forwarding-uat.json) record this bounded result. No WorkOS registration/settings changes, Human account changes, new native window, Prod or merge. This is public-protocol/independent-process evidence, **not** two DSH Hosts, model-driven forwarding or a new mobile UI run. T4 remains active for those owning-module/runtime seams. + +The identity endpoint was deployed and read through the formal CLI in UAT at `3eeed5b05782276b1a02cc2412dd04ab0ec60bca` (Worker `02beb5ec-1804-468d-9318-89e06467377c`). Two distinct channel-scoped Agents returned their expected actor, owner, organization and channel; private credential files were retained solely for the upcoming isolated Host gate. See `docs/evidence/agent-identity-uat.json`. The first preparation attempt omitted the CLI library runtime argument; retry reused the already recorded operation IDs. This is identity qualification, not two-Host communication or a repeat of the earlier forwarding runtime gate. From b0aec6659977260ddb087a90fe3ea3a6a6fd727c Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 12:51:30 +0900 Subject: [PATCH 5/7] docs: clarify channel invitations grant no organization membership --- docs/channel-guests.md | 4 ++++ docs/multi-user-collaboration.md | 2 +- docs/spec.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/channel-guests.md b/docs/channel-guests.md index 5f1aeb2..df86a34 100644 --- a/docs/channel-guests.md +++ b/docs/channel-guests.md @@ -58,3 +58,7 @@ Runtime `6226ec3af48d7c446c95421290aaa280ce8a7490`, Worker version `b8e22111-d97 [DeepSeekBot Draft #2150](https://github.com/BotHarness/DeepSeekBot/pull/2150) final runtime `bc08a47e6f060fe6caa6b879c4b2464e12d69e05`: phone owner invited B, B accepted without organization membership/selection change and sent a message read by owner CLI. Rejoin used a new floor; refresh/cold restart preserved access only to the new message. Revocation refused the old phone session and independent CLI. Owner phone also revoked an accepted grant at maximum text size. Six form and composer size/theme cases passed after correcting title and keyboard defects. Independent phone logout revoked only its own session; CLI sessions were retained. The linked PR evidence records intermediate runtime provenance and first failures. This is synthetic-user iOS Simulator proof, not Hosted AuthKit, email delivery, Android, physical phone or Human QA. Shared Simulator resources were restored and released. + +## Confirmed invitation scope + +The Human clarified that a channel-level invitation admits its recipient only to that channel. It does not grant organization membership, organization directory access or access to other channels. “Invite group channel” is not a group-to-group bridge or federation requirement. Existing organization members still see all organization channels; the guest grant is the only additional scope needed for this slice. diff --git a/docs/multi-user-collaboration.md b/docs/multi-user-collaboration.md index 399fc74..cda919f 100644 --- a/docs/multi-user-collaboration.md +++ b/docs/multi-user-collaboration.md @@ -8,7 +8,7 @@ This supersedes the admin-only application admission restriction in T1. The prot 4. Invite independently authenticated Agents; show identities and allow bounded message interaction. A Bot owner's explicit local capability policy continues to govern work. 5. Two isolated local DSH Hosts use the public Cloudflare UAT endpoint to exchange messages/work/results. Prove cloud persistence, independent identities, reconnect replay and non-duplicate execution. This establishes cross-Host cloud transport, not physical hardware/network diversity. -The meaning of inviting a "group channel" into another channel is awaiting Human clarification. Do not implement federation or bridging implicitly. +The Human confirmed that "group channel invitation" means inviting a recipient only to the selected channel. Acceptance must not create organization membership or reveal sibling channels. This is the existing direct-channel guest scope, not federation or a group-to-group bridge. ## First slice contract diff --git a/docs/spec.md b/docs/spec.md index f16760a..94b13ba 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -130,7 +130,7 @@ flowchart TB - D1 只存应用登记和可靠同步,不为每条消息或每个接收者写一条身份查询记录。Secrets 留在部署配置;跨实例令牌/密钥不混用。 - WorkOS organization id 与应用 org_id 一一映射;客户端 active organization 只是导航状态。服务端验证访问令牌、目标 org 和 Channel 的归属,不能由 URL 参数赋予资格。 -- 最新协作目标:活跃组织成员可见该组织全部频道,不先做频道可见性矩阵。直接受邀的外部频道访客只获得该频道资格,不因此成为整个组织成员。目录与历史均分页;不先建设全局搜索。 +- 最新协作目标:活跃组织成员可见该组织全部频道,不先做频道可见性矩阵。直接受邀的外部频道访客只获得该频道资格,不因此成为整个组织成员,也看不到该组织其他频道。用户已确认“单独的 channel 邀请 group channel”仅指这一频道层级邀请,不指群与群互联。目录与历史均分页;不先建设全局搜索。 - actor_id 与显示名分离;不同主机上的同名 Bot 是不同身份。首版外部 Agent 是群限定身份,不建立全局 Agent 应用商店。 - message 的 kind 区分文本、工作请求、结果和系统事件。系统事件记录成员/凭证变更但不泄密;消息作者派生自受信上下文。 - read_from_seq 默认加入后的下一条序号。同一 Human 换设备继承本人历史;新 Agent/新 Human 默认不读旧历史。重新加入不自动恢复旧权限。显式授予旧历史后续实现。 From 0da66fbd2719931d963170607d7630230ed0e1cc Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 13:02:17 +0900 Subject: [PATCH 6/7] docs: record native two-Host UAT work and replay boundary --- docs/work-forwarding.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md index d05ec1a..f8769f9 100644 --- a/docs/work-forwarding.md +++ b/docs/work-forwarding.md @@ -51,3 +51,9 @@ Using the existing synthetic ordinary users and public CLI, the member submitted [Sanitized assertions](evidence/work-forwarding-uat.json) record this bounded result. No WorkOS registration/settings changes, Human account changes, new native window, Prod or merge. This is public-protocol/independent-process evidence, **not** two DSH Hosts, model-driven forwarding or a new mobile UI run. T4 remains active for those owning-module/runtime seams. The identity endpoint was deployed and read through the formal CLI in UAT at `3eeed5b05782276b1a02cc2412dd04ab0ec60bca` (Worker `02beb5ec-1804-468d-9318-89e06467377c`). Two distinct channel-scoped Agents returned their expected actor, owner, organization and channel; private credential files were retained solely for the upcoming isolated Host gate. See `docs/evidence/agent-identity-uat.json`. The first preparation attempt omitted the CLI library runtime argument; retry reused the already recorded operation IDs. This is identity qualification, not two-Host communication or a repeat of the earlier forwarding runtime gate. + +## Native two-Host follow-up — 2026-10-18 JST + +DeepSeekBot Draft [#2157](https://github.com/BotHarness/DeepSeekBot/pull/2157), runtime `f02c2423bce02c264548193ac738013f4eac83ab`, qualified native model A→B fixed health work against unchanged UAT service `3eeed5b`. Two distinct local DSH Profiles used their own Bots, native Credentials and Operational Databases. A inspected/forwarded the accepted Source Event; B inspected/executed it and returned a result to the same cloud channel. Three canonical messages retained the original Human and deadline; A executed zero times, B once. Both Hosts restarted, and formal direct Tool replay returned the same child and zero additional executions/messages. Local work policies were restored. + +The first model request executed locally on A, so it is not counted as forwarding. A new request after an explicit dispatcher instruction passed. A fresh mobile→A→B run, WebSocket, organization-wide Agent membership and broader permission/revocation paths remain separate gates; T4 is not closed. This is two real Hosts on one machine, not proof of network or hardware diversity. No service redeployment, Prod change or merge accompanied this follow-up. From 2a56b4ec17f01f50157f2d5ca1d3e0d1fee9b54e Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 18 Oct 2026 13:32:07 +0900 Subject: [PATCH 7/7] docs: record phone to two native Hosts UAT qualification Refs #5; evidence-only follow-up, no service deployment or merge. --- docs/multi-user-collaboration.md | 4 ++++ docs/work-forwarding.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/multi-user-collaboration.md b/docs/multi-user-collaboration.md index cda919f..aab9f5b 100644 --- a/docs/multi-user-collaboration.md +++ b/docs/multi-user-collaboration.md @@ -64,3 +64,7 @@ The channel credential/echo sub-slice is qualified in UAT and the phone. Next, [ ## One-hop protocol qualified; DSH Host integration next [Draft #24](https://github.com/BotHarness/tob/pull/24) adds [bounded forwarding](work-forwarding.md). UAT runtime864b16d preserved the original member/deadline/capability across A→B, returned B's result to the same channel, rejected local permission loss and second hops, and replayed without another execution. The cloud durably chooses execution or forwarding per parent. Public CLI/independent processes are qualified; DSH Messaging/Runtime binding, real model A and two isolated Hosts remain next. Do not count this preparation as completed T4 or the overall goal. + +### Latest native phone/Host qualification + +The [phone → two native Hosts follow-up](work-forwarding.md#phone--two-native-hosts-follow-up--2026-10-18) now passes through the actual mobile UI, real model Tools and canonical UAT records. Earlier paragraphs are chronological checkpoints; their two-Host/mobile-pending labels are superseded. Organization-wide Agent membership and WebSocket remain open. Channel-only guests remain limited to that channel and never imply group-to-group bridging. diff --git a/docs/work-forwarding.md b/docs/work-forwarding.md index f8769f9..63d44fd 100644 --- a/docs/work-forwarding.md +++ b/docs/work-forwarding.md @@ -57,3 +57,11 @@ The identity endpoint was deployed and read through the formal CLI in UAT at `3e DeepSeekBot Draft [#2157](https://github.com/BotHarness/DeepSeekBot/pull/2157), runtime `f02c2423bce02c264548193ac738013f4eac83ab`, qualified native model A→B fixed health work against unchanged UAT service `3eeed5b`. Two distinct local DSH Profiles used their own Bots, native Credentials and Operational Databases. A inspected/forwarded the accepted Source Event; B inspected/executed it and returned a result to the same cloud channel. Three canonical messages retained the original Human and deadline; A executed zero times, B once. Both Hosts restarted, and formal direct Tool replay returned the same child and zero additional executions/messages. Local work policies were restored. The first model request executed locally on A, so it is not counted as forwarding. A new request after an explicit dispatcher instruction passed. A fresh mobile→A→B run, WebSocket, organization-wide Agent membership and broader permission/revocation paths remain separate gates; T4 is not closed. This is two real Hosts on one machine, not proof of network or hardware diversity. No service redeployment, Prod change or merge accompanied this follow-up. + +## Phone → two native Hosts follow-up — 2026-10-18 + +The assigned iOS Simulator now qualifies the complete bounded path: phone member presses Request health check for A; A's real native model forwards; B's native model executes; phone reloads and displays all three same-channel records. Canonical sequences16/17/18 preserve the original Human/deadline; local execution ledgers record A0/B1. Mobile JS247a727b8 / installed native494a20f1e / Host runtimef02c2423b / cloud3eeed5b. [Screenshots, recordings and assertions](https://github.com/BotHarness/DeepSeekBot/pull/2157) are pinned to evidence-only head d90f28bca. + +Independent synthetic staging authentication allowed unattended verification: UI refresh rotated credentials, cold restart retained the session, mobile logout revoked only itself and rejected latest refresh while A/B CLI sessions stayed valid. This run does not re-prove Hosted AuthKit/PKCE. Temporary Host policies were restored, own driver/Metro stopped and the shared device window released. No service redeployment, WorkOS setting change, Prod operation or merge. + +This supersedes the mobile-to-two-Host pending statement above. T4/overall goal remain open for WebSocket, organization-wide Agent membership and wider live permission/revocation coverage. Fixed health work, same-Mac isolated Hosts and one iOS Simulator do not establish arbitrary work, hardware diversity, Android or Human QA.