@huynle/hub 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The canonical agent event journal (lifecycle design: Events; decision xwzersmm). One bounded journal per user of
|
|
2
|
+
// compact events that GET /api/agent/events, MCP `events` and /api/agent/watch read:
|
|
3
|
+
//
|
|
4
|
+
// {id: "evt_…", at, type, subject: {sessionID?, rootID?, messageID?, senderID?, launchID?, machineID?}, data}
|
|
5
|
+
//
|
|
6
|
+
// Payloads are built from allowlists here and nowhere else: never prompts, message bodies, titles, blocker
|
|
7
|
+
// summaries, launch reasons or labels, credentials, or connection/pod/spoke IDs. Each record also keeps, for the
|
|
8
|
+
// reader only, its scope family (session, manage, message, launch) and its audience (the parties of a message, who
|
|
9
|
+
// started a launch), so an app or session token sees only what its scopes and identity allow.
|
|
10
|
+
//
|
|
11
|
+
// Storage is an EventLog (a Valkey stream per user in a cluster, process memory otherwise) bounded to
|
|
12
|
+
// AGENT_EVENTS_MAX events and AGENT_EVENTS_AGE_MS. An event id is assigned once, before any cross-pod fan-out;
|
|
13
|
+
// append claims it in the Kv first (SET NX), so appending the same id twice stores it once.
|
|
14
|
+
import { randomBytes } from "node:crypto"
|
|
15
|
+
import type { Clock, EventLog, Kv } from "../backend/types"
|
|
16
|
+
import type { LiveStatus } from "../registry"
|
|
17
|
+
import { partyKey } from "./envelope"
|
|
18
|
+
import type { Launch } from "./launch-store"
|
|
19
|
+
import { addressOf, type Message } from "./messages"
|
|
20
|
+
|
|
21
|
+
export const AGENT_EVENTS_MAX = 10_000
|
|
22
|
+
export const AGENT_EVENTS_AGE_MS = 86_400_000
|
|
23
|
+
/** Record format version (readers reject others rather than skip them). */
|
|
24
|
+
export const AGENT_EVENT_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
|
|
27
|
+
export type EventFamily = "session" | "manage" | "message" | "launch"
|
|
28
|
+
|
|
29
|
+
export type EventSubject = Readonly<Record<string, string>>
|
|
30
|
+
export type EventData = Readonly<Record<string, string | number | boolean>>
|
|
31
|
+
|
|
32
|
+
/** An event as readers receive it. */
|
|
33
|
+
export interface AgentEvent {
|
|
34
|
+
readonly id: string
|
|
35
|
+
readonly at: number
|
|
36
|
+
readonly type: string
|
|
37
|
+
readonly subject: EventSubject
|
|
38
|
+
readonly data: EventData
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Who else may read an event beyond the user: the party keys of a message, who started a launch. */
|
|
42
|
+
export interface EventAudience {
|
|
43
|
+
readonly parties?: readonly string[]
|
|
44
|
+
readonly by?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a writer appends. */
|
|
48
|
+
export interface EventInput {
|
|
49
|
+
/** A stable id (assigned before fan-out); a new one when absent. */
|
|
50
|
+
readonly id?: string
|
|
51
|
+
readonly type: string
|
|
52
|
+
readonly family: EventFamily
|
|
53
|
+
readonly subject: EventSubject
|
|
54
|
+
readonly data: EventData
|
|
55
|
+
readonly audience?: EventAudience
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A journal record: the event, its reader-only scope fields, and its position in the user's log. */
|
|
59
|
+
export interface EventRecord {
|
|
60
|
+
readonly position: string
|
|
61
|
+
readonly event: AgentEvent
|
|
62
|
+
readonly family: EventFamily
|
|
63
|
+
readonly audience?: EventAudience
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
|
|
67
|
+
export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
|
|
68
|
+
|
|
69
|
+
const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
|
|
70
|
+
const EVENT_ID = /^evt_[0-9a-f]{20}$/
|
|
71
|
+
|
|
72
|
+
export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
|
|
73
|
+
|
|
74
|
+
export interface AgentEventsOptions {
|
|
75
|
+
readonly log: EventLog
|
|
76
|
+
readonly kv: Kv
|
|
77
|
+
readonly now?: Clock
|
|
78
|
+
readonly onAppend?: (userID: string, record: Pick<EventRecord, "event" | "family" | "audience">) => void | Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class AgentEvents {
|
|
82
|
+
private readonly log: EventLog
|
|
83
|
+
private readonly kv: Kv
|
|
84
|
+
private readonly now: Clock
|
|
85
|
+
private readonly onAppend?: AgentEventsOptions["onAppend"]
|
|
86
|
+
/** Readers waiting for this process's next append, per user (long polling; other pods' appends are rechecked). */
|
|
87
|
+
private readonly waiters = new Map<string, Set<() => void>>()
|
|
88
|
+
|
|
89
|
+
constructor(options: AgentEventsOptions) {
|
|
90
|
+
this.log = options.log
|
|
91
|
+
this.kv = options.kv
|
|
92
|
+
this.now = options.now ?? Date.now
|
|
93
|
+
this.onAppend = options.onAppend
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Stores an event once per id; returns its id. */
|
|
97
|
+
async append(userID: string, input: EventInput): Promise<string> {
|
|
98
|
+
const id = input.id ?? newEventID()
|
|
99
|
+
if (!EVENT_ID.test(id)) throw new Error("Invalid event id")
|
|
100
|
+
const claim = `aev:${userID}:${id}`
|
|
101
|
+
if (!(await this.kv.set(claim, "1", { nx: true, px: AGENT_EVENTS_AGE_MS }))) return id
|
|
102
|
+
const record = {
|
|
103
|
+
type: input.type,
|
|
104
|
+
v: AGENT_EVENT_VERSION,
|
|
105
|
+
id,
|
|
106
|
+
at: this.now(),
|
|
107
|
+
family: input.family,
|
|
108
|
+
subject: input.subject,
|
|
109
|
+
data: input.data,
|
|
110
|
+
...(input.audience ? { audience: input.audience } : {}),
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await this.log.append(userID, record)
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
// Not stored: a retry with the same id must be able to store it.
|
|
116
|
+
await this.kv.del(claim).catch(() => {})
|
|
117
|
+
throw cause
|
|
118
|
+
}
|
|
119
|
+
await this.onAppend?.(userID, {
|
|
120
|
+
event: { id, at: record.at, type: input.type, subject: input.subject, data: input.data },
|
|
121
|
+
family: input.family,
|
|
122
|
+
...(input.audience ? { audience: input.audience } : {}),
|
|
123
|
+
})
|
|
124
|
+
const waiting = this.waiters.get(userID)
|
|
125
|
+
if (waiting) for (const wake of [...waiting]) wake()
|
|
126
|
+
return id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resolves on this process's next append for the user, after `ms`, or when `signal` aborts. */
|
|
130
|
+
appended(userID: string, ms: number, signal?: AbortSignal): Promise<void> {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let set = this.waiters.get(userID)
|
|
133
|
+
if (!set) this.waiters.set(userID, (set = new Set()))
|
|
134
|
+
const done = () => {
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
signal?.removeEventListener("abort", done)
|
|
137
|
+
set!.delete(done)
|
|
138
|
+
if (set!.size === 0 && this.waiters.get(userID) === set) this.waiters.delete(userID)
|
|
139
|
+
resolve()
|
|
140
|
+
}
|
|
141
|
+
const timer = setTimeout(done, ms)
|
|
142
|
+
set.add(done)
|
|
143
|
+
if (signal?.aborted) done()
|
|
144
|
+
else signal?.addEventListener("abort", done, { once: true })
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The position of the user's latest record; a reader holding it gets every later one. */
|
|
149
|
+
latest(userID: string): Promise<string> {
|
|
150
|
+
return this.log.latest(userID)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async since(userID: string, position: string): Promise<EventsSince> {
|
|
154
|
+
const result = await this.log.since(userID, position)
|
|
155
|
+
if ("resync" in result) return { reset: true }
|
|
156
|
+
const records: EventRecord[] = []
|
|
157
|
+
for (const logged of result.events) {
|
|
158
|
+
const m = logged.message as Record<string, unknown>
|
|
159
|
+
// A record this version cannot read is a reset, never a silent skip.
|
|
160
|
+
if (m.v !== AGENT_EVENT_VERSION || typeof m.id !== "string" || typeof m.at !== "number" || !FAMILIES.has(m.family as string)) return { reset: true }
|
|
161
|
+
records.push({
|
|
162
|
+
position: logged.id,
|
|
163
|
+
event: { id: m.id, at: m.at, type: String(m.type), subject: (m.subject ?? {}) as EventSubject, data: (m.data ?? {}) as EventData },
|
|
164
|
+
family: m.family as EventFamily,
|
|
165
|
+
...(m.audience ? { audience: m.audience as EventAudience } : {}),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return { records }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- Compact builders (allowlists) ------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** A stored agent message changed (sent, delivered, answered…): never its text. */
|
|
175
|
+
export function messageEvent(message: Message): EventInput {
|
|
176
|
+
const { id, kind, state, from, recipient, replyTo, delivery } = message
|
|
177
|
+
return {
|
|
178
|
+
type: "message.changed",
|
|
179
|
+
family: "message",
|
|
180
|
+
subject: {
|
|
181
|
+
messageID: id,
|
|
182
|
+
...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
|
|
183
|
+
...(from.kind === "session" ? { senderID: from.sessionID } : {}),
|
|
184
|
+
},
|
|
185
|
+
data: { kind, state, from: addressOf(from), to: message.to, ...(replyTo ? { replyTo } : {}), ...(delivery ? { delivery } : {}) },
|
|
186
|
+
audience: { parties: [partyKey(from), partyKey(recipient)] },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A launch changed: never its prompt, reason or labels. */
|
|
191
|
+
export function launchEvent(launch: Launch): EventInput {
|
|
192
|
+
return {
|
|
193
|
+
type: "launch.changed",
|
|
194
|
+
family: "launch",
|
|
195
|
+
subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
|
|
196
|
+
data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
|
|
197
|
+
...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A session's archive state changed. */
|
|
202
|
+
export const archiveEvent = (sessionID: string, archived: boolean): EventInput => ({ type: "session.archived", family: "manage", subject: { sessionID }, data: { archived } })
|
|
203
|
+
|
|
204
|
+
export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
|
|
205
|
+
|
|
206
|
+
/** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
|
|
207
|
+
export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
|
|
208
|
+
|
|
209
|
+
/** A session claimed (held) or released a role; role and project are validated slugs. */
|
|
210
|
+
export function roleEvent(sessionID: string, held: boolean, role: { role: string; project?: string; generation?: number; exclusive?: boolean }): EventInput {
|
|
211
|
+
return {
|
|
212
|
+
type: held ? "role.claimed" : "role.released",
|
|
213
|
+
family: "manage",
|
|
214
|
+
subject: { sessionID },
|
|
215
|
+
data: {
|
|
216
|
+
role: role.role,
|
|
217
|
+
...(role.project !== undefined ? { project: role.project } : {}),
|
|
218
|
+
...(held && role.generation !== undefined ? { generation: role.generation } : {}),
|
|
219
|
+
...(held && role.exclusive ? { exclusive: true } : {}),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface Known {
|
|
225
|
+
state: string
|
|
226
|
+
blockers: Set<string>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* State and blocker changes of status trees, against what was seen before (one per writer). Bounded by the live
|
|
231
|
+
* trees: a tree's entry is replaced by each status of it and dropped by forget().
|
|
232
|
+
*/
|
|
233
|
+
export class StatusDiff {
|
|
234
|
+
private readonly trees = new Map<string, Map<string, Known>>()
|
|
235
|
+
|
|
236
|
+
events(rootID: string, sessions: readonly LiveStatus[]): EventInput[] {
|
|
237
|
+
const before = this.trees.get(rootID) ?? new Map<string, Known>()
|
|
238
|
+
const after = new Map<string, Known>()
|
|
239
|
+
const out: EventInput[] = []
|
|
240
|
+
for (const entry of sessions) {
|
|
241
|
+
const known = before.get(entry.id)
|
|
242
|
+
const subject = { sessionID: entry.id, rootID }
|
|
243
|
+
const open = new Set(entry.blockers.map((b) => b.id))
|
|
244
|
+
for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
|
|
245
|
+
for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
|
|
246
|
+
if (known?.state !== entry.state) out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}) } })
|
|
247
|
+
after.set(entry.id, { state: entry.state, blockers: open })
|
|
248
|
+
}
|
|
249
|
+
this.trees.set(rootID, after)
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Drops a tree that is gone. */
|
|
254
|
+
forget(rootID: string): void {
|
|
255
|
+
this.trees.delete(rootID)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
roots(): string[] {
|
|
259
|
+
return [...this.trees.keys()]
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Presence per user (sessions and machines) diffed against the previous view (the catalog writer's). */
|
|
264
|
+
export interface PresenceView {
|
|
265
|
+
readonly sessions: ReadonlySet<string>
|
|
266
|
+
/** Machine id → name, for the machines with a connected process. */
|
|
267
|
+
readonly machines: ReadonlyMap<string, string>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function presenceEvents(before: PresenceView, after: PresenceView): EventInput[] {
|
|
271
|
+
const out: EventInput[] = []
|
|
272
|
+
for (const id of after.sessions) if (!before.sessions.has(id)) out.push({ type: "session.added", family: "session", subject: { sessionID: id }, data: {} })
|
|
273
|
+
for (const id of before.sessions) if (!after.sessions.has(id)) out.push({ type: "session.removed", family: "session", subject: { sessionID: id }, data: {} })
|
|
274
|
+
for (const [id, name] of after.machines) if (!before.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: true } })
|
|
275
|
+
for (const [id, name] of before.machines) if (!after.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: false } })
|
|
276
|
+
return out
|
|
277
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Grant requests (design §5, §7; P3-T9): an agent asks the user for a grant. Grants are created only by users
|
|
2
|
+
// (grant-routes.ts); a session (any token) or an app token (scope messages:send) may only request one, and the
|
|
3
|
+
// user's tap in the console creates it.
|
|
4
|
+
//
|
|
5
|
+
// - The body is {grant, why}: the grant as POST /api/agent/grants takes it, validated the same way (validateGrant)
|
|
6
|
+
// when requested and again, as the user's, when approved (Grants.create). The holder defaults to the requester
|
|
7
|
+
// (`ses_…`, `app:<token id>`); it may name only the requester itself (`ses_…`, its `@name`: stored as the session
|
|
8
|
+
// id, so a name moving on never moves the grant) or a reserved `@@role` the requesting session holds now.
|
|
9
|
+
// Another session or app is forbidden; a session that is not the user's is not_found.
|
|
10
|
+
// - It opens a Decision to @me (tap tier, high urgency; options approve / deny, deny safe) whose question
|
|
11
|
+
// summarises holder, projects, verbs, permissions allow/never, always, the hourly cap, expiry and the why
|
|
12
|
+
// (sanitised); its action (kind "grant", the canonical grant body) is the hub's, never the asker's.
|
|
13
|
+
// - Approve: the grant is created AS THE USER (owner the user); the requester's reply carries the grant id. The
|
|
14
|
+
// holder gone by then (not_found) closes the Decision instead. Deny: nothing is created; the reply says so.
|
|
15
|
+
// No editing on approval (yet): the user may create an edited grant directly and deny the request.
|
|
16
|
+
// - Limits: one open request per requester (another is rate_limited with the open Decision's id; a version-checked
|
|
17
|
+
// reservation settles races) and GRANT_REQUESTS_PER_HOUR per requester an hour (Counters). Asks to @me also
|
|
18
|
+
// count against the message limits.
|
|
19
|
+
// - Audit: the request (grant.request, needs_user, escalated) and its outcome (grant.approve ok / grant.deny
|
|
20
|
+
// refused, as the user).
|
|
21
|
+
import type { Counters, StateStore } from "../backend/types"
|
|
22
|
+
import type { AgentActor, AppActor, SessionActor, UserActor } from "./actor"
|
|
23
|
+
import type { AgentAudit } from "./audit"
|
|
24
|
+
import { type ActOutcome, type Decision, type Decisions, WHY_MAX } from "./decisions"
|
|
25
|
+
import type { Directory } from "./directory"
|
|
26
|
+
import { type Party, partyFromActor, partyKey, partyLabel, sanitizeLabel } from "./envelope"
|
|
27
|
+
import { AgentError } from "./errors"
|
|
28
|
+
import type { Grants } from "./grants"
|
|
29
|
+
import type { Message } from "./messages"
|
|
30
|
+
import { parseAddress } from "./names"
|
|
31
|
+
import { type GrantSpec, validateGrant } from "./policy"
|
|
32
|
+
|
|
33
|
+
/** Grant requests each requester opens an hour. */
|
|
34
|
+
export const GRANT_REQUESTS_PER_HOUR = 5
|
|
35
|
+
/** Characters of the question (lists are cut to fit). */
|
|
36
|
+
const QUESTION_MAX = 4_000
|
|
37
|
+
/** A reservation older than this (its opener crashed) no longer blocks a new request. */
|
|
38
|
+
const RESERVATION_MS = 60_000
|
|
39
|
+
const HOUR_MS = 3_600_000
|
|
40
|
+
const COLLECTION = "grant-requests"
|
|
41
|
+
const BODY_FIELDS = ["grant", "why"]
|
|
42
|
+
|
|
43
|
+
/** requester → its latest request's Decision (without decisionID while it is being opened). */
|
|
44
|
+
interface RequestRecord {
|
|
45
|
+
readonly at: number
|
|
46
|
+
readonly decisionID?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GrantRequestsDeps {
|
|
50
|
+
readonly state: StateStore
|
|
51
|
+
readonly counters: Counters
|
|
52
|
+
readonly decisions: Pick<Decisions, "ask" | "get">
|
|
53
|
+
readonly grants: Pick<Grants, "create">
|
|
54
|
+
readonly directory: Pick<Directory, "resolve">
|
|
55
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
56
|
+
/** The requester as envelopes name it; default: from the actor alone. */
|
|
57
|
+
readonly describe?: (userID: string, actor: AgentActor) => Party
|
|
58
|
+
/** The Decision's ask was stored (the console's message feed). */
|
|
59
|
+
readonly published?: (userID: string, message: Message) => void
|
|
60
|
+
readonly now?: () => number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
64
|
+
const forbidden = (message: string) => new AgentError("forbidden", message, { hint: "Request a grant held by yourself (the default), or by a reserved role you hold" })
|
|
65
|
+
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
66
|
+
|
|
67
|
+
export class GrantRequests {
|
|
68
|
+
private readonly now: () => number
|
|
69
|
+
|
|
70
|
+
constructor(private readonly deps: GrantRequestsDeps) {
|
|
71
|
+
this.now = deps.now ?? Date.now
|
|
72
|
+
this.act = this.act.bind(this)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Opens the Decision for the agent's request. Throws invalid, forbidden, not_found or rate_limited. */
|
|
76
|
+
async request(userID: string, actor: AgentActor, body: unknown): Promise<{ decision: Decision; message: Message }> {
|
|
77
|
+
if (actor.kind === "user") throw invalid("You are the user: create the grant directly (POST /api/agent/grants)")
|
|
78
|
+
if (!isObject(body)) throw invalid("A grant request is {grant, why}")
|
|
79
|
+
for (const key of Object.keys(body)) if (!BODY_FIELDS.includes(key)) throw invalid(`Unknown grant request field ${key}`)
|
|
80
|
+
if (!isObject(body.grant)) throw invalid("grant is the grant to request (as POST /api/agent/grants takes it)")
|
|
81
|
+
const why = typeof body.why === "string" && body.why.length <= WHY_MAX * 4 ? sanitizeLabel(body.why, WHY_MAX) : ""
|
|
82
|
+
if (!why) throw invalid(`why is the reason the user reads (1–${WHY_MAX} characters)`)
|
|
83
|
+
const own = ownHolder(actor)
|
|
84
|
+
const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
|
|
85
|
+
const holder = this.holder(userID, actor, spec.holder, own)
|
|
86
|
+
const grant = canonical({ ...spec, holder })
|
|
87
|
+
|
|
88
|
+
const requester = this.describe(userID, actor)
|
|
89
|
+
const key = partyKey(requester)
|
|
90
|
+
const { state, counters, decisions } = this.deps
|
|
91
|
+
const existing = await state.get<RequestRecord>(userID, COLLECTION, key)
|
|
92
|
+
if (existing?.value.decisionID) {
|
|
93
|
+
const open = await decisions.get(userID, existing.value.decisionID)
|
|
94
|
+
if (open?.state === "open") {
|
|
95
|
+
throw new AgentError("rate_limited", `Your grant request ${open.id} still waits for the user`, { extra: { retryAfter: 60, decisionID: open.id } })
|
|
96
|
+
}
|
|
97
|
+
} else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
|
|
98
|
+
throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
|
|
99
|
+
}
|
|
100
|
+
const at = this.now()
|
|
101
|
+
const count = await counters.incr(`grant-requests:${userID}:${key}`, HOUR_MS)
|
|
102
|
+
if (count > GRANT_REQUESTS_PER_HOUR) {
|
|
103
|
+
throw new AgentError("rate_limited", `At most ${GRANT_REQUESTS_PER_HOUR} grant requests an hour`, { extra: { retryAfter: HOUR_MS / 1000 } })
|
|
104
|
+
}
|
|
105
|
+
const reserved = await state.put(userID, COLLECTION, key, { at } satisfies RequestRecord, { ifVersion: existing?.version ?? 0 })
|
|
106
|
+
if (!reserved.ok) throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
|
|
107
|
+
let opened: { decision: Decision; message: Message }
|
|
108
|
+
try {
|
|
109
|
+
opened = await decisions.ask(
|
|
110
|
+
userID,
|
|
111
|
+
actor,
|
|
112
|
+
{
|
|
113
|
+
question: question(partyLabel(requester), spec, holder, why),
|
|
114
|
+
options: [
|
|
115
|
+
{ id: "approve", label: "Approve grant" },
|
|
116
|
+
{ id: "deny", label: "Deny", safe: true },
|
|
117
|
+
],
|
|
118
|
+
urgency: "high",
|
|
119
|
+
tier: "tap",
|
|
120
|
+
},
|
|
121
|
+
{ action: { kind: "grant", requester: key, grant } },
|
|
122
|
+
)
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
|
|
125
|
+
throw cause
|
|
126
|
+
}
|
|
127
|
+
await state.put(userID, COLLECTION, key, { at, decisionID: opened.decision.id } satisfies RequestRecord, { ifVersion: reserved.version })
|
|
128
|
+
this.deps.published?.(userID, opened.message)
|
|
129
|
+
await this.deps.audit?.record(userID, { actor: requester, verb: "grant.request", target: target(actor), decision: "needs_user", reason: why, outcome: "escalated" })
|
|
130
|
+
return opened
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Decisions' action runner: approve creates the grant as the answering user; deny creates nothing. */
|
|
134
|
+
async act(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> {
|
|
135
|
+
const action = decision.action
|
|
136
|
+
if (!action || action.kind !== "grant" || by.userID !== userID) return "gone"
|
|
137
|
+
const about = decision.from.kind === "session" ? { sessionID: decision.from.sessionID } : {}
|
|
138
|
+
const reason = `Decision ${decision.id}`
|
|
139
|
+
if (choice.option !== "approve") {
|
|
140
|
+
await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.deny", target: about, decision: "allow", reason, outcome: "refused" })
|
|
141
|
+
return "done"
|
|
142
|
+
}
|
|
143
|
+
let created
|
|
144
|
+
try {
|
|
145
|
+
created = await this.deps.grants.create(userID, action.grant)
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
if (!(cause instanceof AgentError) || cause.code !== "not_found") throw cause
|
|
148
|
+
await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, decision: "allow", reason, outcome: "gone" })
|
|
149
|
+
return { outcome: "gone", reason: "The requested holder is gone" }
|
|
150
|
+
}
|
|
151
|
+
await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, grant: created.id, decision: "allow", reason, outcome: "ok" })
|
|
152
|
+
return { outcome: "done", note: `Grant ${created.id} created (holder ${created.holder}, until ${new Date(created.expiresAt).toISOString()})` }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
private describe(userID: string, actor: AgentActor): Party {
|
|
158
|
+
return this.deps.describe?.(userID, actor) ?? partyFromActor(actor)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The holder the request may name (see the header), as stored. */
|
|
162
|
+
private holder(userID: string, actor: SessionActor | AppActor, holder: string, own: string): string {
|
|
163
|
+
if (holder === own) return own
|
|
164
|
+
if (holder.startsWith("app:") || actor.kind === "app") throw forbidden("A grant request may name only the requester as its holder")
|
|
165
|
+
const holds = (sessions: readonly { readonly sessionID: string }[]) => sessions.some((s) => s.sessionID === actor.sessionID)
|
|
166
|
+
if (parseAddress(holder)?.kind === "role") {
|
|
167
|
+
let held = false
|
|
168
|
+
try {
|
|
169
|
+
held = holds(this.deps.directory.resolve(userID, holder).sessions)
|
|
170
|
+
} catch {
|
|
171
|
+
held = false
|
|
172
|
+
}
|
|
173
|
+
if (!held) throw forbidden(`You do not hold ${holder}`)
|
|
174
|
+
return holder
|
|
175
|
+
}
|
|
176
|
+
// A session or name that is not the user's is not_found (the resolver's tenancy).
|
|
177
|
+
const { sessions } = this.deps.directory.resolve(userID, holder)
|
|
178
|
+
if (sessions.length !== 1 || !holds(sessions)) throw forbidden("A grant request may name only the requester as its holder")
|
|
179
|
+
return actor.sessionID
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The holder a request defaults to: the session's id, or `app:<token id>`. */
|
|
184
|
+
const ownHolder = (actor: SessionActor | AppActor): string => (actor.kind === "session" ? actor.sessionID : `app:${actor.tokenID}`)
|
|
185
|
+
|
|
186
|
+
const target = (actor: SessionActor | AppActor) => (actor.kind === "session" ? { sessionID: actor.sessionID } : {})
|
|
187
|
+
|
|
188
|
+
/** The spec as the body Grants.create takes (expires in seconds). */
|
|
189
|
+
const canonical = (spec: GrantSpec): Record<string, unknown> => ({
|
|
190
|
+
holder: spec.holder,
|
|
191
|
+
scope: { projects: [...spec.scope.projects] },
|
|
192
|
+
allow: [...spec.allow],
|
|
193
|
+
permissions: { allow: [...spec.permissions.allow], never: [...spec.permissions.never] },
|
|
194
|
+
allowAlways: spec.allowAlways,
|
|
195
|
+
maxActionsPerHour: spec.maxActionsPerHour,
|
|
196
|
+
expires: Math.round(spec.expiresInMs / 1000),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
/** `90m`, `8h`, `2d`; seconds otherwise. */
|
|
200
|
+
function duration(ms: number): string {
|
|
201
|
+
for (const [unit, size] of [["d", 86_400_000], ["h", 3_600_000], ["m", 60_000]] as const) if (ms % size === 0) return `${ms / size}${unit}`
|
|
202
|
+
return `${Math.round(ms / 1000)}s`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** "@orchestrator requests a grant: holder ses_o; projects /p; verbs approve, deny; …. Why: …" (bounded). */
|
|
206
|
+
function question(by: string, spec: GrantSpec, holder: string, why: string): string {
|
|
207
|
+
const list = (values: readonly string[]) => (values.length ? values.join(", ") : "none")
|
|
208
|
+
const text = [
|
|
209
|
+
`${by} requests a grant: holder ${holder}`,
|
|
210
|
+
`projects ${list(spec.scope.projects)}`,
|
|
211
|
+
`verbs ${list(spec.allow)}`,
|
|
212
|
+
`permissions allow ${list(spec.permissions.allow)}`,
|
|
213
|
+
`never ${list(spec.permissions.never)}`,
|
|
214
|
+
`always ${spec.allowAlways ? "yes" : "no"}`,
|
|
215
|
+
`up to ${spec.maxActionsPerHour} actions an hour`,
|
|
216
|
+
`expires ${duration(spec.expiresInMs)} after approval`,
|
|
217
|
+
].join("; ")
|
|
218
|
+
const tail = `. Why: ${why}`
|
|
219
|
+
const head = text.length + tail.length > QUESTION_MAX ? `${text.slice(0, Math.max(0, QUESTION_MAX - tail.length - 1))}…` : text
|
|
220
|
+
return `${head}${tail}`
|
|
221
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
|
|
2
|
+
// writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
|
|
3
|
+
// or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
|
|
4
|
+
//
|
|
5
|
+
// POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
|
|
6
|
+
// GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
|
|
7
|
+
// DELETE /api/agent/grants/:id revoke (gone when already ended)
|
|
8
|
+
//
|
|
9
|
+
// Agents only request one (grant-requests.ts): POST /api/agent/grants/requests {grant, why} from a session (any of
|
|
10
|
+
// its tokens) or an app token with messages:send → 201 {message, decision} (a tap Decision for the user); the user
|
|
11
|
+
// gets invalid (create it directly).
|
|
12
|
+
import type { GrantRequests } from "./grant-requests"
|
|
13
|
+
import { type Grant, type Grants, grantState } from "./grants"
|
|
14
|
+
import type { AgentRoute } from "./routes"
|
|
15
|
+
|
|
16
|
+
// A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
|
|
17
|
+
const PREFIX = "/api/agent/grants"
|
|
18
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
19
|
+
|
|
20
|
+
/** A grant as the API shows it: without the owner, with its state now. */
|
|
21
|
+
export function grantView(grant: Grant, at: number) {
|
|
22
|
+
const { owner: _owner, ...rest } = grant
|
|
23
|
+
return { ...rest, state: grantState(grant, at) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function grantRoutes(grants: Grants, now: () => number = Date.now, requests?: GrantRequests): AgentRoute[] {
|
|
27
|
+
const one = (grant: Grant, status = 200) => Response.json({ grant: grantView(grant, now()) }, { status, headers: NO_STORE })
|
|
28
|
+
return [
|
|
29
|
+
...(requests
|
|
30
|
+
? [
|
|
31
|
+
{
|
|
32
|
+
method: "POST",
|
|
33
|
+
path: `${PREFIX}/requests`,
|
|
34
|
+
allow: { session: true, scope: "messages:send" },
|
|
35
|
+
handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await request.json()), { status: 201, headers: NO_STORE }),
|
|
36
|
+
} satisfies AgentRoute,
|
|
37
|
+
]
|
|
38
|
+
: []),
|
|
39
|
+
{
|
|
40
|
+
method: "POST",
|
|
41
|
+
path: PREFIX,
|
|
42
|
+
allow: {},
|
|
43
|
+
handle: async ({ request, user }) => one(await grants.create(user.id, await request.json()), 201),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
method: "GET",
|
|
47
|
+
path: PREFIX,
|
|
48
|
+
allow: {},
|
|
49
|
+
handle: async ({ user }) => Response.json({ grants: (await grants.list(user.id)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: `${PREFIX}/:id`,
|
|
54
|
+
allow: {},
|
|
55
|
+
handle: async ({ params, user }) => one(await grants.get(user.id, params.id!)),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
method: "PUT",
|
|
59
|
+
path: `${PREFIX}/:id`,
|
|
60
|
+
allow: {},
|
|
61
|
+
handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await request.json())),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
method: "DELETE",
|
|
65
|
+
path: `${PREFIX}/:id`,
|
|
66
|
+
allow: {},
|
|
67
|
+
handle: async ({ params, user }) => one(await grants.revoke(user.id, params.id!)),
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
}
|