@huynle/hub 0.7.3 → 0.8.0
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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Profile application for harness adapters (HWA-15; contract packages/protocol/src/profiles.ts). Shared by the
|
|
2
|
+
// OpenCode (HWA-16), Claude Code (HWA-17) and Codex/preset (HWA-18) adapters; checked by the conformance suite.
|
|
3
|
+
//
|
|
4
|
+
// Precedence (the documented rule): a profile ADDS a session-scoped layer and never replaces local configuration.
|
|
5
|
+
// - Local instructions (project AGENTS.md/CLAUDE.md, the user's own harness config) load unchanged. Profile
|
|
6
|
+
// instructions come after them, framed by profileInstructionsBlock, which says local instructions take
|
|
7
|
+
// precedence where they conflict.
|
|
8
|
+
// - A local skill or command with the same name wins: the profile's is skipped with a note (status partial).
|
|
9
|
+
// - Parts the adapter cannot apply are skipped with a note (partial); nothing applicable is `unsupported`.
|
|
10
|
+
// - An adapter whose harness reads the profile only at session start answers `restart_required`.
|
|
11
|
+
// - Adapters write session-scoped state only, never global harness configuration.
|
|
12
|
+
// Secrets: profiles carry `{{secret:NAME}}` references only (central never resolves them). The adapter resolves
|
|
13
|
+
// them on the spoke's machine when a command is rendered at invocation (renderProfileCommand; default source
|
|
14
|
+
// envSecrets: HUB_SECRET_<NAME> in the spoke's environment). Resolved values are never written to disk, sent to
|
|
15
|
+
// the hub, or put in notes; an adapter that cannot render at invocation skips commands that reference secrets.
|
|
16
|
+
import { createHash } from "node:crypto"
|
|
17
|
+
import {
|
|
18
|
+
canonicalProfileJSON,
|
|
19
|
+
PROFILE_APPLY_LIMITS,
|
|
20
|
+
type ProfileApplyRequest,
|
|
21
|
+
type ProfileApplyResult,
|
|
22
|
+
type ProfileApplyStatus,
|
|
23
|
+
type ProfileCommand,
|
|
24
|
+
type ProfileSkill,
|
|
25
|
+
profileTemplateParts,
|
|
26
|
+
type ProfileVersionContent,
|
|
27
|
+
} from "../../protocol/src/index.ts"
|
|
28
|
+
|
|
29
|
+
/** sha256 hex of the canonical JSON of a version's content: what the hub sends as `digest`. */
|
|
30
|
+
export function profileDigest(content: ProfileVersionContent): string {
|
|
31
|
+
return createHash("sha256").update(canonicalProfileJSON(content)).digest("hex")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a harness adapter can apply to a running session. */
|
|
35
|
+
export interface ProfileCapabilities {
|
|
36
|
+
readonly instructions: boolean
|
|
37
|
+
readonly skills: boolean
|
|
38
|
+
readonly commands: boolean
|
|
39
|
+
/** It can switch the session's model. */
|
|
40
|
+
readonly model: boolean
|
|
41
|
+
/** Applied parts take effect in the running session; false: only after the session restarts. */
|
|
42
|
+
readonly live: boolean
|
|
43
|
+
/** It renders commands when they are invoked (so secret references resolve then, on this machine). */
|
|
44
|
+
readonly renderAtInvocation: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Names the session already has locally (they win over the profile's). */
|
|
48
|
+
export interface ProfileLocalNames {
|
|
49
|
+
readonly skills?: Iterable<string>
|
|
50
|
+
readonly commands?: Iterable<string>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProfilePlan {
|
|
54
|
+
readonly status: ProfileApplyStatus
|
|
55
|
+
/** The framed instructions block to add after local instructions (absent: none to apply). */
|
|
56
|
+
readonly instructions?: string
|
|
57
|
+
readonly skills: readonly ProfileSkill[]
|
|
58
|
+
readonly commands: readonly ProfileCommand[]
|
|
59
|
+
readonly model?: string
|
|
60
|
+
readonly notes: readonly string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A note within the protocol's bounds: plain text of at most noteChars characters. */
|
|
64
|
+
export function profileNote(text: string): string {
|
|
65
|
+
const clean = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
|
|
66
|
+
return clean.length <= PROFILE_APPLY_LIMITS.noteChars ? clean : `${clean.slice(0, PROFILE_APPLY_LIMITS.noteChars - 1)}…`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** At most PROFILE_APPLY_LIMITS.notes notes (the last one says how many were left out). */
|
|
70
|
+
export function boundedNotes(notes: readonly string[]): string[] {
|
|
71
|
+
const max = PROFILE_APPLY_LIMITS.notes
|
|
72
|
+
const clean = notes.map(profileNote)
|
|
73
|
+
return clean.length <= max ? clean : [...clean.slice(0, max - 1), `… ${clean.length - max + 1} more notes`]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The profile instructions as adapters add them: after local instructions, which take precedence. */
|
|
77
|
+
export function profileInstructionsBlock(req: Pick<ProfileApplyRequest, "profileID" | "version" | "content">): string {
|
|
78
|
+
return [
|
|
79
|
+
`<!-- hub profile ${req.profileID} v${req.version} (session-scoped) -->`,
|
|
80
|
+
`Hub agent profile ${req.profileID} version ${req.version}, applied to this session only. Where it conflicts with local project or user instructions, the local instructions take precedence.`,
|
|
81
|
+
"",
|
|
82
|
+
req.content.instructions ?? "",
|
|
83
|
+
].join("\n")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Secret names a template references. */
|
|
87
|
+
export function templateSecrets(template: string): string[] {
|
|
88
|
+
const parts = profileTemplateParts(template)
|
|
89
|
+
if (typeof parts === "string") return []
|
|
90
|
+
return [...new Set(parts.flatMap((p) => ("secret" in p ? [p.secret] : [])))]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What to apply of a profile version, by the precedence rule above. */
|
|
94
|
+
export function planProfileApplication(req: ProfileApplyRequest, options: { readonly capabilities: ProfileCapabilities; readonly local?: ProfileLocalNames }): ProfilePlan {
|
|
95
|
+
const { capabilities: can } = options
|
|
96
|
+
const { content } = req
|
|
97
|
+
const localSkills = new Set(options.local?.skills ?? [])
|
|
98
|
+
const localCommands = new Set(options.local?.commands ?? [])
|
|
99
|
+
const notes: string[] = []
|
|
100
|
+
let offered = 0
|
|
101
|
+
let usable = 0
|
|
102
|
+
|
|
103
|
+
let instructions: string | undefined
|
|
104
|
+
if (content.instructions !== undefined && content.instructions.trim() !== "") {
|
|
105
|
+
offered++
|
|
106
|
+
if (can.instructions) {
|
|
107
|
+
instructions = profileInstructionsBlock(req)
|
|
108
|
+
usable++
|
|
109
|
+
} else notes.push("instructions not supported by this harness adapter")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const skills: ProfileSkill[] = []
|
|
113
|
+
if (content.skills.length) {
|
|
114
|
+
offered++
|
|
115
|
+
if (!can.skills) notes.push("skills not supported by this harness adapter")
|
|
116
|
+
else
|
|
117
|
+
for (const skill of content.skills) {
|
|
118
|
+
if (localSkills.has(skill.name)) notes.push(`skill ${skill.name} skipped: a local skill of that name takes precedence`)
|
|
119
|
+
else skills.push(skill)
|
|
120
|
+
}
|
|
121
|
+
if (skills.length) usable++
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const commands: ProfileCommand[] = []
|
|
125
|
+
if (content.commands.length) {
|
|
126
|
+
offered++
|
|
127
|
+
if (!can.commands) notes.push("commands not supported by this harness adapter")
|
|
128
|
+
else
|
|
129
|
+
for (const command of content.commands) {
|
|
130
|
+
const secrets = templateSecrets(command.template)
|
|
131
|
+
if (localCommands.has(command.name)) notes.push(`command ${command.name} skipped: a local command of that name takes precedence`)
|
|
132
|
+
else if (secrets.length && !can.renderAtInvocation) notes.push(`command ${command.name} skipped: it needs secrets (${secrets.join(", ")}) and this adapter cannot render at invocation`)
|
|
133
|
+
else commands.push(command)
|
|
134
|
+
}
|
|
135
|
+
if (commands.length) usable++
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let model: string | undefined
|
|
139
|
+
if (content.model !== undefined) {
|
|
140
|
+
offered++
|
|
141
|
+
if (can.model) {
|
|
142
|
+
model = content.model
|
|
143
|
+
usable++
|
|
144
|
+
} else notes.push("model ignored: this harness adapter cannot change the model")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const status: ProfileApplyStatus = offered > 0 && usable === 0 ? "unsupported" : !can.live ? "restart_required" : notes.length ? "partial" : "applied"
|
|
148
|
+
return { status, ...(instructions !== undefined ? { instructions } : {}), skills, commands, ...(model !== undefined ? { model } : {}), notes: boundedNotes(notes) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What an adapter's apply may report; its status (if any) and notes replace or extend the plan's. */
|
|
152
|
+
export interface ProfileApplyOutcome {
|
|
153
|
+
readonly status?: ProfileApplyStatus
|
|
154
|
+
readonly notes?: readonly string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ProfileApplyHandlerOptions {
|
|
158
|
+
readonly capabilities: ProfileCapabilities
|
|
159
|
+
/** The session's local skill and command names (they win over the profile's). */
|
|
160
|
+
readonly local?: (sessionID: string) => ProfileLocalNames | undefined
|
|
161
|
+
/** Writes the plan to the session's own (session-scoped) state. Throwing answers `rejected` with the reason. */
|
|
162
|
+
readonly apply: (plan: ProfilePlan, req: ProfileApplyRequest) => Promise<ProfileApplyOutcome | void>
|
|
163
|
+
/** Sessions whose last applied digest is remembered (default 1000). */
|
|
164
|
+
readonly remembered?: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The answer to a profileApply request, echoing what it was asked to apply. */
|
|
168
|
+
export function profileApplyResult(req: ProfileApplyRequest, status: ProfileApplyStatus, notes: readonly string[] = []): ProfileApplyResult {
|
|
169
|
+
const bounded = boundedNotes(notes)
|
|
170
|
+
return { sessionID: req.sessionID, profileID: req.profileID, version: req.version, digest: req.digest, status, ...(bounded.length ? { notes: bounded } : {}) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The `profileApply` op handler for HarnessSpoke (`handlers: { profileApply: handler.handle }`, which announces the
|
|
175
|
+
* feature): checks the digest, plans by precedence, calls the adapter, and answers a valid result. A repeated
|
|
176
|
+
* request for the digest a session already applied (applied or partial) answers the same result without
|
|
177
|
+
* re-applying; restart_required, rejected and unsupported are tried again.
|
|
178
|
+
*/
|
|
179
|
+
export class ProfileApplyHandler {
|
|
180
|
+
private readonly options: ProfileApplyHandlerOptions
|
|
181
|
+
private readonly done = new Map<string, ProfileApplyResult>()
|
|
182
|
+
|
|
183
|
+
constructor(options: ProfileApplyHandlerOptions) {
|
|
184
|
+
this.options = options
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
readonly handle = async (req: ProfileApplyRequest): Promise<ProfileApplyResult> => {
|
|
188
|
+
if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
|
|
189
|
+
const previous = this.done.get(req.sessionID)
|
|
190
|
+
if (previous && previous.profileID === req.profileID && previous.version === req.version && previous.digest === req.digest) return previous
|
|
191
|
+
const plan = planProfileApplication(req, { capabilities: this.options.capabilities, ...(this.options.local ? { local: this.options.local(req.sessionID) ?? {} } : {}) })
|
|
192
|
+
if (plan.status === "unsupported") return profileApplyResult(req, "unsupported", plan.notes)
|
|
193
|
+
let outcome: ProfileApplyOutcome | void
|
|
194
|
+
try {
|
|
195
|
+
outcome = await this.options.apply(plan, req)
|
|
196
|
+
} catch (cause) {
|
|
197
|
+
return profileApplyResult(req, "rejected", [`adapter failed: ${cause instanceof Error ? cause.message : String(cause)}`])
|
|
198
|
+
}
|
|
199
|
+
const result = profileApplyResult(req, outcome?.status ?? plan.status, [...plan.notes, ...(outcome?.notes ?? [])])
|
|
200
|
+
this.done.delete(req.sessionID)
|
|
201
|
+
if (result.status === "applied" || result.status === "partial") {
|
|
202
|
+
this.done.set(req.sessionID, result)
|
|
203
|
+
while (this.done.size > (this.options.remembered ?? 1000)) this.done.delete(this.done.keys().next().value!)
|
|
204
|
+
}
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Forgets a session (it ended, or its applied state was lost). */
|
|
209
|
+
forget(sessionID: string): void {
|
|
210
|
+
this.done.delete(sessionID)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A command's secret references are not available on this machine (names only, never values). */
|
|
215
|
+
export class ProfileSecretMissingError extends Error {
|
|
216
|
+
constructor(readonly missing: readonly string[]) {
|
|
217
|
+
super(`missing secret(s) ${missing.join(", ")} on this machine`)
|
|
218
|
+
this.name = "ProfileSecretMissingError"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Secrets from the spoke's environment: `{{secret:NAME}}` reads `<prefix>NAME` (default HUB_SECRET_NAME). */
|
|
223
|
+
export function envSecrets(env: Readonly<Record<string, string | undefined>> = process.env, prefix = "HUB_SECRET_"): (name: string) => string | undefined {
|
|
224
|
+
return (name) => {
|
|
225
|
+
const value = env[`${prefix}${name}`]
|
|
226
|
+
return value === undefined || value === "" ? undefined : value
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Renders a command at invocation on the spoke: each `{{param}}` once, literally (a value is never parsed again),
|
|
232
|
+
* each `{{secret:NAME}}` from `secret`. Exactly the template's parameters must be given.
|
|
233
|
+
*/
|
|
234
|
+
export function renderProfileCommand(template: string, params: Readonly<Record<string, string>>, secret: (name: string) => string | undefined): string {
|
|
235
|
+
const parts = profileTemplateParts(template)
|
|
236
|
+
if (typeof parts === "string") throw new Error(`template: ${parts}`)
|
|
237
|
+
const wanted = [...new Set(parts.flatMap((p) => ("param" in p ? [p.param] : [])))]
|
|
238
|
+
const missing = wanted.filter((name) => !Object.hasOwn(params, name))
|
|
239
|
+
if (missing.length) throw new Error(`missing parameter(s) ${missing.join(", ")}`)
|
|
240
|
+
const unknown = Object.keys(params).filter((name) => !wanted.includes(name))
|
|
241
|
+
if (unknown.length) throw new Error(`unknown parameter(s) ${unknown.map((n) => (/^[a-z][a-z0-9_]{0,39}$/.test(n) ? n : "?")).join(", ")}`)
|
|
242
|
+
const absent = [...new Set(parts.flatMap((p) => ("secret" in p && secret(p.secret) === undefined ? [p.secret] : [])))]
|
|
243
|
+
if (absent.length) throw new ProfileSecretMissingError(absent)
|
|
244
|
+
return parts.map((p) => ("text" in p ? p.text : "param" in p ? params[p.param]! : secret(p.secret)!)).join("")
|
|
245
|
+
}
|
package/src/central/actions.ts
CHANGED
|
@@ -73,6 +73,13 @@ export interface PromptInput {
|
|
|
73
73
|
readonly images?: readonly PromptImage[]
|
|
74
74
|
/** True for the user's own prompts; agent prompts never reset the hop chain (decision wvqeuis0 §8). */
|
|
75
75
|
readonly resetHops: boolean
|
|
76
|
+
/**
|
|
77
|
+
* (CON-09a) false: the user did not write this text (a Concierge proposal run as the user), so it is never the
|
|
78
|
+
* source of the session's first-prompt intent. (CON-10) "first": it is the session's first prompt by construction
|
|
79
|
+
* (a launch's prompt, even when the launch already gave the session its name as title). Absent: it is the first
|
|
80
|
+
* prompt only when the session has no real title yet.
|
|
81
|
+
*/
|
|
82
|
+
readonly intent?: false | "first"
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/** Typed input steered into the running tree. */
|
|
@@ -14,6 +14,7 @@ import { DELIVER_ID_MAX, DELIVERY_MODES, type DeliveryMode, type FormAnswer, for
|
|
|
14
14
|
import type { Acting } from "./acting"
|
|
15
15
|
import { AgentError } from "./errors"
|
|
16
16
|
import type { AgentRoute } from "./routes"
|
|
17
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
|
|
17
18
|
|
|
18
19
|
// A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
|
|
19
20
|
const PREFIX = "/api/agent/sessions/:addr"
|
|
@@ -27,7 +28,7 @@ const invalid = (message: string) => new AgentError("invalid", message)
|
|
|
27
28
|
type Body = Record<string, unknown>
|
|
28
29
|
|
|
29
30
|
async function bodyOf(request: Request, optional = false): Promise<Body> {
|
|
30
|
-
const raw = await request
|
|
31
|
+
const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
31
32
|
if (!raw.trim() && optional) return {}
|
|
32
33
|
const value: unknown = JSON.parse(raw)
|
|
33
34
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid("Body must be a JSON object")
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (agent/acting.ts) calls ensureActing() first and again before the spoke request; it answers 423 paused.
|
|
7
7
|
// Reading and messaging keep working (messaging has its own switch, AgentData.messagingPaused).
|
|
8
8
|
// - hubNever(): hub.json `agents.never` (already validated by config.ts), checked before every grant.
|
|
9
|
+
import { boundedJson } from "./body"
|
|
9
10
|
import type { Store } from "../store"
|
|
10
11
|
import type { AgentActor } from "./actor"
|
|
11
12
|
import type { AgentAudit } from "./audit"
|
|
@@ -82,7 +83,7 @@ export function actingSwitchRoutes(acting: ActingSwitch): AgentRoute[] {
|
|
|
82
83
|
path: `${AGENT_PREFIX}/acting`,
|
|
83
84
|
allow: {},
|
|
84
85
|
async handle({ request, user, actor }) {
|
|
85
|
-
const body: unknown = await request
|
|
86
|
+
const body: unknown = await boundedJson(request)
|
|
86
87
|
const enabled = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>).enabled : undefined
|
|
87
88
|
if (typeof enabled !== "boolean") throw new AgentError("invalid", "enabled must be true or false")
|
|
88
89
|
await acting.setUserActing(user.id, actor, enabled)
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
// - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
|
|
20
20
|
// paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
|
|
21
21
|
// - Races: the request answered elsewhere first is gone (410).
|
|
22
|
+
// - (CON-06) preflight: a Concierge proposal is checked exactly like the act verb would check the agent, short of
|
|
23
|
+
// its grant (kill switch, tenancy, the Concierge guard, pending request/form, self-tree, prompt rules); nothing is
|
|
24
|
+
// consumed, audited or sent. When the user says go, the verb runs as the user (no grant, tenancy re-checked).
|
|
22
25
|
import {
|
|
23
26
|
DELIVER_TEXT_MAX,
|
|
24
27
|
type Delivered,
|
|
@@ -35,7 +38,8 @@ import {
|
|
|
35
38
|
import { type Actions, type Unsupported, unsupportedMessage } from "../actions"
|
|
36
39
|
import { BusyError, type Registry } from "../registry"
|
|
37
40
|
import { SpokeError } from "../spoke-ws"
|
|
38
|
-
import type { PermissionRequest } from "../../status"
|
|
41
|
+
import type { FormFieldSpec, PermissionRequest } from "../../status"
|
|
42
|
+
import { formAnswerFieldProblem } from "./form-check"
|
|
39
43
|
import type { ActingSwitch } from "./acting-switch"
|
|
40
44
|
import type { AgentActor } from "./actor"
|
|
41
45
|
import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
|
|
@@ -51,7 +55,9 @@ import { evaluatePermission, formatPattern, type GrantVerb, parsePattern, type P
|
|
|
51
55
|
export type ActingRegistry = Pick<
|
|
52
56
|
Registry,
|
|
53
57
|
"sessionTenant" | "session" | "rootOf" | "permissionRequest" | "permissionTargets" | "formTargets" | "owner" | "supports" | "harnessOf"
|
|
54
|
-
>
|
|
58
|
+
> &
|
|
59
|
+
/** (CON-13) The tree's live status: a pending form's fields (its schema). Without it no answer is checked. */
|
|
60
|
+
Partial<Pick<Registry, "status">>
|
|
55
61
|
|
|
56
62
|
/** Why a request needs the user, as the policy saw it. */
|
|
57
63
|
export interface Evaluation {
|
|
@@ -97,8 +103,20 @@ export interface ActingDeps {
|
|
|
97
103
|
readonly newMessageID?: () => string
|
|
98
104
|
/** Longest prompt a user sends (the console's limit); agents' prompts are at most DELIVER_TEXT_MAX. */
|
|
99
105
|
readonly maxPromptChars?: number
|
|
106
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): agents get forbidden {policy: "concierge"}. */
|
|
107
|
+
readonly protect?: (userID: string, sessionID: string) => Promise<boolean>
|
|
108
|
+
/**
|
|
109
|
+
* (CON-13 decision, design §11.4) Whether the acting session is a Concierge session (current or past). Such an
|
|
110
|
+
* actor never executes a tap-tier approval (shell, outside folders, MCP and unknown tools, near-never) or an
|
|
111
|
+
* "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
|
|
112
|
+
*/
|
|
113
|
+
readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
/** (CON-13 decision) Why the Concierge escalates a covered request. */
|
|
117
|
+
export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
|
|
118
|
+
export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
|
|
119
|
+
|
|
102
120
|
/** Who acts, for which user. */
|
|
103
121
|
export interface Caller {
|
|
104
122
|
readonly actor: AgentActor
|
|
@@ -162,6 +180,8 @@ export interface PromptInput {
|
|
|
162
180
|
/** Agents' prompts always wake the session: false is refused. */
|
|
163
181
|
readonly wake?: boolean
|
|
164
182
|
readonly why?: string
|
|
183
|
+
/** (CON-09a) false: not the user's own text (a proposal run as the user): never the session's first-prompt intent. */
|
|
184
|
+
readonly intent?: false
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/** An agent's prompt, admitted by the spoke under its provenance header. */
|
|
@@ -176,6 +196,28 @@ export interface AgentPrompted {
|
|
|
176
196
|
/** The user's prompt: steered into the running tree, or a new turn (its reply streams to the console). */
|
|
177
197
|
export type UserPrompted = { readonly sessionID: string; readonly mode: "steer"; readonly inboxID: string } | { readonly sessionID: string; readonly mode: "turn" }
|
|
178
198
|
|
|
199
|
+
/** (CON-13) A pending form's schema, as an agent may read it: the fields it takes (bounded by the spoke). */
|
|
200
|
+
export interface PendingForm {
|
|
201
|
+
readonly sessionID: string
|
|
202
|
+
readonly formID: string
|
|
203
|
+
/** The session in the tree that asked (a subagent's form is answered through its root). */
|
|
204
|
+
readonly askedBy: string
|
|
205
|
+
readonly title: string
|
|
206
|
+
readonly question?: true
|
|
207
|
+
readonly fields: readonly FormFieldSpec[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** (CON-06) The verbs a Concierge proposal covers, and what preflight answers about the target. */
|
|
211
|
+
export type ProposalVerb = "stop" | "deny" | "prompt" | "answer"
|
|
212
|
+
export interface Preflighted {
|
|
213
|
+
readonly sessionID: string
|
|
214
|
+
readonly rootID: string
|
|
215
|
+
/** The target's @name. */
|
|
216
|
+
readonly name?: string
|
|
217
|
+
/** deny: the pending request's details (when known). */
|
|
218
|
+
readonly request?: PermissionRequest
|
|
219
|
+
}
|
|
220
|
+
|
|
179
221
|
/** The console's prompt limit (http.ts MAX_PROMPT_CHARS; Central passes it). */
|
|
180
222
|
const DEFAULT_MAX_PROMPT_CHARS = 100_000
|
|
181
223
|
|
|
@@ -258,9 +300,10 @@ export class Acting {
|
|
|
258
300
|
const origin = this.pendingPermission(target, input.requestID)
|
|
259
301
|
const always = input.always === true
|
|
260
302
|
const decision: PermissionDecision = always ? "always" : "once"
|
|
303
|
+
const concierge = await this.isConcierge(caller)
|
|
261
304
|
return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
|
|
262
305
|
selfTree: true,
|
|
263
|
-
choose: (usable) => this.choosePermission(usable, origin.request, always),
|
|
306
|
+
choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
|
|
264
307
|
escalation: (evaluation) => ({
|
|
265
308
|
verb: "approve",
|
|
266
309
|
target: target.sessionID,
|
|
@@ -281,6 +324,39 @@ export class Acting {
|
|
|
281
324
|
})
|
|
282
325
|
}
|
|
283
326
|
|
|
327
|
+
/**
|
|
328
|
+
* (CON-06) Checks an agent's proposed act as the verb would, short of its grant: kill switch (paused), tenancy
|
|
329
|
+
* (not_found/offline), the Concierge guard (forbidden {policy: concierge}), the pending request or form
|
|
330
|
+
* (not_found), self-tree (forbidden), the prompt rules (invalid). Consumes, audits and sends nothing.
|
|
331
|
+
*/
|
|
332
|
+
async preflight(caller: Caller, verb: ProposalVerb, addr: string, ids: { readonly requestID?: string; readonly formID?: string; readonly text?: string; readonly answer?: FormAnswer }): Promise<Preflighted> {
|
|
333
|
+
const { actor, userID } = caller
|
|
334
|
+
if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
|
|
335
|
+
if (verb === "prompt" && actor.kind !== "user") {
|
|
336
|
+
const text = (ids.text ?? "").trim()
|
|
337
|
+
if (!text) throw invalid("Empty prompt")
|
|
338
|
+
if (text.length > DELIVER_TEXT_MAX) throw invalid(`Prompt longer than ${DELIVER_TEXT_MAX} characters`)
|
|
339
|
+
}
|
|
340
|
+
const target = this.locate(userID, addr)
|
|
341
|
+
if (actor.kind !== "user") await this.guard(userID, target)
|
|
342
|
+
let request: PermissionRequest | undefined
|
|
343
|
+
if (verb === "deny") request = this.pendingPermission(target, ids.requestID ?? "").request
|
|
344
|
+
if (verb === "answer" && !this.deps.registry.formTargets(target.rootID, ids.formID ?? "").length) throw new AgentError("not_found", `No pending form ${ids.formID} in this session's tree`)
|
|
345
|
+
if (verb === "answer" && ids.answer !== undefined && actor.kind !== "user") this.checkAnswer(target, ids.formID ?? "", ids.answer)
|
|
346
|
+
if ((verb === "deny" || verb === "answer") && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
347
|
+
throw forbidden("A session never answers requests in its own session tree")
|
|
348
|
+
}
|
|
349
|
+
if (verb === "prompt" && actor.kind !== "user") {
|
|
350
|
+
if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
|
|
351
|
+
if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
|
|
352
|
+
}
|
|
353
|
+
let name: string | undefined
|
|
354
|
+
try {
|
|
355
|
+
name = this.deps.directory.resolveOne(userID, target.sessionID).name
|
|
356
|
+
} catch {}
|
|
357
|
+
return { sessionID: target.sessionID, rootID: target.rootID, ...(name ? { name } : {}), ...(request ? { request } : {}) }
|
|
358
|
+
}
|
|
359
|
+
|
|
284
360
|
/** Rejects a pending permission request (any request: a reject is always safe), with an optional reason. */
|
|
285
361
|
async deny(caller: Caller, addr: string, input: DenyInput): Promise<Denied> {
|
|
286
362
|
const target = this.locate(caller.userID, addr)
|
|
@@ -306,6 +382,8 @@ export class Acting {
|
|
|
306
382
|
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
383
|
return this.act(caller, "answer", target, { formID: input.formID }, input.why, {
|
|
308
384
|
selfTree: true,
|
|
385
|
+
// (CON-13) Checked after the grant's checks (a holder without the verb learns nothing of the form), before it runs.
|
|
386
|
+
check: () => (input.cancel || caller.actor.kind === "user" ? undefined : this.checkAnswer(target, input.formID, input.answer)),
|
|
309
387
|
run: async (grant) => {
|
|
310
388
|
const form = input.cancel
|
|
311
389
|
? { rootID: target.rootID, formID: input.formID, cancel: true as const }
|
|
@@ -318,6 +396,88 @@ export class Acting {
|
|
|
318
396
|
})
|
|
319
397
|
}
|
|
320
398
|
|
|
399
|
+
/**
|
|
400
|
+
* (CON-13) A pending form's schema in the addressed session's tree: its title and fields (keys, types, options,
|
|
401
|
+
* required…), never anything else of the session. Checked like the answer verb short of its grant: kill switch,
|
|
402
|
+
* tenancy, the Concierge guard, the pending form, self-tree. Consumes, audits and sends nothing.
|
|
403
|
+
*/
|
|
404
|
+
async form(caller: Caller, addr: string, formID: string): Promise<PendingForm> {
|
|
405
|
+
const { actor, userID } = caller
|
|
406
|
+
if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
|
|
407
|
+
const target = this.locate(userID, addr)
|
|
408
|
+
if (actor.kind !== "user") await this.guard(userID, target)
|
|
409
|
+
if (!this.deps.registry.formTargets(target.rootID, formID).length) throw new AgentError("not_found", `No pending form ${formID} in this session's tree`)
|
|
410
|
+
if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) throw forbidden("A session never answers requests in its own session tree")
|
|
411
|
+
const found = this.formBlocker(target, formID)
|
|
412
|
+
return {
|
|
413
|
+
sessionID: target.sessionID,
|
|
414
|
+
formID,
|
|
415
|
+
askedBy: found?.sessionID ?? target.sessionID,
|
|
416
|
+
title: found?.blocker.summary ?? "question",
|
|
417
|
+
...(found?.blocker.question ? { question: true as const } : {}),
|
|
418
|
+
fields: found?.blocker.fields ?? [],
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* (CON-13) Whether the agent's grant would approve this pending request once, as approve() decides it: kill switch,
|
|
424
|
+
* tenancy, the Concierge guard, pending in the tree, self-tree, a usable grant with approve, then every never rule
|
|
425
|
+
* and the allow rules (choosePermission). Never acts, consumes, audits or escalates; any refusal is false. The
|
|
426
|
+
* hourly cap is not counted (the act may still be rate limited). The board's `inGrant` (the model never decides).
|
|
427
|
+
*/
|
|
428
|
+
async covers(caller: Caller, addr: string, requestID: string): Promise<boolean> {
|
|
429
|
+
const { actor, userID } = caller
|
|
430
|
+
if (actor.kind === "user") return false
|
|
431
|
+
try {
|
|
432
|
+
this.deps.acting.ensureActing(userID)
|
|
433
|
+
const target = this.locate(userID, addr)
|
|
434
|
+
await this.guard(userID, target)
|
|
435
|
+
const { request } = this.pendingPermission(target, requestID)
|
|
436
|
+
if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) return false
|
|
437
|
+
const usable = await this.deps.grants.usable(actor, "approve", { directory: target.directory })
|
|
438
|
+
if (!usable.length) return false
|
|
439
|
+
this.choosePermission(usable, request, false)
|
|
440
|
+
return true
|
|
441
|
+
} catch {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* (CON-13 decision) The board's facts for a pending request: `inGrant` (covers(): the policy's truth) and `auto`:
|
|
448
|
+
* inGrant and not the tap risk tier, i.e. the Concierge may approve it once without the user.
|
|
449
|
+
*/
|
|
450
|
+
async coverage(caller: Caller, addr: string, requestID: string): Promise<{ inGrant: boolean; auto: boolean }> {
|
|
451
|
+
const inGrant = await this.covers(caller, addr, requestID)
|
|
452
|
+
if (!inGrant) return { inGrant, auto: false }
|
|
453
|
+
try {
|
|
454
|
+
const target = this.locate(caller.userID, addr)
|
|
455
|
+
const { request } = this.pendingPermission(target, requestID)
|
|
456
|
+
if (!request) return { inGrant, auto: false }
|
|
457
|
+
const usable = await this.deps.grants.usable(caller.actor, "approve", { directory: target.directory })
|
|
458
|
+
const never = [...this.deps.acting.hubNever(), ...usable.flatMap((g) => patterns(g.permissions.never))]
|
|
459
|
+
return { inGrant, auto: riskTier(request, never) !== "tap" }
|
|
460
|
+
} catch {
|
|
461
|
+
return { inGrant, auto: false }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The form's blocker in the target's tree (with the session that shows it), when the registry reports it. */
|
|
466
|
+
private formBlocker(target: Located, formID: string) {
|
|
467
|
+
for (const s of this.deps.registry.status?.(target.rootID) ?? []) {
|
|
468
|
+
const blocker = s.blockers.find((b) => b.kind === "form" && b.id === formID)
|
|
469
|
+
if (blocker) return { sessionID: s.id, blocker }
|
|
470
|
+
}
|
|
471
|
+
return undefined
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** (CON-13) 400 invalid with the precise problem when an agent's answer does not fit the form's fields. */
|
|
475
|
+
private checkAnswer(target: Located, formID: string, answer: FormAnswer): void {
|
|
476
|
+
const fields = this.formBlocker(target, formID)?.blocker.fields
|
|
477
|
+
const problem = fields ? formAnswerFieldProblem(fields, answer) : undefined
|
|
478
|
+
if (problem) throw new AgentError("invalid", problem, { hint: "Read the form's fields first (concierge_form, or show) and answer with their keys and option values" })
|
|
479
|
+
}
|
|
480
|
+
|
|
321
481
|
/** Stops the addressed session's tree. */
|
|
322
482
|
async stop(caller: Caller, addr: string, input: StopInput): Promise<StoppedSession> {
|
|
323
483
|
const target = this.locate(caller.userID, addr)
|
|
@@ -361,16 +521,16 @@ export class Acting {
|
|
|
361
521
|
}
|
|
362
522
|
return this.act(caller, "prompt", target, {}, input.why, {
|
|
363
523
|
run: async (grant) => {
|
|
364
|
-
if (!grant) return this.userPrompt(target.sessionID, text, images)
|
|
524
|
+
if (!grant) return this.userPrompt(target.sessionID, text, images, input.intent)
|
|
365
525
|
return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
|
|
366
526
|
},
|
|
367
527
|
})
|
|
368
528
|
}
|
|
369
529
|
|
|
370
|
-
private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined): Promise<UserPrompted> {
|
|
530
|
+
private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined, intent?: false): Promise<UserPrompted> {
|
|
371
531
|
let started
|
|
372
532
|
try {
|
|
373
|
-
started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
|
|
533
|
+
started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true, ...(intent === false ? { intent } : {}) })
|
|
374
534
|
} catch (cause) {
|
|
375
535
|
if (cause instanceof BusyError) throw new AgentError("rate_limited", "Another turn is starting in this session", { hint: "Try again", extra: { retryAfter: 1 } })
|
|
376
536
|
throw cause
|
|
@@ -427,6 +587,8 @@ export class Acting {
|
|
|
427
587
|
/** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
|
|
428
588
|
readonly choose?: (usable: Grant[]) => Grant
|
|
429
589
|
readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
|
|
590
|
+
/** (CON-13) Input checks that need the target (throw invalid): after the grant's checks, before it is consumed. */
|
|
591
|
+
readonly check?: () => void
|
|
430
592
|
readonly run: (grant: Grant | undefined) => Promise<T>
|
|
431
593
|
},
|
|
432
594
|
): Promise<T> {
|
|
@@ -446,16 +608,21 @@ export class Acting {
|
|
|
446
608
|
try {
|
|
447
609
|
if (actor.kind !== "user") {
|
|
448
610
|
acting.ensureActing(userID)
|
|
611
|
+
await this.guard(userID, target)
|
|
449
612
|
const usable = await grants.usable(actor, verb, { directory: target.directory })
|
|
450
613
|
if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
451
614
|
if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
452
615
|
throw forbidden("A session never answers requests in its own session tree")
|
|
453
616
|
}
|
|
454
617
|
grant = plan.choose ? plan.choose(usable) : usable[0]!
|
|
618
|
+
plan.check?.()
|
|
455
619
|
await grants.consume(grant)
|
|
456
620
|
acting.ensureActing(userID)
|
|
457
621
|
}
|
|
458
|
-
|
|
622
|
+
// (CON-13) What the spoke said when it refused, never a bare 500.
|
|
623
|
+
const result = await plan.run(grant).catch((cause: unknown) => {
|
|
624
|
+
throw refusedBySession(cause)
|
|
625
|
+
})
|
|
459
626
|
await record("allow", "ok", why)
|
|
460
627
|
return result
|
|
461
628
|
} catch (cause) {
|
|
@@ -474,6 +641,13 @@ export class Acting {
|
|
|
474
641
|
}
|
|
475
642
|
}
|
|
476
643
|
|
|
644
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): agents are forbidden {policy: concierge}. */
|
|
645
|
+
private async guard(userID: string, target: Located): Promise<void> {
|
|
646
|
+
if (this.deps.protect && ((await this.deps.protect(userID, target.rootID)) || (await this.deps.protect(userID, target.sessionID)))) {
|
|
647
|
+
throw new AgentError("forbidden", "Only the user acts on the Concierge", { extra: { policy: "concierge" } })
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
477
651
|
/** Hands the escalation to the hook: undefined when there is none or it failed. */
|
|
478
652
|
private async escalate(caller: Caller, escalation: Omit<Escalation, "userID" | "actor"> | undefined): Promise<{ decisionID?: string } | undefined> {
|
|
479
653
|
if (!this.deps.escalate || !escalation) return undefined
|
|
@@ -485,10 +659,25 @@ export class Acting {
|
|
|
485
659
|
}
|
|
486
660
|
}
|
|
487
661
|
|
|
488
|
-
/** The
|
|
489
|
-
private
|
|
662
|
+
/** (CON-13 decision) The actor is a Concierge session (current or past); a failed read counts as yes. */
|
|
663
|
+
private async isConcierge(caller: Caller): Promise<boolean> {
|
|
664
|
+
const { actor } = caller
|
|
665
|
+
if (actor.kind !== "session" || !this.deps.conciergeActor) return false
|
|
666
|
+
try {
|
|
667
|
+
return await this.deps.conciergeActor(caller.userID, actor.sessionID)
|
|
668
|
+
} catch {
|
|
669
|
+
return true
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
|
|
675
|
+
* `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
|
|
676
|
+
*/
|
|
677
|
+
private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
|
|
490
678
|
const hubNever = this.deps.acting.hubNever()
|
|
491
679
|
const ids = usable.map((g) => g.id)
|
|
680
|
+
if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
|
|
492
681
|
if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
|
|
493
682
|
const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
|
|
494
683
|
for (const form of neverForms(request)) {
|
|
@@ -504,7 +693,10 @@ export class Acting {
|
|
|
504
693
|
let reason: string | undefined
|
|
505
694
|
for (const { grant, allow, never } of rules) {
|
|
506
695
|
const verdict = evaluatePermission(request, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
507
|
-
if (verdict.verdict === "allow")
|
|
696
|
+
if (verdict.verdict === "allow") {
|
|
697
|
+
if (concierge && riskTier(request, allNever) === "tap") throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_TAP_REASON, tier: "tap", grants: ids })
|
|
698
|
+
return grant
|
|
699
|
+
}
|
|
508
700
|
if (verdict.verdict === "outside") reason ??= verdict.reason
|
|
509
701
|
}
|
|
510
702
|
throw new NeedsUser({ verdict: "outside", reason: reason ?? "Outside the grant", tier: riskTier(request, allNever), grants: ids })
|
|
@@ -570,12 +762,25 @@ function bounded(text: string, max: number): string {
|
|
|
570
762
|
}
|
|
571
763
|
|
|
572
764
|
const gone = () => new AgentError("gone", "Already answered, expired or ended")
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* (CON-13) The session's harness refused the act (OpenCode said why, e.g. "Unknown form field: Questions"): 400
|
|
768
|
+
* invalid with its words (bounded, one line), never a 500 "Internal error" a model would repeat to the user. The
|
|
769
|
+
* audit keeps it as allowed · failed. A disconnect stays offline; hub errors pass through.
|
|
770
|
+
*/
|
|
771
|
+
export function refusedBySession(cause: unknown): unknown {
|
|
772
|
+
if (cause instanceof AgentError || cause instanceof NeedsUser || cause instanceof BusyError) return cause
|
|
773
|
+
if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) return new AgentError("offline", "The session's spoke disconnected")
|
|
774
|
+
const said = sanitizeText(cause instanceof Error ? cause.message : String(cause)).replace(/\s+/g, " ").trim().slice(0, 300) || "no reason given"
|
|
775
|
+
return new AgentError("invalid", `The session refused it: ${said}`, { extra: { refusedBy: "session" } })
|
|
776
|
+
}
|
|
573
777
|
/** The spoke lacks the core op: unsupported {feature, harness}, as the read routes answer. */
|
|
574
778
|
const unsupported = ({ feature, harness }: Unsupported) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
575
779
|
|
|
576
780
|
/** An error's audit decision and outcome (shared with the launch routes). */
|
|
577
781
|
export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
578
782
|
if (!(cause instanceof AgentError)) return ["allow", "failed"]
|
|
783
|
+
if (cause.options.extra?.refusedBy === "session") return ["allow", "failed"]
|
|
579
784
|
switch (cause.code) {
|
|
580
785
|
case "paused":
|
|
581
786
|
return ["paused", "refused"]
|