diff --git a/README.md b/README.md index 7764a78..0e2ed7f 100644 --- a/README.md +++ b/README.md @@ -163,3 +163,5 @@ Organization member invitations (UAT slice): [API, CLI, recovery and verificatio 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). + +- [Fixed Agent health-check request and local execution contract](docs/agent-health-check.md) — T3 continuation, UAT qualification pending. diff --git a/apps/cli/lib/agent.ts b/apps/cli/lib/agent.ts index 4d7d3f5..f87f59d 100644 --- a/apps/cli/lib/agent.ts +++ b/apps/cli/lib/agent.ts @@ -4,7 +4,7 @@ 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) { +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"); @@ -14,9 +14,10 @@ export function readAgentFile(path: string) { 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"))); + 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"); diff --git a/apps/cli/lib/commands.ts b/apps/cli/lib/commands.ts index 535e361..9602e77 100644 --- a/apps/cli/lib/commands.ts +++ b/apps/cli/lib/commands.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { agentCommand } from "./agent.ts"; +import { parseWorkInput } from "../../../packages/contracts/work.ts"; import { setTimeout as sleep } from "node:timers/promises"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -28,6 +29,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-health") return (await import("../../../packages/reference/health.ts")).healthCommand(args); 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"))) { @@ -52,13 +54,14 @@ export async function execute(args: string[], rt: Runtime): Promise { const isGuest = args[0] === "guest-channel"; const isGuestInvite = args[0] === "channel-invite"; const isAgent = args[0] === "agent"; - const scoped = isAgent || isOrg || isChannel || isInvitation || isGuest || isGuestInvite; + const isWork = args[0] === "work"; + const scoped = isWork || isAgent || isOrg || isChannel || isInvitation || isGuest || isGuestInvite; const orgCommand = scoped ? args[1] : undefined; const options: Record = {}; if (scoped) { - 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"); + if (!(isWork ? ["request"] : 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"); + if (!["--server", "--id", "--name", "--operation", "--description", "--version", "--after", "--org", "--text-file", "--email", "--channel", "--agent", "--expires-at"].includes(args[n]) || args[n+1] === undefined || options[args[n]] !== undefined) throw new ClientError("USAGE"); options[args[n]] = args[n+1]; } } @@ -149,6 +152,14 @@ export async function execute(args: string[], rt: Runtime): Promise { } return value; }; + if (isWork) { + const orgId = options["--org"] ?? i.organizationId; + const channel = options["--channel"]; + if (!orgId || orgId !== i.organizationId) throw new ClientError("ORGANIZATION_TOKEN_REQUIRED"); + if (!channel || !/^ch_[a-f0-9]{64}$/.test(channel) || !/^[A-Za-z0-9_-]{16,100}$/.test(options["--operation"] ?? "")) throw new ClientError("USAGE"); + const input = parseWorkInput({ targetActorId: options["--agent"], capability: "health-check", expiresAt: options["--expires-at"] }); + return call("/"+orgId+"/channels/"+channel+"/work-requests", { method:"POST",headers:{"Content-Type":"application/json","Idempotency-Key":options["--operation"]},body:JSON.stringify(input) }); + } if (isAgent) { const orgId = options["--org"] ?? i.organizationId; const channelId = options["--channel"]; diff --git a/apps/service/db/channel/migrations/0003_steep_brood.sql b/apps/service/db/channel/migrations/0003_steep_brood.sql new file mode 100644 index 0000000..4622eb8 --- /dev/null +++ b/apps/service/db/channel/migrations/0003_steep_brood.sql @@ -0,0 +1,12 @@ +CREATE TABLE `channel_work` ( + `request_id` text PRIMARY KEY NOT NULL, + `sequence` integer NOT NULL, + `requester_membership_id` text NOT NULL, + `requester_user_id` text NOT NULL, + `target_actor_id` text NOT NULL, + `expires_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `channel_work_sequence_unique` ON `channel_work` (`sequence`);--> statement-breakpoint +ALTER TABLE `channel_message` ADD `kind` text DEFAULT 'text' NOT NULL;--> statement-breakpoint +ALTER TABLE `channel_message` ADD `payload` text; \ No newline at end of file diff --git a/apps/service/db/channel/migrations/meta/0003_snapshot.json b/apps/service/db/channel/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..d384205 --- /dev/null +++ b/apps/service/db/channel/migrations/meta/0003_snapshot.json @@ -0,0 +1,377 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "55a5f62f-04fb-4fd6-91a3-e7660013d915", + "prevId": "c4d38e41-6ca3-4240-848e-c0bef841f48e", + "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 + }, + "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 + } + }, + "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 7765abb..1ded396 100644 --- a/apps/service/db/channel/migrations/meta/_journal.json +++ b/apps/service/db/channel/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1792285033378, "tag": "0002_grey_strong_guy", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1792287189376, + "tag": "0003_steep_brood", + "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 9aa896a..35c7dd6 100644 --- a/apps/service/db/channel/migrations/migrations.js +++ b/apps/service/db/channel/migrations/migrations.js @@ -2,12 +2,14 @@ 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'; +import m0003 from './0003_steep_brood.sql'; export default { journal, migrations: { m0000, m0001, -m0002 +m0002, +m0003 } } diff --git a/apps/service/db/channel/schema.ts b/apps/service/db/channel/schema.ts index 44787e0..6344903 100644 --- a/apps/service/db/channel/schema.ts +++ b/apps/service/db/channel/schema.ts @@ -14,9 +14,20 @@ export const channelMessage = sqliteTable("channel_message", { authorType: text("author_type", { enum: ["human", "agent"] }).notNull(), operationId: text("operation_id").notNull(), text: text("text").notNull(), + kind: text("kind", { enum: ["text", "work_request", "work_result"] }).notNull().default("text"), + payload: text("payload", { mode: "json" }).$type(), createdAt: text("created_at").notNull(), }, t => [uniqueIndex("message_author_operation").on(t.authorType, t.authorId, t.operationId)]); +export const channelWork = sqliteTable("channel_work", { + requestId: text("request_id").primaryKey(), + sequence: integer("sequence").notNull().unique(), + requesterMembershipId: text("requester_membership_id").notNull(), + requesterUserId: text("requester_user_id").notNull(), + targetActorId: text("target_actor_id").notNull(), + expiresAt: text("expires_at").notNull(), +}); + export const channelInvitation = sqliteTable("channel_invitation", { invitationId: text("invitation_id").primaryKey(), inviterId: text("inviter_id").notNull(), diff --git a/apps/service/src/agents.ts b/apps/service/src/agents.ts index 5a69a58..0e5dbd1 100644 --- a/apps/service/src/agents.ts +++ b/apps/service/src/agents.ts @@ -40,7 +40,7 @@ export const ownerAgents = new Hono<{ Bindings: Env }>() return c.json(result); }); -async function authenticatedAgent(env: Env, header: string | undefined, channelId: string) { +export 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; diff --git a/apps/service/src/channel.ts b/apps/service/src/channel.ts index bff0797..5fb5f6d 100644 --- a/apps/service/src/channel.ts +++ b/apps/service/src/channel.ts @@ -3,7 +3,8 @@ 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, channelAgent } from "../db/channel/schema"; +import { channelConfig, channelMessage, channelInvitation, channelGuest, channelAgent, channelWork } from "../db/channel/schema"; +import type { WorkRequest, WorkResult } from "../../../packages/contracts/work"; 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 +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: 3 }; } + schemaStatus() { this.db.select().from(channelConfig).limit(1).all(); return { schemaVersion: 4 }; } 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"); @@ -28,10 +29,13 @@ export class Channel extends DurableObject { } read(channelId: string, actor: ChannelActor) { return this.authorize(channelId, actor); } send(channelId: string, actor: ChannelActor, operationId: string, text: string) { + return this.writeMessage(channelId, actor, operationId, text, "text", null); + } + private writeMessage(channelId: string, actor: ChannelActor, operationId: string, text: string, kind: "text" | "work_request" | "work_result", payload: WorkRequest | WorkResult | null) { this.authorize(channelId, actor); const old = this.db.select().from(channelMessage).where(and(eq(channelMessage.authorId, actor.id), eq(channelMessage.authorType, actor.type), eq(channelMessage.operationId, operationId))).get(); - if (old) return old.text === text && old.createdAt >= actor.historyFrom && old.sequence > (actor.historyAfter ?? 0) ? { message: old, replayed: true } : null; - const message = this.db.insert(channelMessage).values({ authorId: actor.id, authorType: actor.type, operationId, text, createdAt: new Date().toISOString() }).returning().get(); + if (old) return old.text === text && old.kind === kind && JSON.stringify(old.payload) === JSON.stringify(payload) && old.createdAt >= actor.historyFrom && old.sequence > (actor.historyAfter ?? 0) ? { message: old, replayed: true } : null; + const message = this.db.insert(channelMessage).values({ authorId: actor.id, authorType: actor.type, operationId, text, kind, payload, createdAt: new Date().toISOString() }).returning().get(); return { message, replayed: false }; } history(channelId: string, actor: ChannelActor, after: number) { @@ -149,4 +153,46 @@ 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) { + 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 (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 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 }; + 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(); + 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; + } + 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 }; + } + 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}; + } + } diff --git a/apps/service/src/index.ts b/apps/service/src/index.ts index b436ce8..33f0dad 100644 --- a/apps/service/src/index.ts +++ b/apps/service/src/index.ts @@ -11,6 +11,7 @@ import { channels } from "./channels"; import { invitations, organizationInvitations } from "./invitations"; import { ownerAgents, agentMessages } from "./agents"; +import { humanWork, agentWork } from "./work"; import { ownerGuestInvitations, guestInvitations, guestChannels } from "./guests"; const app = new Hono<{ Bindings: Env }>(); @@ -72,7 +73,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).route("/api/organizations", ownerAgents).route("/api/agent-channels", agentMessages); +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).route("/api/organizations", humanWork).route("/api/agent-channels", agentWork); // 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/apps/service/src/work.ts b/apps/service/src/work.ts new file mode 100644 index 0000000..971f5e3 --- /dev/null +++ b/apps/service/src/work.ts @@ -0,0 +1,107 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +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'; + +const limit = bodyLimit({ + maxSize: 8000, + onError: (c) => c.json({ error: { code: 'INVALID_WORK_INPUT' } }, 413), +}); +async function parse(request: Request, parser: (value: unknown) => T) { + try { + return parser(await request.json()); + } catch { + throw new AccessError('INVALID_WORK_INPUT', 400); + } +} +function checkId(id: string) { + if (!validRequestId(id)) throw new AccessError('INVALID_WORK_INPUT', 400); + return id; +} +export const humanWork = new Hono<{ Bindings: Env }>() + .use('*', limit) + .post('/:orgId/channels/:channelId/work-requests', async (c) => { + const { channel, actor, member } = await channelContext( + c.env, + c.req.header('Authorization'), + c.req.param('orgId'), + c.req.param('channelId'), + ); + const input = await parse(c.req.raw, parseWorkInput); + const key = operation(c.req.header('Idempotency-Key')); + const hash = Array.from( + new Uint8Array( + await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(JSON.stringify([c.req.param('channelId'), actor.id, key])), + ), + ), + (b) => b.toString(16).padStart(2, '0'), + ).join(''); + const result = await channel.requestWork( + c.req.param('channelId'), + actor, + member.id, + key, + 'wr_' + hash, + input.targetActorId, + input.expiresAt, + ); + if ('error' in result) + throw new AccessError( + result.error, + result.error === 'OPERATION_CONFLICT' + ? 409 + : result.error === 'INVALID_WORK_EXPIRY' + ? 400 + : 403, + ); + return c.json(result); + }); +export const agentWork = new Hono<{ Bindings: Env }>() + .use('*', limit) + .post('/:channelId/work-requests/:requestId/authorize', async (c) => { + const id = checkId(c.req.param('requestId')); + 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, 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; + } + 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/result', async (c) => { + const id = checkId(c.req.param('requestId')); + const result = await parse(c.req.raw, parseWorkResult); + if (result.requestId !== id) throw new AccessError('INVALID_WORK_INPUT', 400); + const { channel, actorId, secretHash } = await authenticatedAgent( + c.env, + c.req.header('Authorization'), + c.req.param('channelId'), + ); + const written = await channel.agentWorkResult( + c.req.param('channelId'), + actorId, + secretHash, + result, + ); + if ('error' in written) + throw new AccessError(written.error, written.error === 'OPERATION_CONFLICT' ? 409 : 403); + return c.json(written); + }); diff --git a/docs/agent-health-check.md b/docs/agent-health-check.md new file mode 100644 index 0000000..3a7be1d --- /dev/null +++ b/docs/agent-health-check.md @@ -0,0 +1,48 @@ +# Fixed Agent health check — T3 continuation + +A Human organization member selects an active channel Agent and requests one fixed health check. The Agent's operator must separately allow that actual user ID in its local policy. Membership, ownership and channel credentials do not grant shell or filesystem authority. This increment checks only the executing Node process's platform, architecture and runtime version; it accepts no command, path or model instruction. + +## Canonical request and result + +`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. + +`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. + +Channel schema 4 adds message kind/payload and a request table. D1/Org remain 5/4. Reverting this code removes the work routes while retaining additive data. Existing text messages are not executable requests. + +## Local execution owner + +The reference module owns a private SQLite ledger and explicit local policy, bound to trusted instance configuration + channel + Agent identity. Configure requires the operator's confirmation and a list of allowed Human user IDs. An empty list denies everyone. Credentials and policy are private files; only paths and safe identifiers appear in CLI output. + +The runner locks its state directory, checks discovery before every bearer, reads one bounded history page, rechecks policy after network authorization, and persists `started` before the fixed check. A completed result is persisted in an outbox before sending. Lost acknowledgements retry the identical result without rerunning. An interrupted `started` record becomes `needs-review`, with no automatic retry of execution. This narrow operation does not claim exactly-once arbitrary side effects or distributed execution leases. + +## Public CLI and sample + +```sh +node apps/cli/tob.mjs work request --server https://your-instance.example \ + --channel ch_... --agent ag_... --expires-at --operation +node apps/cli/tob.mjs agent-health configure --credential-file /private/agent.json \ + --state-dir /private/agent-state --environment local-agent-a --allow-users user_... --confirm +node apps/cli/tob.mjs agent-health run --credential-file /private/agent.json \ + --state-dir /private/agent-state --after 0 +``` + +The Human command uses the selected organization. `node examples/health-agent.mjs configure|run ...` uses the same module without a DSH installation or Human token. A runner returns nextCursor/hasMore; callers advance through pages. Replaying earlier pages is safe while retaining the ledger. Deleting the ledger is not an execution retry mechanism. + +The phone saves target, deadline and operation in secure storage before transmission. Restart/retry preserves all three. Removing its local retry record or signing out does not cancel a request already accepted by the cloud. Results appear as readable channel messages; reload remains explicit in this HTTP slice. + +## Acceptance status + +Source checks cover permission/target/expiry, membership removal and rejoin, revocation during provider I/O, durable message/request idempotency, local allow-list, interruption before/after the check, lost result acknowledgement and private policy configuration. Mobile tests cover ordinary-member submission, secure persistence before sending, restart retry, mismatched response, and logout cleanup. + +Real UAT CLI, native phone request/result, independent-process replay, screenshots/video and exact-head CI are still pending for this increment. This does not qualify WebSocket delivery, a DSH adapter, Agent-to-Agent forwarding, two isolated Hosts, Android/physical devices or Human QA. Keep T3 and the overall goal open; no Prod or merge. + +## UAT continuation — 2026-10-18 JST + +Service23db30d was deployed only to UAT (Worker version eb945fb5-c38b-44de-8f28-dc90f17112af), schema5/4/4. Service CI39087690616 passed. Initial deploy smoke failed without a classified cause; a subsequent read-only complete smoke passed. The first CLI attempt used an org B had not joined and correctly returned ORGANIZATION_ACCESS_DENIED; the final run used their existing shared organization without expanding membership. + +Formal CLI created three requests/results: owner permitted, member rejected by the local policy, then a new member request permitted after explicit local grant. Identical request retry reused the same message; fresh-process history replay did not rerun completed work. Revoked Agent read/run was refused. + +[Mobile Draft #2154](https://github.com/BotHarness/DeepSeekBot/pull/2154) Batch18 runtime8586130e5 passed actual member B request → independent Node process check → same-channel result. Clearing the local policy produced a second request's denial with count0; owner CLI read exactly four correlated messages. Six Dynamic Type/theme cases kept the request reachable; the maximum-size button was actually used. Coldrestart/refresh retained session and results. Own mobile logout/revoked latest refresh/local deletion and independent CLI preservation passed. Runtime CI39088289282 passed; evidence-only CI is tracked on the PR. Android, physical device, Human QA, new Hosted AuthKit, WebSocket, DSH forwarding/two Hosts remain separate gates. Test Agent revoked and shared Simulator window released; no Prod or merge. diff --git a/docs/multi-user-collaboration.md b/docs/multi-user-collaboration.md index 4766c22..2b12ee2 100644 --- a/docs/multi-user-collaboration.md +++ b/docs/multi-user-collaboration.md @@ -56,3 +56,7 @@ This completes the narrow HTTP channel sub-slice, not T1/T2 or the overall goal. ## 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. + +## 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. diff --git a/examples/health-agent.mjs b/examples/health-agent.mjs new file mode 100644 index 0000000..0976b5b --- /dev/null +++ b/examples/health-agent.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { healthCommand } from '../packages/reference/health.ts'; +import { ClientError } from '../packages/contracts/instance.ts'; +try { console.log(JSON.stringify(await healthCommand(['agent-health',...process.argv.slice(2)]))); } +catch(e) { console.log(JSON.stringify({error:{code:e instanceof ClientError?e.code:'HEALTH_AGENT_FAILED'}}));process.exitCode=1; } diff --git a/packages/contracts/work.ts b/packages/contracts/work.ts new file mode 100644 index 0000000..85f04a8 --- /dev/null +++ b/packages/contracts/work.ts @@ -0,0 +1,146 @@ +import { ClientError } from './instance.ts'; + +export type WorkRequest = { + version: 1; + requestId: string; + channelId: string; + requesterUserId: string; + targetActorId: string; + capability: 'health-check'; + createdAt: string; + expiresAt: string; +}; +export type WorkResult = { + version: 1; + requestId: string; + state: 'completed' | 'rejected' | 'needs-review'; + environment: string; + executionCount: number; + reason: + | 'OK' + | 'LOCAL_POLICY_DENIED' + | 'REQUEST_EXPIRED' + | 'REQUESTER_ACCESS_DENIED' + | 'INTERRUPTED_EXECUTION'; + diagnostics?: { platform: string; architecture: string; runtime: string }; +}; +export const validRequestId = (value: unknown): value is string => + typeof value === 'string' && /^wr_[a-f0-9]{64}$/.test(value); +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ClientError('INVALID_WORK_INPUT'); + return value as Record; +} +const exact = (v: Record, keys: string[]) => + Object.keys(v).every((k) => keys.includes(k)); +const label = (v: unknown): v is string => + typeof v === 'string' && /^[A-Za-z0-9_. -]{1,64}$/.test(v); +const iso = (v: unknown): v is string => + typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v; +export function parseWorkInput(value: unknown) { + const v = record(value); + if ( + !exact(v, ['targetActorId', 'capability', 'expiresAt']) || + typeof v.targetActorId !== 'string' || + !/^ag_[a-f0-9]{64}$/.test(v.targetActorId) || + v.capability !== 'health-check' || + !iso(v.expiresAt) + ) + throw new ClientError('INVALID_WORK_INPUT'); + return { + targetActorId: v.targetActorId, + capability: 'health-check' as const, + expiresAt: v.expiresAt, + }; +} +export function parseWorkRequest(value: unknown): WorkRequest { + const v = record(value); + const input = parseWorkInput({ + targetActorId: v.targetActorId, + capability: v.capability, + expiresAt: v.expiresAt, + }); + if ( + !exact(v, [ + 'version', + 'requestId', + 'channelId', + 'requesterUserId', + 'targetActorId', + 'capability', + 'createdAt', + 'expiresAt', + ]) || + v.version !== 1 || + !validRequestId(v.requestId) || + typeof v.channelId !== 'string' || + !/^ch_[a-f0-9]{64}$/.test(v.channelId) || + typeof v.requesterUserId !== 'string' || + !/^user_[A-Za-z0-9]+$/.test(v.requesterUserId) || + !iso(v.createdAt) || + Date.parse(input.expiresAt) <= Date.parse(v.createdAt) || + Date.parse(input.expiresAt) - Date.parse(v.createdAt) > 600000 + ) + throw new ClientError('INVALID_WORK_INPUT'); + return { + version: 1, + requestId: v.requestId, + channelId: v.channelId, + requesterUserId: v.requesterUserId, + ...input, + createdAt: v.createdAt, + }; +} +export function parseWorkResult(value: unknown): WorkResult { + const v = record(value); + if ( + !exact(v, [ + 'version', + 'requestId', + 'state', + 'environment', + 'executionCount', + 'reason', + 'diagnostics', + ]) || + v.version !== 1 || + !validRequestId(v.requestId) || + !['completed', 'rejected', 'needs-review'].includes(String(v.state)) || + !label(v.environment) || + ![0, 1].includes(Number(v.executionCount)) || + typeof v.executionCount !== 'number' + ) + throw new ClientError('INVALID_WORK_INPUT'); + let diagnostics: WorkResult['diagnostics']; + if (v.state === 'completed') { + const d = record(v.diagnostics); + if ( + v.executionCount !== 1 || + v.reason !== 'OK' || + !exact(d, ['platform', 'architecture', 'runtime']) || + !label(d.platform) || + !label(d.architecture) || + !label(d.runtime) + ) + throw new ClientError('INVALID_WORK_INPUT'); + diagnostics = { platform: d.platform, architecture: d.architecture, runtime: d.runtime }; + } else if ( + v.diagnostics !== undefined || + (v.state === 'rejected' + ? v.executionCount !== 0 || + !['LOCAL_POLICY_DENIED', 'REQUEST_EXPIRED', 'REQUESTER_ACCESS_DENIED'].includes( + String(v.reason), + ) + : v.reason !== 'INTERRUPTED_EXECUTION') + ) + throw new ClientError('INVALID_WORK_INPUT'); + return { + version: 1, + requestId: v.requestId, + state: v.state as WorkResult['state'], + environment: v.environment, + executionCount: v.executionCount, + reason: v.reason as WorkResult['reason'], + ...(diagnostics ? { diagnostics } : {}), + }; +} diff --git a/packages/reference/health.ts b/packages/reference/health.ts new file mode 100644 index 0000000..66e579e --- /dev/null +++ b/packages/reference/health.ts @@ -0,0 +1,409 @@ +import { DatabaseSync } from 'node:sqlite'; +import { + closeSync, + constants, + existsSync, + fstatSync, + openSync, + writeFileSync, + fsyncSync, + renameSync, + unlinkSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { Store, digest } from '../../apps/cli/lib/store.ts'; +import { readAgentFile, readPrivateJson } from '../../apps/cli/lib/agent.ts'; +import { ClientError, connectionKey } from '../contracts/instance.ts'; +import { + parseWorkRequest, + parseWorkResult, + type WorkRequest, + type WorkResult, +} from '../contracts/work.ts'; +import { checkDiscovery, request, responseJson } from '../client/connection.ts'; + +export type HealthPolicy = { + version: 1; + binding: string; + actorId: string; + channelId: string; + environment: string; + allowedUsers: string[]; +}; +function parsePolicy(value: unknown): HealthPolicy { + const p = value as HealthPolicy; + if ( + !p || + p.version !== 1 || + !/^[a-f0-9]{64}$/.test(p.binding) || + !/^ag_[a-f0-9]{64}$/.test(p.actorId) || + !/^ch_[a-f0-9]{64}$/.test(p.channelId) || + !/^[A-Za-z0-9_. -]{1,64}$/.test(p.environment) || + !Array.isArray(p.allowedUsers) || + p.allowedUsers.length > 100 || + p.allowedUsers.some((u) => typeof u !== 'string' || !/^user_[A-Za-z0-9]+$/.test(u)) || + new Set(p.allowedUsers).size !== p.allowedUsers.length || + Object.keys(p).some( + (k) => + !['version', 'binding', 'actorId', 'channelId', 'environment', 'allowedUsers'].includes(k), + ) + ) + throw new ClientError('INVALID_LOCAL_POLICY'); + return p; +} +export class HealthLedger { + readonly store: Store; + readonly db: DatabaseSync; + constructor(directory: string, binding: string) { + this.store = new Store(directory); + const path = join(this.store.root, 'health.sqlite'); + if (!existsSync(path)) { + const fd = openSync(path, 'wx', 0o600); + closeSync(fd); + } + 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) + throw new ClientError('PRIVATE_LEDGER_REQUIRED'); + } finally { + closeSync(fd); + } + this.db = new DatabaseSync(path); + this.db.exec( + 'PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000; CREATE TABLE IF NOT EXISTS identity (id INTEGER PRIMARY KEY CHECK(id=1),binding TEXT NOT NULL); CREATE TABLE IF NOT EXISTS work (id TEXT PRIMARY KEY,payload TEXT NOT NULL,state TEXT NOT NULL,execution_count INTEGER NOT NULL DEFAULT 0,result TEXT);', + ); + this.db.prepare('INSERT OR IGNORE INTO identity VALUES(1,?)').run(binding); + if (this.db.prepare('SELECT binding FROM identity WHERE id=1').get()?.binding !== binding) { + this.db.close(); + throw new ClientError('LEDGER_TARGET_MISMATCH'); + } + } + row(id: string) { + const r = this.db.prepare('SELECT * FROM work WHERE id=?').get(id); + return r as + | { + id: string; + payload: string; + state: string; + execution_count: number; + result: string | null; + } + | undefined; + } + claim(r: WorkRequest) { + this.db + .prepare("INSERT INTO work (id,payload,state) VALUES (?,?,'started')") + .run(r.requestId, JSON.stringify(r)); + } + executed(id: string) { + const update = this.db + .prepare( + "UPDATE work SET execution_count=execution_count+1 WHERE id=? AND state='started' AND execution_count=0", + ) + .run(id); + if (update.changes !== 1) throw new ClientError('WORK_ALREADY_STARTED'); + } + save(r: WorkRequest, result: WorkResult) { + this.db + .prepare( + "INSERT INTO work (id,payload,state,execution_count,result) VALUES (?,?,'outbox',?,?) ON CONFLICT(id) DO UPDATE SET state='outbox',result=excluded.result", + ) + .run(r.requestId, JSON.stringify(r), result.executionCount, JSON.stringify(result)); + } + finish(id: string) { + this.db.prepare("UPDATE work SET state='finished' WHERE id=? AND state='outbox'").run(id); + } + close() { + this.db.close(); + } +} +export type HealthTransport = { + authorize: (r: WorkRequest) => Promise; + send: (r: WorkResult) => Promise; +}; +export async function processHealthRequest( + ledger: HealthLedger, + requestValue: WorkRequest, + policy: () => HealthPolicy, + transport: HealthTransport, + hooks: { afterClaim?: () => void; afterExecute?: () => void; now?: () => number } = {}, +) { + const r = parseWorkRequest(requestValue), + now = hooks.now ?? Date.now; + const initial = policy(); + if (r.targetActorId !== initial.actorId || r.channelId !== initial.channelId) + throw new ClientError('WORK_TARGET_MISMATCH'); + const old = ledger.row(r.requestId); + if (old && old.payload !== JSON.stringify(r)) throw new ClientError('WORK_REQUEST_CONFLICT'); + if (old?.state === 'finished') + return { executed: 0, replayed: true, state: parseWorkResult(JSON.parse(old.result!)).state }; + let result: WorkResult; + const rejection = (reason: WorkResult['reason']): WorkResult => ({ + version: 1, + requestId: r.requestId, + state: 'rejected', + environment: initial.environment, + executionCount: 0, + reason, + }); + if (old?.state === 'outbox') result = parseWorkResult(JSON.parse(old.result!)); + else if (old?.state === 'started') + result = { + version: 1, + requestId: r.requestId, + state: 'needs-review', + environment: initial.environment, + executionCount: old.execution_count, + reason: 'INTERRUPTED_EXECUTION', + }; + else { + let denied: WorkResult | undefined; + if (!initial.allowedUsers.includes(r.requesterUserId)) + denied = rejection('LOCAL_POLICY_DENIED'); + else if (Date.parse(r.expiresAt) <= now()) denied = rejection('REQUEST_EXPIRED'); + else { + try { + if (JSON.stringify(parseWorkRequest(await transport.authorize(r))) !== JSON.stringify(r)) + throw new ClientError('WORK_REQUEST_CONFLICT'); + } catch (e) { + if ( + e instanceof ClientError && + ['REQUEST_EXPIRED', 'REQUESTER_ACCESS_DENIED'].includes(e.code) + ) + denied = rejection(e.code as WorkResult['reason']); + else throw e; + } + } + const current = policy(); + if ( + current.actorId !== initial.actorId || + current.channelId !== initial.channelId || + current.binding !== initial.binding || + current.environment !== initial.environment + ) + throw new ClientError('LOCAL_POLICY_CHANGED'); + if (!current.allowedUsers.includes(r.requesterUserId)) + denied = rejection('LOCAL_POLICY_DENIED'); + if (!denied && Date.parse(r.expiresAt) <= now()) denied = rejection('REQUEST_EXPIRED'); + if (denied) result = denied; + else { + ledger.claim(r); + hooks.afterClaim?.(); + ledger.executed(r.requestId); + result = { + version: 1, + requestId: r.requestId, + state: 'completed', + environment: current.environment, + executionCount: 1, + reason: 'OK', + diagnostics: { + platform: process.platform, + architecture: process.arch, + runtime: process.version, + }, + }; + hooks.afterExecute?.(); + } + } + parseWorkResult(result); + ledger.save(r, result); + await transport.send(result); + ledger.finish(r.requestId); + return { executed: old ? 0 : result.executionCount, replayed: !!old, state: result.state }; +} +function bindingFor(c: ReturnType) { + return digest(JSON.stringify([connectionKey(c.config), c.actorId, c.channelId])); +} +export async function configureHealth( + credentialFile: string, + directory: string, + environment: string, + allowedUsers: string[], +) { + const c = readAgentFile(credentialFile), + binding = bindingFor(c); + const store = new Store(directory); + const value = parsePolicy({ + version: 1, + binding, + actorId: c.actorId, + channelId: c.channelId, + environment, + allowedUsers, + }); + await store.locked('health-run', async () => { + const file = join(store.root, 'policy.json'); + if (existsSync(file)) { + const old = parsePolicy(readPrivateJson(file)); + if (old.binding !== binding) throw new ClientError('LOCAL_POLICY_TARGET_MISMATCH'); + } + const temp = file + '.' + randomUUID(); + const fd = openSync(temp, 'wx', 0o600); + try { + writeFileSync(fd, JSON.stringify(value)); + fsyncSync(fd); + } finally { + closeSync(fd); + } + try { + renameSync(temp, file); + const dir = openSync(store.root, 'r'); + try { + fsyncSync(dir); + } finally { + closeSync(dir); + } + } finally { + if (existsSync(temp)) unlinkSync(temp); + } + }); + return { + configured: true, + actorId: c.actorId, + channelId: c.channelId, + environment, + allowedUsers, + capability: 'health-check', + }; +} +export async function runHealthOnce(credentialFile: string, directory: string, after = 0) { + if (!Number.isSafeInteger(after) || after < 0) throw new ClientError('INVALID_CURSOR'); + const c = readAgentFile(credentialFile), + binding = bindingFor(c), + store = new Store(directory); + return store.locked('health-run', async () => { + const policy = () => { + const p = parsePolicy(readPrivateJson(join(store.root, 'policy.json'))); + if (p.binding !== binding || p.actorId !== c.actorId || p.channelId !== c.channelId) + throw new ClientError('LOCAL_POLICY_TARGET_MISMATCH'); + return p; + }; + policy(); + const ledger = new HealthLedger(directory, binding); + const base = c.config.server + '/api/agent-channels/' + c.channelId; + const call = async (path: string, body?: unknown) => { + await checkDiscovery(c.config); + const res = await request(base + path, { + method: body === undefined ? 'GET' : 'POST', + headers: { + Authorization: 'Bearer tob_agent_' + c.actorId + '.' + c.secret, + 'Content-Type': 'application/json', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const value = await responseJson(res); + if (!res.ok) { + const code = (value.error as { code?: unknown })?.code; + throw new ClientError( + typeof code === 'string' && /^[A-Z_]+$/.test(code) ? code : 'WORK_UNAVAILABLE', + ); + } + return value; + }; + try { + const page = await call('/messages?after=' + after); + if ( + !Array.isArray(page.messages) || + page.messages.length > 50 || + !Number.isSafeInteger(page.nextCursor) || + Number(page.nextCursor) < after || + typeof page.hasMore !== 'boolean' + ) + throw new ClientError('INVALID_RESPONSE'); + let executed = 0, + 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'); + if (r.targetActorId !== c.actorId) continue; + const result = await processHealthRequest(ledger, r, policy, { + authorize: async (req) => + parseWorkRequest( + (await call('/work-requests/' + req.requestId + '/authorize', {})).request, + ), + send: async (result) => { + const sent = await call('/work-requests/' + result.requestId + '/result', result); + const message = sent.message as { + authorId?: unknown; + kind?: unknown; + payload?: unknown; + }; + if ( + message?.authorId !== c.actorId || + message.kind !== 'work_result' || + JSON.stringify(parseWorkResult(message.payload)) !== JSON.stringify(result) + ) + throw new ClientError('INVALID_WORK_RESULT'); + }, + }); + executed += result.executed; + processed++; + } + return { + actorId: c.actorId, + processed, + executed, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + }; + } finally { + ledger.close(); + } + }); +} +export async function healthCommand(args: string[]) { + const command = args[1], + options: Record = {}; + let confirm = false; + for (let i = 2; i < args.length; i++) { + if (args[i] === '--confirm') { + if (confirm) throw new ClientError('USAGE'); + confirm = true; + continue; + } + if ( + !['--credential-file', '--state-dir', '--environment', '--allow-users', '--after'].includes( + args[i], + ) || + options[args[i]] !== undefined || + args[i + 1] === undefined + ) + throw new ClientError('USAGE'); + const name = args[i]; + options[name] = args[++i]; + } + if (!options['--credential-file'] || !options['--state-dir']) throw new ClientError('USAGE'); + if (command === 'configure') { + if (options['--after'] !== undefined) throw new ClientError('USAGE'); + if (!confirm || !options['--environment'] || options['--allow-users'] === undefined) + throw new ClientError('CONFIRM_LOCAL_POLICY_REQUIRED'); + return configureHealth( + options['--credential-file'], + options['--state-dir'], + options['--environment'], + options['--allow-users'] ? options['--allow-users'].split(',') : [], + ); + } + if ( + command !== 'run' || + confirm || + options['--environment'] || + options['--allow-users'] !== undefined || + !/^(0|[1-9][0-9]*)$/.test(options['--after'] ?? '0') + ) + throw new ClientError('USAGE'); + return runHealthOnce( + options['--credential-file'], + options['--state-dir'], + Number(options['--after'] ?? 0), + ); +} diff --git a/tests/cli.node.test.ts b/tests/cli.node.test.ts index 5453d89..8aa703c 100644 --- a/tests/cli.node.test.ts +++ b/tests/cli.node.test.ts @@ -277,3 +277,15 @@ test('Agent create persists a private stable credential before network and retri 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'); }); + +test('work request CLI carries only fixed target/expiry and stable operation through selected organization',async()=>{ + await setup();const channel='ch_'+'a'.repeat(64),agent='ag_'+'b'.repeat(64),expiresAt=new Date(Date.now()+300000).toISOString(); + const run=()=>execute(['work','request','--server',config.server,'--channel',channel,'--agent',agent,'--expires-at',expiresAt,'--operation','fixed-work-cli-0001'],rt); + await fails(run(),'ORGANIZATION_TOKEN_REQUIRED');orgSelected='org_fixture';await command('refresh'); + const prior=globalThis.fetch;let sent=0; + globalThis.fetch=async(url,init)=>{ + if(String(url).endsWith('/work-requests')){sent++;assert.equal(String(url),config.server+'/api/organizations/org_fixture/channels/'+channel+'/work-requests');assert.deepEqual(JSON.parse(String(init?.body)),{targetActorId:agent,capability:'health-check',expiresAt});assert.equal(new Headers(init?.headers).get('Idempotency-Key'),'fixed-work-cli-0001');return Response.json({replayed:sent>1});} + return prior(url,init); + }; + assert.deepEqual(await run(),{replayed:false});assert.deepEqual(await run(),{replayed:true}); +}); diff --git a/tests/health.node.test.ts b/tests/health.node.test.ts new file mode 100644 index 0000000..2996ac3 --- /dev/null +++ b/tests/health.node.test.ts @@ -0,0 +1,266 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + HealthLedger, + processHealthRequest, + type HealthPolicy, +} from '../packages/reference/health.ts'; +import { ClientError } from '../packages/contracts/instance.ts'; +import type { WorkRequest, WorkResult } from '../packages/contracts/work.ts'; +const binding = 'a'.repeat(64); +const policy: HealthPolicy = { + version: 1, + binding, + actorId: 'ag_' + 'b'.repeat(64), + channelId: 'ch_' + 'c'.repeat(64), + environment: 'test-process', + allowedUsers: ['user_test'], +}; +const request: WorkRequest = { + version: 1, + requestId: 'wr_' + 'd'.repeat(64), + channelId: policy.channelId, + targetActorId: policy.actorId, + requesterUserId: 'user_test', + capability: 'health-check', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300000).toISOString(), +}; +async function fixture( + fn: (value: { + ledger: HealthLedger; + dir: string; + sent: WorkResult[]; + transport: { + authorize: (r: WorkRequest) => Promise; + send: (r: WorkResult) => Promise; + }; + }) => Promise, +) { + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'tob-health-')); + const ledger = new HealthLedger(dir, binding); + const sent: WorkResult[] = []; + try { + await fn({ + ledger, + dir, + sent, + transport: { + authorize: async (r) => r, + send: async (r) => { + sent.push(r); + }, + }, + }); + } finally { + try { + ledger.close(); + } catch {} + rmSync(dir, { recursive: true, force: true }); + } +} +test('fixed check executes once, persists result, and duplicate replay survives ledger reopen', () => + fixture(async ({ ledger, dir, sent, transport }) => { + assert.deepEqual(await processHealthRequest(ledger, request, () => policy, transport), { + executed: 1, + replayed: false, + state: 'completed', + }); + assert.equal(ledger.row(request.requestId)?.execution_count, 1); + assert.equal(sent[0].diagnostics?.platform, process.platform); + ledger.close(); + const reopened = new HealthLedger(dir, binding); + try { + assert.deepEqual(await processHealthRequest(reopened, request, () => policy, transport), { + executed: 0, + replayed: true, + state: 'completed', + }); + assert.equal(sent.length, 1); + assert.equal(reopened.row(request.requestId)?.execution_count, 1); + } finally { + reopened.close(); + } + })); +test('lost result acknowledgement retries identical durable result without re-executing', () => + fixture(async ({ ledger, sent, transport }) => { + let fail = true; + transport.send = async (r) => { + sent.push(r); + if (fail) throw new ClientError('NETWORK_UNAVAILABLE'); + }; + await assert.rejects(processHealthRequest(ledger, request, () => policy, transport)); + assert.equal(ledger.row(request.requestId)?.state, 'outbox'); + fail = false; + const r = await processHealthRequest(ledger, request, () => policy, transport); + assert.equal(r.executed, 0); + assert.deepEqual(sent[0], sent[1]); + assert.equal(ledger.row(request.requestId)?.execution_count, 1); + })); +for (const phase of ['afterClaim', 'afterExecute'] as const) + test('interruption ' + phase + ' reports needs-review instead of re-running', () => + fixture(async ({ ledger, sent, transport }) => { + await assert.rejects( + processHealthRequest(ledger, request, () => policy, transport, { + [phase]: () => { + throw Error('injected crash'); + }, + }), + ); + assert.equal(ledger.row(request.requestId)?.state, 'started'); + const r = await processHealthRequest(ledger, request, () => policy, transport); + assert.equal(r.state, 'needs-review'); + assert.equal(r.executed, 0); + assert.equal(sent[0].reason, 'INTERRUPTED_EXECUTION'); + assert.equal(sent[0].executionCount, phase === 'afterClaim' ? 0 : 1); + }), + ); +test('local policy is separate from cloud authorization and reread after network waits', () => + fixture(async ({ ledger, sent, transport }) => { + let current = policy; + transport.authorize = async (r) => { + current = { ...policy, allowedUsers: [] }; + return r; + }; + await processHealthRequest(ledger, request, () => current, transport); + assert.equal(sent[0].reason, 'LOCAL_POLICY_DENIED'); + assert.equal(ledger.row(request.requestId)?.execution_count, 0); + })); +test('expired work is reported without attempting cloud authorization or executing', () => + fixture(async ({ ledger, sent, transport }) => { + transport.authorize = async () => { + throw Error('must not authorize'); + }; + await processHealthRequest(ledger, request, () => policy, transport, { + now: () => Date.parse(request.expiresAt) + 1, + }); + assert.equal(sent[0].reason, 'REQUEST_EXPIRED'); + assert.equal(ledger.row(request.requestId)?.execution_count, 0); + })); +test('expiry reached while checking current access still refuses work', () => + fixture(async ({ ledger, sent, transport }) => { + let now = Date.parse(request.createdAt); + transport.authorize = async (r) => { + now = Date.parse(r.expiresAt) + 1; + return r; + }; + await processHealthRequest(ledger, request, () => policy, transport, { now: () => now }); + assert.equal(sent[0].reason, 'REQUEST_EXPIRED'); + assert.equal(ledger.row(request.requestId)?.execution_count, 0); + })); +test('temporary authorization failure keeps request retryable, known requester removal rejects without execution', () => + fixture(async ({ ledger, sent, transport }) => { + transport.authorize = async () => { + throw new ClientError('IDENTITY_UNAVAILABLE'); + }; + await assert.rejects(processHealthRequest(ledger, request, () => policy, transport)); + assert.equal(ledger.row(request.requestId), undefined); + transport.authorize = async () => { + throw new ClientError('REQUESTER_ACCESS_DENIED'); + }; + await processHealthRequest(ledger, request, () => policy, transport); + assert.equal(sent[0].reason, 'REQUESTER_ACCESS_DENIED'); + assert.equal(ledger.row(request.requestId)?.execution_count, 0); + })); +test('request identity/content and target changes fail closed without execution', () => + fixture(async ({ ledger, transport }) => { + await assert.rejects( + processHealthRequest( + ledger, + { ...request, targetActorId: 'ag_' + 'e'.repeat(64) }, + () => policy, + transport, + ), + (e) => e instanceof ClientError && e.code === 'WORK_TARGET_MISMATCH', + ); + await processHealthRequest(ledger, request, () => policy, transport); + await assert.rejects( + processHealthRequest( + ledger, + { ...request, requesterUserId: 'user_other' }, + () => policy, + transport, + ), + (e) => e instanceof ClientError && e.code === 'WORK_REQUEST_CONFLICT', + ); + assert.equal(ledger.row(request.requestId)?.execution_count, 1); + })); + +test('private policy setup requires confirmation, rejects target drift and rejects permissive credential files', async () => { + const { healthCommand, configureHealth } = await import('../packages/reference/health.ts'); + const { publicInstance } = await import('../packages/contracts/instance.ts'); + const { writeFileSync, statSync, chmodSync, readFileSync } = await import('node:fs'); + const dir = mkdtempSync(join(realpathSync(tmpdir()), 'tob-health-config-')); + try { + const config = publicInstance({ + instanceId: 'test-health', + environment: 'uat', + server: 'https://health.example', + clientId: 'client_health', + issuer: 'https://api.workos.com/user_management/client_health', + authkitDomain: 'health.authkit.app', + }); + const credential = { + version: 1, + config, + organizationId: 'org_test', + channelId: policy.channelId, + actorId: policy.actorId, + ownerUserId: 'user_test', + name: 'Health check', + operationId: 'op-health-123456789', + secret: 'e'.repeat(64), + }; + const file = join(dir, 'credential.json'), + state = join(dir, 'state'); + writeFileSync(file, JSON.stringify(credential), { mode: 0o600 }); + await assert.rejects( + healthCommand([ + 'agent-health', + 'configure', + '--credential-file', + file, + '--state-dir', + state, + '--environment', + 'test', + '--allow-users', + 'user_test', + ]), + (e) => e instanceof ClientError && e.code === 'CONFIRM_LOCAL_POLICY_REQUIRED', + ); + const output = await healthCommand([ + 'agent-health', + 'configure', + '--credential-file', + file, + '--state-dir', + state, + '--environment', + 'test', + '--allow-users', + 'user_test', + '--confirm', + ]); + assert.equal(JSON.stringify(output).includes(credential.secret), false); + assert.equal(statSync(join(state, 'policy.json')).mode & 0o777, 0o600); + assert.equal(statSync(state).mode & 0o777, 0o700); + await configureHealth(file, state, 'test', []); + assert.deepEqual(JSON.parse(readFileSync(join(state, 'policy.json'), 'utf8')).allowedUsers, []); + writeFileSync(file, JSON.stringify({ ...credential, actorId: 'ag_' + 'f'.repeat(64) })); + await assert.rejects( + configureHealth(file, state, 'test', ['user_test']), + (e) => e instanceof ClientError && e.code === 'LOCAL_POLICY_TARGET_MISMATCH', + ); + chmodSync(file, 0o644); + await assert.rejects( + configureHealth(file, state, 'test', []), + (e) => e instanceof ClientError && e.code === 'PRIVATE_CREDENTIAL_FILE_REQUIRED', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 4796ba3..ff4c44c 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: 3, businessEnabled: true }); + expect(await ready.json()).toMatchObject({ ready: true, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 4, 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(); }); @@ -768,3 +768,56 @@ it('credential revoked while issuer verification yields cannot commit a later Ag 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:[]}); }); + +async function workFixture() { + const f=await agentFixture(); + const a=await (await f.issueAgent()).json() as {actorId:string}; + const expiresAt=new Date(Date.now()+300000).toISOString(); + const input={targetActorId:a.actorId,capability:'health-check',expiresAt}; + const create=(data:unknown=input,key='work-request-operation-0001',user='user_a')=>f.call(f.base+'/work-requests',user,'POST',data,key); + const agent=(id:string,action='authorize',body?:unknown,actorId=a.actorId)=>request('/api/agent-channels/'+f.channel.channelId+'/work-requests/'+id+'/'+action,{method:'POST',headers:{Authorization:'Bearer tob_agent_'+actorId+'.'+f.secret,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{})}); + return {...f,a,input,create,workAgent:agent}; +} +it('structured work requests and results commit once with authenticated authors and survive eviction',async()=>{ + const f=await workFixture();const r=await f.create();expect(r.status).toBe(200); + const v=await r.json() as {message:{payload:{requestId:string}}};const id=v.message.payload.requestId; + expect(v).toMatchObject({replayed:false,message:{kind:'work_request',authorType:'human',authorId:'user_a',payload:{requesterUserId:'user_a',targetActorId:f.a.actorId,capability:'health-check'}}}); + await evictDurableObject(env.CHANNELS.getByName(f.channel.channelId)); + expect(await (await f.create()).json()).toMatchObject({replayed:true,message:{sequence:1}}); + expect((await f.create({...f.input,expiresAt:new Date(Date.now()+200000).toISOString()})).status).toBe(409); + expect(await (await f.workAgent(id)).json()).toMatchObject({request:{requestId:id,requesterUserId:'user_a'}}); + const result={version:1,requestId:id,state:'completed',environment:'reference-a',executionCount:1,reason:'OK',diagnostics:{platform:'linux',architecture:'x64',runtime:'v24.21.0'}}; + expect(await (await f.workAgent(id,'result',result)).json()).toMatchObject({replayed:false,message:{sequence:2,kind:'work_result',authorId:f.a.actorId}}); + expect(await (await f.workAgent(id,'result',result)).json()).toMatchObject({replayed:true,message:{sequence:2}}); + expect((await f.workAgent(id,'result',{...result,environment:'changed'})).status).toBe(409); + expect(await (await f.call(f.base+'/messages')).json()).toMatchObject({messages:[{kind:'work_request'},{kind:'work_result'}]}); +}); +it('work rejects forged source, unbounded execution input, wrong target and expired authorization',async()=>{ + const f=await workFixture(); + for(const extra of [{requesterUserId:'user_b'},{shell:'echo hi'},{capability:'shell'},{expiresAt:new Date(Date.now()+700000).toISOString()}])expect((await f.create({...f.input,...extra})).status).toBe(400); + const v=await (await f.create()).json() as {message:{payload:{requestId:string}}};const id=v.message.payload.requestId; + const other=await (await f.issueAgent('other','another-agent-key-0001')).json() as {actorId:string}; + expect((await f.workAgent(id,'authorize',undefined,other.actorId)).status).toBe(403); + expect((await f.workAgent('wr_'+'a'.repeat(64))).status).toBe(403); + 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'");}); + expect(await (await f.workAgent(id)).json()).toEqual({error:{code:'REQUEST_EXPIRED'}}); +}); +it('member requests do not inherit owner authority and membership removal or rejoin invalidates work',async()=>{ + 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 r=await f.create(f.input,'member-work-operation-0001','user_b');expect(r.status).toBe(200); + const v=await r.json() as {message:{payload:{requestId:string}}};const id=v.message.payload.requestId; + expect((await f.workAgent(id)).status).toBe(200); + f.members[1].status='inactive';expect(await (await f.workAgent(id)).json()).toEqual({error:{code:'REQUESTER_ACCESS_DENIED'}}); + f.members[1].status='active';f.members[1].id='om_rejoined';expect((await f.workAgent(id)).status).toBe(403); +}); +it('revocation during work authorization yields no new start approval or result',async()=>{ + const f=await workFixture();const v=await(await f.create()).json() as {message:{payload:{requestId:string}}};const id=v.message.payload.requestId; + 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(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); +}); diff --git a/tools/deployment/smoke.mjs b/tools/deployment/smoke.mjs index 31addc4..46ded42 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: 3, businessEnabled: true }); + assert.deepEqual(ready.body, { ready: true, releaseSha: target.releaseSha, d1SchemaVersion: 5, orgSchemaVersion: 4, channelSchemaVersion: 4, businessEnabled: true }); const refusal = await get('/api/organizations', 'POST'); assert.equal(refusal.status, 401); assert.equal(refusal.body.error.code, 'UNAUTHENTICATED');