@huynle/hub 0.6.1 → 0.7.1
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 +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -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 +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -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 +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -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 +60 -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 +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -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 +79 -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 +142 -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 +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -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 +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -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 +244 -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 +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- 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 +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- 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 +68 -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/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -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/index.html +9 -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,599 @@
|
|
|
1
|
+
// The acting engine (design §5, §7, §11.4; decision wvqeuis0; exploration hcw63slg): approve, deny, answer, prompt
|
|
2
|
+
// and stop a session for its user. acting-routes.ts maps HTTP onto it; actions.ts runs the spoke side.
|
|
3
|
+
//
|
|
4
|
+
// Who may act:
|
|
5
|
+
// - Users (cookie, device token) always may, on their own sessions: tenancy only, no grant, no kill switch.
|
|
6
|
+
// - Sessions (through their act token only, never a shell token) and app tokens (scope sessions:act) act under a
|
|
7
|
+
// grant (grants.ts) of their user, in this order:
|
|
8
|
+
// tenancy → kill switch → grant → self-tree → hub never → grant never → allow (else needs_user) → hourly cap →
|
|
9
|
+
// kill switch again → spoke.
|
|
10
|
+
// · tenancy: the address resolves among the user's sessions; a request or form must be pending in that tree
|
|
11
|
+
// (otherwise not_found, like another user's).
|
|
12
|
+
// · self-tree: a session never answers a permission request or form in its own session tree (forbidden).
|
|
13
|
+
// · never (hub-wide agents.never, then every usable grant's never): beats any allow; tried on the request as
|
|
14
|
+
// asked and on its normalised shell form (normaliseShell), a hit on either is forbidden.
|
|
15
|
+
// · allow: the first usable grant whose policy covers the request; none → needs_user (403) with the evaluation
|
|
16
|
+
// {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
|
|
17
|
+
// first; a request without parsed details, or truncated, is always needs_user (tap).
|
|
18
|
+
// · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
|
|
19
|
+
// - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
|
|
20
|
+
// paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
|
|
21
|
+
// - Races: the request answered elsewhere first is gone (410).
|
|
22
|
+
import {
|
|
23
|
+
DELIVER_TEXT_MAX,
|
|
24
|
+
type Delivered,
|
|
25
|
+
type DeliveryMode,
|
|
26
|
+
type FormAnswer,
|
|
27
|
+
isDeliverResult,
|
|
28
|
+
PERMISSION_MESSAGE_MAX,
|
|
29
|
+
type PermissionDecision,
|
|
30
|
+
type PromptImage,
|
|
31
|
+
promptImagesProblem,
|
|
32
|
+
type ResultFor,
|
|
33
|
+
type ServerRequest,
|
|
34
|
+
} from "../../protocol"
|
|
35
|
+
import { type Actions, type Unsupported, unsupportedMessage } from "../actions"
|
|
36
|
+
import { BusyError, type Registry } from "../registry"
|
|
37
|
+
import { SpokeError } from "../spoke-ws"
|
|
38
|
+
import type { PermissionRequest } from "../../status"
|
|
39
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
40
|
+
import type { AgentActor } from "./actor"
|
|
41
|
+
import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
|
|
42
|
+
import type { Tier } from "./decisions"
|
|
43
|
+
import type { Directory } from "./directory"
|
|
44
|
+
import { type Party, partyFromActor, partyLabel, sanitizeText } from "./envelope"
|
|
45
|
+
import { AgentError } from "./errors"
|
|
46
|
+
import type { Grant, Grants } from "./grants"
|
|
47
|
+
import { newMessageID } from "./messages"
|
|
48
|
+
import { evaluatePermission, formatPattern, type GrantVerb, parsePattern, type Pattern, riskTier } from "./policy"
|
|
49
|
+
|
|
50
|
+
/** What the engine reads from the Registry. */
|
|
51
|
+
export type ActingRegistry = Pick<
|
|
52
|
+
Registry,
|
|
53
|
+
"sessionTenant" | "session" | "rootOf" | "permissionRequest" | "permissionTargets" | "formTargets" | "owner" | "supports" | "harnessOf"
|
|
54
|
+
>
|
|
55
|
+
|
|
56
|
+
/** Why a request needs the user, as the policy saw it. */
|
|
57
|
+
export interface Evaluation {
|
|
58
|
+
readonly verdict: "outside"
|
|
59
|
+
readonly reason: string
|
|
60
|
+
/** Set by the hub (decision wvqeuis0 §7), never by the agent. */
|
|
61
|
+
readonly tier: Tier
|
|
62
|
+
/** The usable grants it was evaluated against. */
|
|
63
|
+
readonly grants: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An agent's approval outside its grant: what the user is asked to decide (P3-T8 opens a Decision). */
|
|
67
|
+
export interface Escalation {
|
|
68
|
+
readonly userID: string
|
|
69
|
+
readonly actor: AgentActor
|
|
70
|
+
readonly verb: "approve"
|
|
71
|
+
/** The addressed session. */
|
|
72
|
+
readonly target: string
|
|
73
|
+
/** The session that asked, and its tree. */
|
|
74
|
+
readonly sessionID: string
|
|
75
|
+
readonly rootID: string
|
|
76
|
+
readonly requestID: string
|
|
77
|
+
readonly request?: PermissionRequest
|
|
78
|
+
readonly always: boolean
|
|
79
|
+
readonly evaluation: Evaluation
|
|
80
|
+
/** The agent's reason (`--why`): its recommendation to approve. */
|
|
81
|
+
readonly why?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
|
|
85
|
+
export type Escalate = (escalation: Escalation) => Promise<{ readonly decisionID?: string } | void>
|
|
86
|
+
|
|
87
|
+
export interface ActingDeps {
|
|
88
|
+
readonly registry: ActingRegistry
|
|
89
|
+
readonly directory: Pick<Directory, "resolveOne">
|
|
90
|
+
readonly actions: Actions
|
|
91
|
+
readonly spokes: { request(connID: string, req: ServerRequest): Promise<unknown> }
|
|
92
|
+
readonly grants: Pick<Grants, "usable" | "consume">
|
|
93
|
+
readonly acting: Pick<ActingSwitch, "ensureActing" | "hubNever">
|
|
94
|
+
readonly audit: Pick<AgentAudit, "record">
|
|
95
|
+
/** needs_user escalations (P3-T8); without it needs_user is answered and audited as refused. */
|
|
96
|
+
readonly escalate?: Escalate
|
|
97
|
+
readonly newMessageID?: () => string
|
|
98
|
+
/** Longest prompt a user sends (the console's limit); agents' prompts are at most DELIVER_TEXT_MAX. */
|
|
99
|
+
readonly maxPromptChars?: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Who acts, for which user. */
|
|
103
|
+
export interface Caller {
|
|
104
|
+
readonly actor: AgentActor
|
|
105
|
+
readonly userID: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ApproveInput {
|
|
109
|
+
readonly requestID: string
|
|
110
|
+
readonly always?: boolean
|
|
111
|
+
readonly why?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Approved {
|
|
115
|
+
readonly sessionID: string
|
|
116
|
+
readonly requestID: string
|
|
117
|
+
readonly decision: PermissionDecision
|
|
118
|
+
readonly grant?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DenyInput {
|
|
122
|
+
readonly requestID: string
|
|
123
|
+
/** Why: passed to the harness (prefixed with who denied it) by spokes announcing "permissionMessage". */
|
|
124
|
+
readonly why?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface Denied {
|
|
128
|
+
readonly sessionID: string
|
|
129
|
+
readonly requestID: string
|
|
130
|
+
readonly grant?: string
|
|
131
|
+
/** With a reason: whether the harness got it (older spokes take the reject without it). */
|
|
132
|
+
readonly reasonSent?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type AnswerInput =
|
|
136
|
+
| { readonly formID: string; readonly answer: FormAnswer; readonly cancel?: undefined; readonly why?: string }
|
|
137
|
+
| { readonly formID: string; readonly cancel: true; readonly answer?: undefined; readonly why?: string }
|
|
138
|
+
|
|
139
|
+
export interface Answered {
|
|
140
|
+
readonly sessionID: string
|
|
141
|
+
readonly formID: string
|
|
142
|
+
readonly grant?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface StopInput {
|
|
146
|
+
readonly why?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface StoppedSession {
|
|
150
|
+
readonly sessionID: string
|
|
151
|
+
readonly grant?: string
|
|
152
|
+
/** The spoke's answer. */
|
|
153
|
+
readonly result: ResultFor["stop"]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PromptInput {
|
|
157
|
+
readonly text: string
|
|
158
|
+
/** Users only. */
|
|
159
|
+
readonly images?: readonly PromptImage[]
|
|
160
|
+
/** How an agent's prompt enters a busy session (the spoke picks when absent). */
|
|
161
|
+
readonly delivery?: DeliveryMode
|
|
162
|
+
/** Agents' prompts always wake the session: false is refused. */
|
|
163
|
+
readonly wake?: boolean
|
|
164
|
+
readonly why?: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An agent's prompt, admitted by the spoke under its provenance header. */
|
|
168
|
+
export interface AgentPrompted {
|
|
169
|
+
readonly sessionID: string
|
|
170
|
+
readonly messageID: string
|
|
171
|
+
readonly delivered: Delivered
|
|
172
|
+
readonly inboxID?: string
|
|
173
|
+
readonly grant: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The user's prompt: steered into the running tree, or a new turn (its reply streams to the console). */
|
|
177
|
+
export type UserPrompted = { readonly sessionID: string; readonly mode: "steer"; readonly inboxID: string } | { readonly sessionID: string; readonly mode: "turn" }
|
|
178
|
+
|
|
179
|
+
/** The console's prompt limit (http.ts MAX_PROMPT_CHARS; Central passes it). */
|
|
180
|
+
const DEFAULT_MAX_PROMPT_CHARS = 100_000
|
|
181
|
+
|
|
182
|
+
/** The addressed session, live in the user's catalog. */
|
|
183
|
+
interface Located {
|
|
184
|
+
readonly sessionID: string
|
|
185
|
+
readonly directory: string
|
|
186
|
+
readonly rootID: string
|
|
187
|
+
readonly parentID?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------- shell normalisation (never rules) ----------
|
|
191
|
+
|
|
192
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
193
|
+
/** Wrappers that run the rest of the line, and their options that take a value. */
|
|
194
|
+
const WRAPPERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
195
|
+
["sudo", new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T"])],
|
|
196
|
+
["env", new Set(["-u", "-C", "-S"])],
|
|
197
|
+
["time", new Set(["-f", "-o"])],
|
|
198
|
+
["command", new Set<string>()],
|
|
199
|
+
["builtin", new Set<string>()],
|
|
200
|
+
["exec", new Set(["-a"])],
|
|
201
|
+
["nohup", new Set<string>()],
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A shell command as a never rule also sees it: whitespace collapsed, leading variable assignments and wrappers
|
|
206
|
+
* (env, command, builtin, exec, nohup, sudo, time, with their options) stripped.
|
|
207
|
+
*/
|
|
208
|
+
export function normaliseShell(command: string): string {
|
|
209
|
+
const words = command.trim().split(/\s+/).filter(Boolean)
|
|
210
|
+
for (;;) {
|
|
211
|
+
const word = words[0]
|
|
212
|
+
if (word === undefined) break
|
|
213
|
+
if (ASSIGNMENT.test(word)) {
|
|
214
|
+
words.shift()
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const valued = WRAPPERS.get(word)
|
|
218
|
+
if (!valued) break
|
|
219
|
+
words.shift()
|
|
220
|
+
while (words[0]?.startsWith("-")) {
|
|
221
|
+
const option = words.shift()!
|
|
222
|
+
if (valued.has(option)) words.shift()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return words.join(" ")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const isShell = (action: string) => action === "shell" || action === "bash"
|
|
229
|
+
|
|
230
|
+
/** The request as asked and (for shell) normalised, for never matching. */
|
|
231
|
+
function neverForms(request: PermissionRequest): PermissionRequest[] {
|
|
232
|
+
if (!isShell(request.action)) return [request]
|
|
233
|
+
const normalised = {
|
|
234
|
+
action: request.action,
|
|
235
|
+
resources: request.resources.map(normaliseShell),
|
|
236
|
+
...(request.save ? { save: request.save.map(normaliseShell) } : {}),
|
|
237
|
+
}
|
|
238
|
+
return [request, normalised]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
|
|
242
|
+
|
|
243
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
244
|
+
const forbidden = (message: string) => new AgentError("forbidden", message)
|
|
245
|
+
const offline = () => new AgentError("offline", "No spoke reports this session now")
|
|
246
|
+
|
|
247
|
+
/** Thrown by a policy: the request needs the user. */
|
|
248
|
+
class NeedsUser {
|
|
249
|
+
constructor(readonly evaluation: Evaluation) {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class Acting {
|
|
253
|
+
constructor(private readonly deps: ActingDeps) {}
|
|
254
|
+
|
|
255
|
+
/** Approves a pending permission request in the addressed session's tree (once, or always). */
|
|
256
|
+
async approve(caller: Caller, addr: string, input: ApproveInput): Promise<Approved> {
|
|
257
|
+
const target = this.locate(caller.userID, addr)
|
|
258
|
+
const origin = this.pendingPermission(target, input.requestID)
|
|
259
|
+
const always = input.always === true
|
|
260
|
+
const decision: PermissionDecision = always ? "always" : "once"
|
|
261
|
+
return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
|
|
262
|
+
selfTree: true,
|
|
263
|
+
choose: (usable) => this.choosePermission(usable, origin.request, always),
|
|
264
|
+
escalation: (evaluation) => ({
|
|
265
|
+
verb: "approve",
|
|
266
|
+
target: target.sessionID,
|
|
267
|
+
sessionID: origin.sessionID,
|
|
268
|
+
rootID: target.rootID,
|
|
269
|
+
requestID: input.requestID,
|
|
270
|
+
...(origin.request ? { request: origin.request } : {}),
|
|
271
|
+
always,
|
|
272
|
+
evaluation,
|
|
273
|
+
...(input.why ? { why: input.why } : {}),
|
|
274
|
+
}),
|
|
275
|
+
run: async (grant) => {
|
|
276
|
+
const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
|
|
277
|
+
if (answered.outcome === "unsupported") throw unsupported(answered)
|
|
278
|
+
if (answered.outcome === "gone") throw gone()
|
|
279
|
+
return { sessionID: target.sessionID, requestID: input.requestID, decision, ...(grant ? { grant: grant.id } : {}) }
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Rejects a pending permission request (any request: a reject is always safe), with an optional reason. */
|
|
285
|
+
async deny(caller: Caller, addr: string, input: DenyInput): Promise<Denied> {
|
|
286
|
+
const target = this.locate(caller.userID, addr)
|
|
287
|
+
this.pendingPermission(target, input.requestID)
|
|
288
|
+
return this.act(caller, "deny", target, { requestID: input.requestID }, input.why, {
|
|
289
|
+
selfTree: true,
|
|
290
|
+
run: async (grant) => {
|
|
291
|
+
const { registry } = this.deps
|
|
292
|
+
const why = input.why === undefined ? "" : sanitizeText(input.why).trim()
|
|
293
|
+
const reasonSent = why !== "" && registry.permissionTargets(target.rootID, input.requestID).every((t) => registry.supports(t.connID, "permissionMessage"))
|
|
294
|
+
const message = reasonSent ? bounded(`Denied by ${partyLabel(this.party(caller))}${grant ? ` (grant ${grant.id})` : ""}: ${why}`, PERMISSION_MESSAGE_MAX) : undefined
|
|
295
|
+
const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision: "reject", ...(message ? { message } : {}) })
|
|
296
|
+
if (answered.outcome === "unsupported") throw unsupported(answered)
|
|
297
|
+
if (answered.outcome === "gone") throw gone()
|
|
298
|
+
return { sessionID: target.sessionID, requestID: input.requestID, ...(grant ? { grant: grant.id } : {}), ...(why ? { reasonSent } : {}) }
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Answers (or cancels) a form pending in the addressed session's tree. */
|
|
304
|
+
async answer(caller: Caller, addr: string, input: AnswerInput): Promise<Answered> {
|
|
305
|
+
const target = this.locate(caller.userID, addr)
|
|
306
|
+
if (!this.deps.registry.formTargets(target.rootID, input.formID).length) throw new AgentError("not_found", `No pending form ${input.formID} in this session's tree`)
|
|
307
|
+
return this.act(caller, "answer", target, { formID: input.formID }, input.why, {
|
|
308
|
+
selfTree: true,
|
|
309
|
+
run: async (grant) => {
|
|
310
|
+
const form = input.cancel
|
|
311
|
+
? { rootID: target.rootID, formID: input.formID, cancel: true as const }
|
|
312
|
+
: { rootID: target.rootID, formID: input.formID, answer: input.answer }
|
|
313
|
+
const answered = await this.deps.actions.answerForm(form)
|
|
314
|
+
if (answered.outcome === "unsupported") throw unsupported(answered)
|
|
315
|
+
if (answered.outcome === "gone") throw gone()
|
|
316
|
+
return { sessionID: target.sessionID, formID: input.formID, ...(grant ? { grant: grant.id } : {}) }
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Stops the addressed session's tree. */
|
|
322
|
+
async stop(caller: Caller, addr: string, input: StopInput): Promise<StoppedSession> {
|
|
323
|
+
const target = this.locate(caller.userID, addr)
|
|
324
|
+
return this.act(caller, "stop", target, {}, input.why, {
|
|
325
|
+
run: async (grant) => {
|
|
326
|
+
const stopped = await this.deps.actions.stop(target.sessionID)
|
|
327
|
+
if (stopped.outcome === "offline") throw offline()
|
|
328
|
+
if (stopped.outcome === "unsupported") throw unsupported(stopped)
|
|
329
|
+
return { sessionID: target.sessionID, ...(grant ? { grant: grant.id } : {}), result: stopped.result }
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Prompts the addressed session. An agent's prompt is a deliver of kind "prompt" naming the sender and grant
|
|
336
|
+
* (feature hubPrompt; the spoke renders the provenance header), never a human turn: hop chains are kept.
|
|
337
|
+
* Refused (invalid): subagent sessions, the session itself, wake:false, images. The user's prompt is a human turn.
|
|
338
|
+
*/
|
|
339
|
+
async prompt(caller: Caller, addr: string, input: PromptInput): Promise<AgentPrompted | UserPrompted> {
|
|
340
|
+
const { actor, userID } = caller
|
|
341
|
+
const text = input.text.trim()
|
|
342
|
+
const images = input.images?.length ? input.images : undefined
|
|
343
|
+
if (actor.kind === "user") {
|
|
344
|
+
if (images) {
|
|
345
|
+
const problem = promptImagesProblem(images)
|
|
346
|
+
if (problem) throw invalid(problem)
|
|
347
|
+
}
|
|
348
|
+
if (!text && !images) throw invalid("Empty prompt")
|
|
349
|
+
const max = this.deps.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS
|
|
350
|
+
if (text.length > max) throw invalid(`Prompt longer than ${max} characters`)
|
|
351
|
+
} else {
|
|
352
|
+
if (images) throw invalid("An agent's prompt is text only")
|
|
353
|
+
if (input.wake === false) throw invalid("An agent's prompt always wakes the session (send a message for wake: false)")
|
|
354
|
+
if (!text) throw invalid("Empty prompt")
|
|
355
|
+
if (text.length > DELIVER_TEXT_MAX) throw invalid(`Prompt longer than ${DELIVER_TEXT_MAX} characters`)
|
|
356
|
+
}
|
|
357
|
+
const target = this.locate(userID, addr)
|
|
358
|
+
if (actor.kind !== "user") {
|
|
359
|
+
if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
|
|
360
|
+
if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
|
|
361
|
+
}
|
|
362
|
+
return this.act(caller, "prompt", target, {}, input.why, {
|
|
363
|
+
run: async (grant) => {
|
|
364
|
+
if (!grant) return this.userPrompt(target.sessionID, text, images)
|
|
365
|
+
return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined): Promise<UserPrompted> {
|
|
371
|
+
let started
|
|
372
|
+
try {
|
|
373
|
+
started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
374
|
+
} catch (cause) {
|
|
375
|
+
if (cause instanceof BusyError) throw new AgentError("rate_limited", "Another turn is starting in this session", { hint: "Try again", extra: { retryAfter: 1 } })
|
|
376
|
+
throw cause
|
|
377
|
+
}
|
|
378
|
+
if (started.outcome === "offline") throw offline()
|
|
379
|
+
if (started.outcome === "unsupported") throw unsupported(started)
|
|
380
|
+
if (started.mode === "steer") return { sessionID, mode: "steer", inboxID: started.inboxID }
|
|
381
|
+
// Nobody waits for the turn here: its reply streams to the console.
|
|
382
|
+
void started.result.catch(() => {})
|
|
383
|
+
return { sessionID, mode: "turn" }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async agentPrompt(caller: Caller, addr: string, sessionID: string, text: string, grant: Grant, delivery: DeliveryMode | undefined): Promise<AgentPrompted> {
|
|
387
|
+
const { registry, spokes } = this.deps
|
|
388
|
+
const connID = registry.owner(sessionID)
|
|
389
|
+
if (!connID) throw offline()
|
|
390
|
+
for (const feature of ["deliver", "hubPrompt"] as const) {
|
|
391
|
+
if (!registry.supports(connID, feature)) {
|
|
392
|
+
const harness = registry.harnessOf(connID) ?? "unknown"
|
|
393
|
+
throw new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const messageID = (this.deps.newMessageID ?? newMessageID)()
|
|
397
|
+
const from = this.address(caller)
|
|
398
|
+
let result: unknown
|
|
399
|
+
try {
|
|
400
|
+
result = await spokes.request(connID, {
|
|
401
|
+
op: "deliver",
|
|
402
|
+
sessionID,
|
|
403
|
+
messageID,
|
|
404
|
+
text,
|
|
405
|
+
wake: true,
|
|
406
|
+
meta: { kind: "prompt", from, to: addr, grant: grant.id, ...(delivery ? { delivery } : {}) },
|
|
407
|
+
})
|
|
408
|
+
} catch (cause) {
|
|
409
|
+
if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) throw new AgentError("offline", "The session's spoke disconnected")
|
|
410
|
+
throw cause
|
|
411
|
+
}
|
|
412
|
+
if (!isDeliverResult(result)) throw new Error("The spoke answered a deliver malformed")
|
|
413
|
+
return { sessionID, messageID, delivered: result.delivered, ...(result.inboxID ? { inboxID: result.inboxID } : {}), grant: grant.id }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------- the check order ----------
|
|
417
|
+
|
|
418
|
+
private async act<T>(
|
|
419
|
+
caller: Caller,
|
|
420
|
+
verb: GrantVerb,
|
|
421
|
+
target: Located,
|
|
422
|
+
ids: Omit<AuditTarget, "sessionID">,
|
|
423
|
+
why: string | undefined,
|
|
424
|
+
plan: {
|
|
425
|
+
/** A session never acts in its own tree (permissions and forms). */
|
|
426
|
+
readonly selfTree?: boolean
|
|
427
|
+
/** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
|
|
428
|
+
readonly choose?: (usable: Grant[]) => Grant
|
|
429
|
+
readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
|
|
430
|
+
readonly run: (grant: Grant | undefined) => Promise<T>
|
|
431
|
+
},
|
|
432
|
+
): Promise<T> {
|
|
433
|
+
const { actor, userID } = caller
|
|
434
|
+
const { acting, grants } = this.deps
|
|
435
|
+
let grant: Grant | undefined
|
|
436
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
437
|
+
this.deps.audit.record(userID, {
|
|
438
|
+
actor: this.party(caller),
|
|
439
|
+
verb,
|
|
440
|
+
target: { sessionID: target.sessionID, ...ids },
|
|
441
|
+
...(grant ? { grant: grant.id } : {}),
|
|
442
|
+
decision,
|
|
443
|
+
...(reason ? { reason } : {}),
|
|
444
|
+
outcome,
|
|
445
|
+
})
|
|
446
|
+
try {
|
|
447
|
+
if (actor.kind !== "user") {
|
|
448
|
+
acting.ensureActing(userID)
|
|
449
|
+
const usable = await grants.usable(actor, verb, { directory: target.directory })
|
|
450
|
+
if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
451
|
+
if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
452
|
+
throw forbidden("A session never answers requests in its own session tree")
|
|
453
|
+
}
|
|
454
|
+
grant = plan.choose ? plan.choose(usable) : usable[0]!
|
|
455
|
+
await grants.consume(grant)
|
|
456
|
+
acting.ensureActing(userID)
|
|
457
|
+
}
|
|
458
|
+
const result = await plan.run(grant)
|
|
459
|
+
await record("allow", "ok", why)
|
|
460
|
+
return result
|
|
461
|
+
} catch (cause) {
|
|
462
|
+
if (cause instanceof NeedsUser) {
|
|
463
|
+
const escalated = await this.escalate(caller, plan.escalation?.(cause.evaluation))
|
|
464
|
+
const decisionID = escalated?.decisionID
|
|
465
|
+
await record("needs_user", escalated ? "escalated" : "refused", cause.evaluation.reason)
|
|
466
|
+
throw new AgentError("needs_user", `Needs the user: ${cause.evaluation.reason}`, {
|
|
467
|
+
hint: "The user decides in the console",
|
|
468
|
+
extra: { evaluation: cause.evaluation, ...(decisionID !== undefined ? { decisionID } : {}) },
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
const [decision, outcome] = auditOf(cause)
|
|
472
|
+
await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
|
|
473
|
+
throw cause
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Hands the escalation to the hook: undefined when there is none or it failed. */
|
|
478
|
+
private async escalate(caller: Caller, escalation: Omit<Escalation, "userID" | "actor"> | undefined): Promise<{ decisionID?: string } | undefined> {
|
|
479
|
+
if (!this.deps.escalate || !escalation) return undefined
|
|
480
|
+
try {
|
|
481
|
+
return (await this.deps.escalate({ userID: caller.userID, actor: caller.actor, ...escalation })) ?? {}
|
|
482
|
+
} catch (cause) {
|
|
483
|
+
console.error(`[hub] escalation failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
484
|
+
return undefined
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The grant that covers the request, after every never rule; NeedsUser when none does. */
|
|
489
|
+
private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean): Grant {
|
|
490
|
+
const hubNever = this.deps.acting.hubNever()
|
|
491
|
+
const ids = usable.map((g) => g.id)
|
|
492
|
+
if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
|
|
493
|
+
const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
|
|
494
|
+
for (const form of neverForms(request)) {
|
|
495
|
+
for (const { grant, allow, never } of rules) {
|
|
496
|
+
const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
497
|
+
if (verdict.verdict === "never") {
|
|
498
|
+
throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
|
|
503
|
+
if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
|
|
504
|
+
let reason: string | undefined
|
|
505
|
+
for (const { grant, allow, never } of rules) {
|
|
506
|
+
const verdict = evaluatePermission(request, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
507
|
+
if (verdict.verdict === "allow") return grant
|
|
508
|
+
if (verdict.verdict === "outside") reason ??= verdict.reason
|
|
509
|
+
}
|
|
510
|
+
throw new NeedsUser({ verdict: "outside", reason: reason ?? "Outside the grant", tier: riskTier(request, allNever), grants: ids })
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------- targets ----------
|
|
514
|
+
|
|
515
|
+
/** The addressed session among the user's (not_found otherwise), live now (else offline). */
|
|
516
|
+
private locate(userID: string, addr: string): Located {
|
|
517
|
+
const entry = this.deps.directory.resolveOne(userID, addr)
|
|
518
|
+
const { registry } = this.deps
|
|
519
|
+
if (registry.sessionTenant(entry.sessionID) !== userID) throw offline()
|
|
520
|
+
const live = registry.session(entry.sessionID)
|
|
521
|
+
if (!live) throw offline()
|
|
522
|
+
const parentID = live.parentID ?? entry.parentID
|
|
523
|
+
return { sessionID: entry.sessionID, directory: live.directory, rootID: registry.rootOf(entry.sessionID) ?? entry.sessionID, ...(parentID ? { parentID } : {}) }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** A permission request pending in the target's tree; not_found otherwise (unknown, elsewhere, another user's). */
|
|
527
|
+
private pendingPermission(target: Located, requestID: string): { sessionID: string; request?: PermissionRequest } {
|
|
528
|
+
const { registry } = this.deps
|
|
529
|
+
const origin = registry.permissionRequest(requestID)
|
|
530
|
+
if (!origin || (registry.rootOf(origin.sessionID) ?? origin.sessionID) !== target.rootID || !registry.permissionTargets(target.rootID, requestID).length) {
|
|
531
|
+
throw new AgentError("not_found", `No pending request ${requestID} in this session's tree`)
|
|
532
|
+
}
|
|
533
|
+
return origin
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The caller's address as a deliver's meta.from: `@name` or the session ID, `app:<token id>`. */
|
|
537
|
+
private address(caller: Caller): string {
|
|
538
|
+
const party = this.party(caller)
|
|
539
|
+
switch (party.kind) {
|
|
540
|
+
case "session":
|
|
541
|
+
return party.name ? `@${party.name}` : party.sessionID
|
|
542
|
+
case "app":
|
|
543
|
+
return `app:${party.tokenID}`
|
|
544
|
+
case "user":
|
|
545
|
+
return "@me"
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The caller as the audit and envelopes name it (a session by its directory name). */
|
|
550
|
+
private party(caller: Caller): Party {
|
|
551
|
+
const { actor } = caller
|
|
552
|
+
if (actor.kind !== "session") return partyFromActor(actor)
|
|
553
|
+
let name: string | undefined
|
|
554
|
+
try {
|
|
555
|
+
name = this.deps.directory.resolveOne(caller.userID, actor.sessionID).name
|
|
556
|
+
} catch {}
|
|
557
|
+
return partyFromActor(actor, name ? { name } : {})
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** At most `max` UTF-16 units, cut on a character boundary. */
|
|
562
|
+
function bounded(text: string, max: number): string {
|
|
563
|
+
if (text.length <= max) return text
|
|
564
|
+
let out = ""
|
|
565
|
+
for (const char of text) {
|
|
566
|
+
if (out.length + char.length > max) break
|
|
567
|
+
out += char
|
|
568
|
+
}
|
|
569
|
+
return out
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const gone = () => new AgentError("gone", "Already answered, expired or ended")
|
|
573
|
+
/** The spoke lacks the core op: unsupported {feature, harness}, as the read routes answer. */
|
|
574
|
+
const unsupported = ({ feature, harness }: Unsupported) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
575
|
+
|
|
576
|
+
/** An error's audit decision and outcome (shared with the launch routes). */
|
|
577
|
+
export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
578
|
+
if (!(cause instanceof AgentError)) return ["allow", "failed"]
|
|
579
|
+
switch (cause.code) {
|
|
580
|
+
case "paused":
|
|
581
|
+
return ["paused", "refused"]
|
|
582
|
+
case "forbidden":
|
|
583
|
+
case "invalid":
|
|
584
|
+
case "not_found":
|
|
585
|
+
return ["forbidden", "refused"]
|
|
586
|
+
case "rate_limited":
|
|
587
|
+
return ["rate_limited", "refused"]
|
|
588
|
+
case "needs_user":
|
|
589
|
+
return ["needs_user", "refused"]
|
|
590
|
+
case "gone":
|
|
591
|
+
return ["allow", "gone"]
|
|
592
|
+
case "offline":
|
|
593
|
+
return ["allow", "offline"]
|
|
594
|
+
case "unsupported":
|
|
595
|
+
return ["allow", "unsupported"]
|
|
596
|
+
default:
|
|
597
|
+
return ["allow", "failed"]
|
|
598
|
+
}
|
|
599
|
+
}
|