@huynle/hub 0.8.1 → 0.8.3
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 +193 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +116 -53
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +92 -7
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/protocol/src/wire.ts +1 -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/client.ts +3 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +14 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +134 -29
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +91 -6
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +8 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +96 -6
- package/src/spoke/spoke.ts +119 -36
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +23 -2
- package/src/status.ts +33 -6
- package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
- package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-lIOfSYck.js +0 -34
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The act verbs on /api/agent (design §5, §8; decision wvqeuis0): thin HTTP over the acting engine (acting.ts).
|
|
2
2
|
//
|
|
3
|
-
// POST /api/agent/sessions/:addr/approve {requestID, always?, why?}
|
|
3
|
+
// POST /api/agent/sessions/:addr/approve {requestID, always?, why?, recommend?} recommend: a Concierge advises it (R-03)
|
|
4
4
|
// POST /api/agent/sessions/:addr/deny {requestID, why?} why → the harness, with who denied it
|
|
5
5
|
// POST /api/agent/sessions/:addr/answer {formID, answer | cancel: true, why?}
|
|
6
6
|
// POST /api/agent/sessions/:addr/prompt {text, images? (users), delivery? "queue"|"steer", wake?, why?}
|
|
@@ -65,7 +65,8 @@ export function actingRoutes(acting: Acting): AgentRoute[] {
|
|
|
65
65
|
async handle({ request, params, actor, user }) {
|
|
66
66
|
const body = await bodyOf(request)
|
|
67
67
|
const always = flag(body, "always")
|
|
68
|
-
|
|
68
|
+
const recommend = flag(body, "recommend")
|
|
69
|
+
return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...(recommend ? { recommend } : {}), ...why(body) }))
|
|
69
70
|
},
|
|
70
71
|
},
|
|
71
72
|
{
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
|
|
17
17
|
// first; a request without parsed details, or truncated, is always needs_user (tap).
|
|
18
18
|
// · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
|
|
19
|
+
// · (R-03) the CURRENT Concierge session's approve without any usable grant is needs_user too (a Decision at the hub's tier;
|
|
20
|
+
// the hub's never rules still forbid; self-tree still applies), never a bare refusal; other agents stay forbidden.
|
|
21
|
+
// A Concierge escalation recommends approve only when it passed `recommend: true`.
|
|
19
22
|
// - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
|
|
20
23
|
// paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
|
|
21
24
|
// - Races: the request answered elsewhere first is gone (410).
|
|
@@ -83,8 +86,12 @@ export interface Escalation {
|
|
|
83
86
|
readonly request?: PermissionRequest
|
|
84
87
|
readonly always: boolean
|
|
85
88
|
readonly evaluation: Evaluation
|
|
86
|
-
/** The agent's reason (`--why`): its recommendation to approve. */
|
|
89
|
+
/** The agent's reason (`--why`): its recommendation to approve (a Concierge's: only with `recommend`). */
|
|
87
90
|
readonly why?: string
|
|
91
|
+
/** (R-03) The actor is a Concierge session: its escalation recommends approve only with `recommend`. */
|
|
92
|
+
readonly concierge?: true
|
|
93
|
+
/** (R-03) The Concierge itself advises approving (absent: the user only asked it to try). */
|
|
94
|
+
readonly recommend?: true
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
|
|
@@ -111,11 +118,18 @@ export interface ActingDeps {
|
|
|
111
118
|
* "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
|
|
112
119
|
*/
|
|
113
120
|
readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
|
|
121
|
+
/**
|
|
122
|
+
* (R-03 verify) Whether the acting session is the user's CURRENT Concierge session (never a retired one). Only it
|
|
123
|
+
* may escalate an approve without a grant; a failed read counts as no (fail closed: refused).
|
|
124
|
+
*/
|
|
125
|
+
readonly currentConcierge?: (userID: string, sessionID: string) => Promise<boolean>
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/** (CON-13 decision) Why the Concierge escalates a covered request. */
|
|
117
129
|
export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
|
|
118
130
|
export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
|
|
131
|
+
/** (R-03) Why the Concierge escalates an approval it holds no grant for. */
|
|
132
|
+
export const CONCIERGE_NO_GRANT_REASON = "No grant lets it approve here: the user decides"
|
|
119
133
|
|
|
120
134
|
/** Who acts, for which user. */
|
|
121
135
|
export interface Caller {
|
|
@@ -127,6 +141,8 @@ export interface ApproveInput {
|
|
|
127
141
|
readonly requestID: string
|
|
128
142
|
readonly always?: boolean
|
|
129
143
|
readonly why?: string
|
|
144
|
+
/** (R-03) The Concierge advises approving: its escalation recommends approve (other agents' why always does). */
|
|
145
|
+
readonly recommend?: boolean
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
export interface Approved {
|
|
@@ -282,6 +298,17 @@ function neverForms(request: PermissionRequest): PermissionRequest[] {
|
|
|
282
298
|
|
|
283
299
|
const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
|
|
284
300
|
|
|
301
|
+
/** Forbidden when a never rule (the hub's, or a usable grant's) matches the request as asked or normalised (saves too, for always). */
|
|
302
|
+
function checkNever(request: PermissionRequest, rules: ReadonlyArray<{ grant: Grant; never: Pattern[] }>, hubNever: readonly Pattern[], always: boolean): void {
|
|
303
|
+
const sets = rules.length ? rules.map((r) => ({ id: r.grant.id, never: r.never })) : [{ id: undefined, never: [] as Pattern[] }]
|
|
304
|
+
for (const form of neverForms(request)) {
|
|
305
|
+
for (const { id, never } of sets) {
|
|
306
|
+
const verdict = evaluatePermission(form, { grant: { allow: [], never }, hubNever, always, allowAlways: true })
|
|
307
|
+
if (verdict.verdict === "never") throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
const invalid = (message: string) => new AgentError("invalid", message)
|
|
286
313
|
const forbidden = (message: string) => new AgentError("forbidden", message)
|
|
287
314
|
const offline = () => new AgentError("offline", "No spoke reports this session now")
|
|
@@ -303,6 +330,8 @@ export class Acting {
|
|
|
303
330
|
const concierge = await this.isConcierge(caller)
|
|
304
331
|
return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
|
|
305
332
|
selfTree: true,
|
|
333
|
+
// (R-03) The Concierge without a grant escalates (needs_user, a Decision) instead of being refused.
|
|
334
|
+
...(concierge ? { withoutGrant: () => this.isCurrentConcierge(caller) } : {}),
|
|
306
335
|
choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
|
|
307
336
|
escalation: (evaluation) => ({
|
|
308
337
|
verb: "approve",
|
|
@@ -314,6 +343,8 @@ export class Acting {
|
|
|
314
343
|
always,
|
|
315
344
|
evaluation,
|
|
316
345
|
...(input.why ? { why: input.why } : {}),
|
|
346
|
+
...(concierge ? { concierge: true as const } : {}),
|
|
347
|
+
...(concierge && input.recommend === true ? { recommend: true as const } : {}),
|
|
317
348
|
}),
|
|
318
349
|
run: async (grant) => {
|
|
319
350
|
const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
|
|
@@ -519,9 +550,20 @@ export class Acting {
|
|
|
519
550
|
if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
|
|
520
551
|
if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
|
|
521
552
|
}
|
|
553
|
+
// A prompt commits a staged revert (OpenCode, quirk 9budy9na): only the console's Send (or Restore) ends one.
|
|
554
|
+
// Checked after the grant (an agent without one learns nothing about the session) and audited like any refusal.
|
|
555
|
+
const notStaged = () => {
|
|
556
|
+
if ((this.deps.registry.session(target.sessionID) as { revert?: unknown } | undefined)?.revert) {
|
|
557
|
+
throw new AgentError("conflict", "A revert is staged in this session: a prompt would make it permanent", { hint: "Restore it or send from the session's console first" })
|
|
558
|
+
}
|
|
559
|
+
}
|
|
522
560
|
return this.act(caller, "prompt", target, {}, input.why, {
|
|
561
|
+
check: notStaged,
|
|
523
562
|
run: async (grant) => {
|
|
524
|
-
if (!grant)
|
|
563
|
+
if (!grant) {
|
|
564
|
+
notStaged()
|
|
565
|
+
return this.userPrompt(target.sessionID, text, images, input.intent)
|
|
566
|
+
}
|
|
525
567
|
return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
|
|
526
568
|
},
|
|
527
569
|
})
|
|
@@ -584,6 +626,8 @@ export class Acting {
|
|
|
584
626
|
plan: {
|
|
585
627
|
/** A session never acts in its own tree (permissions and forms). */
|
|
586
628
|
readonly selfTree?: boolean
|
|
629
|
+
/** (R-03) No usable grant is not forbidden: `choose` gets none and throws NeedsUser (or forbidden). */
|
|
630
|
+
readonly withoutGrant?: () => Promise<boolean>
|
|
587
631
|
/** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
|
|
588
632
|
readonly choose?: (usable: Grant[]) => Grant
|
|
589
633
|
readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
|
|
@@ -610,7 +654,7 @@ export class Acting {
|
|
|
610
654
|
acting.ensureActing(userID)
|
|
611
655
|
await this.guard(userID, target)
|
|
612
656
|
const usable = await grants.usable(actor, verb, { directory: target.directory })
|
|
613
|
-
if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
657
|
+
if (!usable.length && !(plan.choose && plan.withoutGrant && (await plan.withoutGrant()))) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
|
|
614
658
|
if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
|
|
615
659
|
throw forbidden("A session never answers requests in its own session tree")
|
|
616
660
|
}
|
|
@@ -670,24 +714,32 @@ export class Acting {
|
|
|
670
714
|
}
|
|
671
715
|
}
|
|
672
716
|
|
|
717
|
+
/** (R-03 verify) The actor is the user's current Concierge session; a failed read (or no reader) is no. */
|
|
718
|
+
private async isCurrentConcierge(caller: Caller): Promise<boolean> {
|
|
719
|
+
const { actor } = caller
|
|
720
|
+
if (actor.kind !== "session" || !this.deps.currentConcierge) return false
|
|
721
|
+
try {
|
|
722
|
+
return await this.deps.currentConcierge(caller.userID, actor.sessionID)
|
|
723
|
+
} catch {
|
|
724
|
+
return false
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
673
728
|
/**
|
|
674
729
|
* The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
|
|
675
730
|
* `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
|
|
731
|
+
* (R-03 verify) Every never rule (the hub's and each usable grant's, also with no grant) is checked first, before
|
|
732
|
+
* any NeedsUser: a forbidden request never becomes a Decision, "always" or not.
|
|
676
733
|
*/
|
|
677
734
|
private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
|
|
678
735
|
const hubNever = this.deps.acting.hubNever()
|
|
679
736
|
const ids = usable.map((g) => g.id)
|
|
737
|
+
const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
|
|
738
|
+
if (request) checkNever(request, rules, hubNever, always)
|
|
680
739
|
if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
|
|
681
740
|
if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
for (const { grant, allow, never } of rules) {
|
|
685
|
-
const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
|
|
686
|
-
if (verdict.verdict === "never") {
|
|
687
|
-
throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
741
|
+
// (R-03) The current Concierge holds no grant: anything the never rules let through is the user's, at the hub's tier.
|
|
742
|
+
if (!usable.length) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_NO_GRANT_REASON, tier: request.truncated ? "tap" : riskTier(request, hubNever), grants: [] })
|
|
691
743
|
const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
|
|
692
744
|
if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
|
|
693
745
|
let reason: string | undefined
|
|
@@ -790,6 +842,9 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
|
790
842
|
return ["forbidden", "refused"]
|
|
791
843
|
case "rate_limited":
|
|
792
844
|
return ["rate_limited", "refused"]
|
|
845
|
+
// Allowed, but the session's state refuses it now (a staged revert).
|
|
846
|
+
case "conflict":
|
|
847
|
+
return ["allow", "refused"]
|
|
793
848
|
// (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
|
|
794
849
|
case "busy":
|
|
795
850
|
return ["busy", "refused"]
|
|
@@ -68,6 +68,8 @@ export interface DeliveryRegistry {
|
|
|
68
68
|
idle(sessionID: string): boolean
|
|
69
69
|
/** Higher: more recent activity. */
|
|
70
70
|
recency(sessionID: string): number
|
|
71
|
+
/** A revert is staged in the session (Registry.revertStaged): nothing is sent to it (absent: never). */
|
|
72
|
+
revertStaged?(sessionID: string): boolean
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
|
|
@@ -673,8 +675,13 @@ export class Delivery {
|
|
|
673
675
|
return { message: updated, delivery: result.delivered }
|
|
674
676
|
}
|
|
675
677
|
|
|
676
|
-
/**
|
|
678
|
+
/**
|
|
679
|
+
* Sends the request to each of the user's deliver-capable connections for the session until one takes it. Not at
|
|
680
|
+
* all while a revert is staged there: admitting anything commits it (decision in docs/session-view.md), so the
|
|
681
|
+
* message stays pending and goes in on the sessions frame that shows the revert restored or committed.
|
|
682
|
+
*/
|
|
677
683
|
private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
|
|
684
|
+
if (this.deps.registry.revertStaged?.(sessionID)) return undefined
|
|
678
685
|
for (const connID of this.targets(userID, sessionID)) {
|
|
679
686
|
let result: unknown
|
|
680
687
|
try {
|
|
@@ -748,6 +755,8 @@ export class Delivery {
|
|
|
748
755
|
if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
|
|
749
756
|
const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
|
|
750
757
|
const what = `${message.kind} ${message.id} to ${message.to}`
|
|
758
|
+
// Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
|
|
759
|
+
const why = this.deps.registry.revertStaged?.(value.sessionID) ? "a revert stayed staged in its session for 24 h" : "its session stayed offline or busy for 24 h"
|
|
751
760
|
try {
|
|
752
761
|
this.deps.expired?.(userID, message)
|
|
753
762
|
} catch (cause) {
|
|
@@ -755,14 +764,14 @@ export class Delivery {
|
|
|
755
764
|
}
|
|
756
765
|
const { from } = message
|
|
757
766
|
if (from.kind === "user") {
|
|
758
|
-
this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered:
|
|
767
|
+
this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: ${why}` })
|
|
759
768
|
} else if (from.kind === "session") {
|
|
760
769
|
const id = this.newID()
|
|
761
770
|
await this.request(userID, from.sessionID, {
|
|
762
771
|
op: "deliver",
|
|
763
772
|
sessionID: from.sessionID,
|
|
764
773
|
messageID: id,
|
|
765
|
-
text: `[hub] notice · ${what} was not delivered:
|
|
774
|
+
text: `[hub] notice · ${what} was not delivered: ${why} · ${id}`,
|
|
766
775
|
wake: false,
|
|
767
776
|
meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
|
|
768
777
|
})
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// agent's recommendation, the tier the hub set on the evaluation, about the asking session); escalating the same
|
|
6
6
|
// request again updates the open Decision's reason instead (no new push). Concurrent escalations race on a
|
|
7
7
|
// version-checked reservation: one opens the Decision, the other is rate_limited (retry).
|
|
8
|
+
// - (R-03) A Concierge's escalation recommends nothing unless it passed `recommend: true`: its why is then a note
|
|
9
|
+
// after the question ("The Concierge's note: …"), and the blocker only links the Decision (no "recommends").
|
|
8
10
|
// - The recommendation also shows on the request's blocker (registry.recommend: catalog, status, the push's
|
|
9
11
|
// service worker reads it there).
|
|
10
12
|
// - Answering (decisions.ts runs `act` after the answer wins) replies to the permission AS THE USER through the
|
|
@@ -150,19 +152,24 @@ export class Escalations {
|
|
|
150
152
|
const opened = await counters.incr(`escalations:${userID}:${partyKey(holder)}`, HOUR_MS)
|
|
151
153
|
if (opened > ESCALATIONS_PER_HOLDER_HOUR) throw rateLimited(`At most ${ESCALATIONS_PER_HOLDER_HOUR} escalations an hour`, 3600)
|
|
152
154
|
const pushes = await counters.incr(`escalation-push:${userID}`, ESCALATION_PUSH_COALESCE_MS)
|
|
153
|
-
|
|
155
|
+
// (R-03 verify) The Concierge never gets "always": its Decision offers Approve once or Deny only (the user can
|
|
156
|
+
// still approve always on the session's card).
|
|
157
|
+
const always = offersAlways(escalation)
|
|
158
|
+
const recommend = always ? "always" : "approve"
|
|
154
159
|
const why = boundedWhy(escalation.why)
|
|
160
|
+
const advises = recommends(escalation)
|
|
155
161
|
const { message, decision } = await decisions.ask(
|
|
156
162
|
userID,
|
|
157
163
|
escalation.actor,
|
|
158
164
|
{
|
|
159
|
-
|
|
165
|
+
// (R-03) A Concierge that only tries recommends nothing: its why is a note after the question.
|
|
166
|
+
question: question(escalation, partyLabel(holder)) + (!advises && why ? `\nThe Concierge's note: ${why}` : ""),
|
|
160
167
|
options: [
|
|
161
|
-
...(
|
|
168
|
+
...(always ? [{ id: "always", label: "Approve always" }] : []),
|
|
162
169
|
{ id: "approve", label: "Approve once" },
|
|
163
170
|
{ id: "deny", label: "Deny", safe: true },
|
|
164
171
|
],
|
|
165
|
-
recommendation: { option: recommend, ...(why ? { why } : {}) },
|
|
172
|
+
...(advises ? { recommendation: { option: recommend, ...(why ? { why } : {}) } } : {}),
|
|
166
173
|
urgency: "high",
|
|
167
174
|
tier: escalation.evaluation.tier,
|
|
168
175
|
about: [escalation.sessionID],
|
|
@@ -173,7 +180,7 @@ export class Escalations {
|
|
|
173
180
|
requestID: escalation.requestID,
|
|
174
181
|
rootID: escalation.rootID,
|
|
175
182
|
sessionID: escalation.sessionID,
|
|
176
|
-
always
|
|
183
|
+
always,
|
|
177
184
|
...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
|
|
178
185
|
...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
|
|
179
186
|
},
|
|
@@ -189,8 +196,8 @@ export class Escalations {
|
|
|
189
196
|
private async update(escalation: Escalation, holder: Party, decision: Decision): Promise<{ decisionID: string }> {
|
|
190
197
|
const why = boundedWhy(escalation.why)
|
|
191
198
|
let current = decision
|
|
192
|
-
if (why && why !== decision.recommendation?.why) {
|
|
193
|
-
const option = decision.recommendation?.option ?? (escalation
|
|
199
|
+
if (recommends(escalation) && why && why !== decision.recommendation?.why) {
|
|
200
|
+
const option = decision.recommendation?.option ?? (offersAlways(escalation) ? "always" : "approve")
|
|
194
201
|
current = (await this.deps.decisions.revise(escalation.userID, decision.id, { recommendation: { option, why } })) ?? decision
|
|
195
202
|
}
|
|
196
203
|
this.showOnBlocker(escalation.requestID, holder, current)
|
|
@@ -198,8 +205,11 @@ export class Escalations {
|
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
private showOnBlocker(requestID: string, holder: Party, decision: Decision): void {
|
|
208
|
+
// (R-03) Without a recommendation the blocker only links the Decision (its tier still gates quick answers).
|
|
201
209
|
const why = decision.recommendation?.why
|
|
202
|
-
const recommendation: BlockerRecommendation =
|
|
210
|
+
const recommendation: BlockerRecommendation = decision.recommendation
|
|
211
|
+
? { by: partyLabel(holder), recommends: "approve", ...(why ? { why } : {}), decisionID: decision.id }
|
|
212
|
+
: { by: partyLabel(holder), decisionID: decision.id }
|
|
203
213
|
this.deps.registry.recommend(requestID, recommendation)
|
|
204
214
|
}
|
|
205
215
|
|
|
@@ -211,6 +221,16 @@ export class Escalations {
|
|
|
211
221
|
}
|
|
212
222
|
}
|
|
213
223
|
|
|
224
|
+
/** (R-03 verify) Whether the Decision offers Approve always: never for the Concierge. */
|
|
225
|
+
function offersAlways(escalation: Escalation): boolean {
|
|
226
|
+
return escalation.always && !escalation.concierge
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** (R-03) Whether the escalation recommends approving: always for agents, for the Concierge only with `recommend`. */
|
|
230
|
+
function recommends(escalation: Escalation): boolean {
|
|
231
|
+
return !escalation.concierge || escalation.recommend === true
|
|
232
|
+
}
|
|
233
|
+
|
|
214
234
|
/** The agent's reason, cut to what a Decision keeps. */
|
|
215
235
|
function boundedWhy(why: string | undefined): string | undefined {
|
|
216
236
|
const trimmed = why?.trim()
|
|
@@ -221,7 +241,8 @@ function boundedWhy(why: string | undefined): string | undefined {
|
|
|
221
241
|
function question(escalation: Escalation, by: string): string {
|
|
222
242
|
const { request, evaluation } = escalation
|
|
223
243
|
const what = request ? `${request.action}${request.resources.length ? `: ${request.resources.join(", ")}` : ""}` : "a permission request (details unknown)"
|
|
224
|
-
const
|
|
244
|
+
const asksAlways = offersAlways(escalation)
|
|
245
|
+
const always = asksAlways && request?.save?.length ? ` always (saves ${request.save.join(", ")})` : asksAlways ? " always" : ""
|
|
225
246
|
const text = `${by} asks to approve${always} ${what}. Outside its grant: ${evaluation.reason}`
|
|
226
247
|
return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
|
|
227
248
|
}
|
|
@@ -105,6 +105,24 @@ const TOP_FIELDS = FIELDS.filter((field) => !SECTIONS.some(([section]) => sectio
|
|
|
105
105
|
/** (FX-03) The hint every unknown-field answer carries: callers often send a flat spec. */
|
|
106
106
|
export const LAUNCH_SPEC_SHAPE = `A launch spec is nested: ${SECTIONS.map(([section, fields]) => `${section}{${fields.join(", ")}}`).join(", ")}; top level: ${TOP_FIELDS.join(", ")}`
|
|
107
107
|
|
|
108
|
+
/** How validateLaunchSpec words its hints for one caller. */
|
|
109
|
+
export interface LaunchSpecOptions {
|
|
110
|
+
/**
|
|
111
|
+
* (R-04) Team staffing builds the spec: it sets launch.key itself and refuses the caller's, so a misplaced `key`
|
|
112
|
+
* points at idempotencyKey (the Idempotency-Key header), not "key (top level)".
|
|
113
|
+
*/
|
|
114
|
+
readonly staffing?: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where a misplaced `key` belongs and the shape hint, for one caller. */
|
|
118
|
+
interface Wording {
|
|
119
|
+
readonly keyPlace: string
|
|
120
|
+
readonly shape: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const PLAIN: Wording = { keyPlace: "key (top level)", shape: LAUNCH_SPEC_SHAPE }
|
|
124
|
+
const STAFFING: Wording = { keyPlace: "idempotencyKey (the Idempotency-Key header)", shape: `${LAUNCH_SPEC_SHAPE}; key is set by staffing: send idempotencyKey (the Idempotency-Key header) instead` }
|
|
125
|
+
|
|
108
126
|
const KEY = /^[!-~]{1,200}$/
|
|
109
127
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
|
|
110
128
|
const AGENT = /^[A-Za-z0-9_.-]{1,80}$/
|
|
@@ -120,21 +138,21 @@ const invalid = (message: string, hint?: string) => new AgentError("invalid", me
|
|
|
120
138
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
121
139
|
|
|
122
140
|
/** Where an unknown `key` at level `what` (launch, or a section) belongs instead: `section.key`s, or the top level. */
|
|
123
|
-
function placesOf(key: string, what: string): string[] {
|
|
141
|
+
function placesOf(key: string, what: string, wording: Wording): string[] {
|
|
124
142
|
const places = SECTIONS.filter(([section, fields]) => section !== what && fields.includes(key)).map(([section]) => `${section}.${key}`)
|
|
125
|
-
if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(`${key} (top level)`)
|
|
143
|
+
if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(key === "key" ? wording.keyPlace : `${key} (top level)`)
|
|
126
144
|
return places
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
/** Refuses every field outside `allowed` at once, naming where a misplaced one belongs (FX-03). */
|
|
130
|
-
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
|
|
148
|
+
function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string, wording: Wording = PLAIN): void {
|
|
131
149
|
const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
|
|
132
150
|
if (unknown.length === 0) return
|
|
133
|
-
const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what)).filter((places) => places.length > 0).map((places) => places.join(" or "))
|
|
151
|
+
const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what, wording)).filter((places) => places.length > 0).map((places) => places.join(" or "))
|
|
134
152
|
// Keys are the caller's: bounded in number and length.
|
|
135
153
|
const shown = [...unknown.slice(0, 10).map((key) => key.slice(0, 40)), ...(unknown.length > 10 ? ["…"] : [])]
|
|
136
154
|
const message = `Unknown ${what} field${unknown.length > 1 ? "s" : ""} ${shown.join(", ")}${suggestions.length ? `: did you mean ${suggestions.join(", ")}?` : ""}`
|
|
137
|
-
throw invalid(message,
|
|
155
|
+
throw invalid(message, wording.shape)
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
|
|
@@ -202,9 +220,9 @@ function validRoles(value: unknown): string[] {
|
|
|
202
220
|
return roles
|
|
203
221
|
}
|
|
204
222
|
|
|
205
|
-
function validWorkspace(value: unknown): LaunchWorkspace {
|
|
223
|
+
function validWorkspace(value: unknown, wording: Wording): LaunchWorkspace {
|
|
206
224
|
if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
|
|
207
|
-
onlyFields(value, WORKSPACE_FIELDS, "workspace")
|
|
225
|
+
onlyFields(value, WORKSPACE_FIELDS, "workspace", wording)
|
|
208
226
|
// canonicalProject rejects relative, `~` and root paths.
|
|
209
227
|
const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
|
|
210
228
|
if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
|
|
@@ -224,10 +242,10 @@ function validWorkspace(value: unknown): LaunchWorkspace {
|
|
|
224
242
|
}
|
|
225
243
|
}
|
|
226
244
|
|
|
227
|
-
function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
|
|
245
|
+
function validRuntime(value: unknown, workspace: LaunchWorkspace, wording: Wording): LaunchRuntime {
|
|
228
246
|
if (value === undefined) return { kind: "host" }
|
|
229
247
|
if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
|
|
230
|
-
onlyFields(value, RUNTIME_FIELDS, "runtime")
|
|
248
|
+
onlyFields(value, RUNTIME_FIELDS, "runtime", wording)
|
|
231
249
|
const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
|
|
232
250
|
const profile = matching(value.profile, SLUG, "runtime.profile")
|
|
233
251
|
if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
|
|
@@ -235,20 +253,20 @@ function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime
|
|
|
235
253
|
return { kind, ...(profile !== undefined ? { profile } : {}) }
|
|
236
254
|
}
|
|
237
255
|
|
|
238
|
-
function validPlacement(value: unknown): LaunchPlacement {
|
|
256
|
+
function validPlacement(value: unknown, wording: Wording): LaunchPlacement {
|
|
239
257
|
if (value === undefined) return { labels: {}, mode: "require" }
|
|
240
258
|
if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
|
|
241
|
-
onlyFields(value, PLACEMENT_FIELDS, "placement")
|
|
259
|
+
onlyFields(value, PLACEMENT_FIELDS, "placement", wording)
|
|
242
260
|
const labels = validLabels(value.labels, "placement.labels")
|
|
243
261
|
const machine = matching(value.machine, MACHINE, "placement.machine")
|
|
244
262
|
const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
|
|
245
263
|
return { labels, ...(machine !== undefined ? { machine } : {}), mode }
|
|
246
264
|
}
|
|
247
265
|
|
|
248
|
-
function validEnd(value: unknown): LaunchEnd {
|
|
266
|
+
function validEnd(value: unknown, wording: Wording): LaunchEnd {
|
|
249
267
|
if (value === undefined) return { when: "idle", cleanup: "keep" }
|
|
250
268
|
if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
|
|
251
|
-
onlyFields(value, END_FIELDS, "end")
|
|
269
|
+
onlyFields(value, END_FIELDS, "end", wording)
|
|
252
270
|
const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
|
|
253
271
|
let timeoutMs: number | undefined
|
|
254
272
|
if (value.timeout !== undefined) {
|
|
@@ -262,10 +280,11 @@ function validEnd(value: unknown): LaunchEnd {
|
|
|
262
280
|
// ---------- spec ----------
|
|
263
281
|
|
|
264
282
|
/** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
|
|
265
|
-
export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
283
|
+
export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions = {}): LaunchSpec {
|
|
284
|
+
const wording = options.staffing ? STAFFING : PLAIN
|
|
266
285
|
if (!isObject(input)) throw invalid("A launch is an object")
|
|
267
286
|
if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
|
|
268
|
-
onlyFields(input, FIELDS, "launch")
|
|
287
|
+
onlyFields(input, FIELDS, "launch", wording)
|
|
269
288
|
const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
|
|
270
289
|
let name: string | undefined
|
|
271
290
|
if (input.name !== undefined) {
|
|
@@ -276,9 +295,9 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
276
295
|
const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
|
|
277
296
|
const agent = matching(input.agent, AGENT, "agent")
|
|
278
297
|
const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
|
|
279
|
-
const workspace = validWorkspace(input.workspace)
|
|
280
|
-
const runtime = validRuntime(input.runtime, workspace)
|
|
281
|
-
const placement = validPlacement(input.placement)
|
|
298
|
+
const workspace = validWorkspace(input.workspace, wording)
|
|
299
|
+
const runtime = validRuntime(input.runtime, workspace, wording)
|
|
300
|
+
const placement = validPlacement(input.placement, wording)
|
|
282
301
|
let prompt: string | undefined
|
|
283
302
|
if (input.prompt !== undefined) {
|
|
284
303
|
if (typeof input.prompt !== "string" || input.prompt.length > PROMPT_MAX || PROMPT_CONTROL.test(input.prompt)) throw invalid(`prompt is text of up to ${PROMPT_MAX} characters`)
|
|
@@ -287,7 +306,7 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
|
|
|
287
306
|
// (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
|
|
288
307
|
const intent = input.intent === undefined ? undefined : intentText(input.intent)
|
|
289
308
|
const labels = validLabels(input.labels, "labels")
|
|
290
|
-
const end = validEnd(input.end)
|
|
309
|
+
const end = validEnd(input.end, wording)
|
|
291
310
|
const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
|
|
292
311
|
if (profileProblem) throw invalid(profileProblem)
|
|
293
312
|
const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
|
|
7
7
|
// route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
|
|
8
8
|
// Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
|
|
9
|
+
import { type ConciergeCallRecorder, recordConciergeCalls } from "../recording/concierge-taps"
|
|
9
10
|
import type { AgentEvents } from "./events"
|
|
10
11
|
import type { CursorKeys } from "./event-cursor"
|
|
11
12
|
import { eventsRoutes, type EventsRouteDeps } from "./events-routes"
|
|
@@ -88,6 +89,8 @@ export interface AgentRoute {
|
|
|
88
89
|
/** What the agent API needs from the hub. */
|
|
89
90
|
export interface AgentDeps {
|
|
90
91
|
readonly store: Store
|
|
92
|
+
/** (REC-01) Voice recordings: the Concierge's agent calls (recordConciergeCalls). */
|
|
93
|
+
readonly recording?: ConciergeCallRecorder
|
|
91
94
|
/** Hub-minted session tokens (P1-T5). */
|
|
92
95
|
readonly sessionTokens?: SessionTokens
|
|
93
96
|
/** Names, roles and address resolution (P1-T8); its routes are added when given. */
|
|
@@ -272,7 +275,7 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
272
275
|
const identity = deps.concierge?.identity
|
|
273
276
|
const directory = deps.directory
|
|
274
277
|
const launches = deps.launches
|
|
275
|
-
|
|
278
|
+
const guarded = identity && directory
|
|
276
279
|
? guardConciergeRoutes(all, {
|
|
277
280
|
identity,
|
|
278
281
|
resolve: (userID, addr) => directory.resolve(userID, addr).sessions.map((s) => s.sessionID),
|
|
@@ -286,6 +289,9 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
286
289
|
: {}),
|
|
287
290
|
})
|
|
288
291
|
: all
|
|
292
|
+
// (REC-01) concierge.tool: a session actor's calls (method, route, target, status, ms; never a body or response)
|
|
293
|
+
// reach the recording service, which keeps them only for a recorded Concierge session.
|
|
294
|
+
return deps.recording ? recordConciergeCalls(guarded, deps.recording) : guarded
|
|
289
295
|
}
|
|
290
296
|
|
|
291
297
|
/** HWA-28/40: unfiltered events and watch read by a team-scoped session leave out what discovery hides from it. */
|
|
@@ -328,6 +334,12 @@ function conciergeCurrent(deps: AgentDeps): { conciergeCurrent?: (userID: string
|
|
|
328
334
|
return identity ? { conciergeCurrent: async (userID: string) => (await identity.current(userID)).sessionID } : {}
|
|
329
335
|
}
|
|
330
336
|
|
|
337
|
+
/** (R-03 verify) Whether a session is the user's CURRENT Concierge session (the no-grant escalation; never a retired one). */
|
|
338
|
+
function currentConciergeActor(deps: AgentDeps): { currentConcierge?: (userID: string, sessionID: string) => Promise<boolean> } {
|
|
339
|
+
const identity = deps.concierge?.identity
|
|
340
|
+
return identity ? { currentConcierge: async (userID: string, sessionID: string) => (await identity.current(userID)).sessionID === sessionID } : {}
|
|
341
|
+
}
|
|
342
|
+
|
|
331
343
|
/** (CON-02) What reads show of intents: the stored ones of the listed sessions, by session. */
|
|
332
344
|
function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs: readonly string[]) => Promise<Map<string, SessionIntent>> } {
|
|
333
345
|
const { intents } = deps
|
|
@@ -350,6 +362,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
350
362
|
...(deps.escalate ? { escalate: deps.escalate } : {}),
|
|
351
363
|
...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
|
|
352
364
|
...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
|
|
365
|
+
...currentConciergeActor(deps),
|
|
353
366
|
}),
|
|
354
367
|
)
|
|
355
368
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// usage is per user. The same bytes stored by two users are two blobs.
|
|
10
10
|
// - put streams the body: it hashes as it reads, stops reading (and cancels the source) as soon as the body passes
|
|
11
11
|
// `maxBytes` (default and ceiling ARTIFACT_LIMITS.blobBytes), and stores nothing when the body is too large, its
|
|
12
|
-
// hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (never sniffed), or a new blob would
|
|
12
|
+
// hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (or the put's `mediaTypes`; never sniffed), or a new blob would
|
|
13
13
|
// take the user past `quotaBytes` (default ARTIFACT_LIMITS.userBlobBytes). Storing bytes that are already there
|
|
14
14
|
// stores nothing new, is never refused for quota, and refreshes `touchedAt`.
|
|
15
15
|
// - A blob becomes visible to get/has/stat/list only once all its bytes are durably stored and verified
|
|
@@ -30,6 +30,11 @@ export interface BlobPutOptions {
|
|
|
30
30
|
readonly maxBytes?: number
|
|
31
31
|
/** The user's total stored bytes a new blob may not exceed (default ARTIFACT_LIMITS.userBlobBytes). */
|
|
32
32
|
readonly quotaBytes?: number
|
|
33
|
+
/**
|
|
34
|
+
* The media types this put accepts instead of ARTIFACT_MEDIA_TYPES (REC-01: RECORDING_MEDIA_TYPES under the
|
|
35
|
+
* `rec~<userID>` tenant). Artifact routes never pass it, so audio stays refused there.
|
|
36
|
+
*/
|
|
37
|
+
readonly mediaTypes?: readonly string[]
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/** A stored blob's facts (no media type: that belongs to the revision's BlobRef). */
|
|
@@ -11,7 +11,9 @@ export interface PutLimits {
|
|
|
11
11
|
|
|
12
12
|
/** Checks put's media type and options (BlobInputError) and answers the limits in force. */
|
|
13
13
|
export function putLimits(mediaType: string, options: BlobPutOptions = {}): PutLimits {
|
|
14
|
-
|
|
14
|
+
// The allowlist is ARTIFACT_MEDIA_TYPES unless the caller names its own (REC-01 recordings: RECORDING_MEDIA_TYPES).
|
|
15
|
+
const allowed: readonly string[] = options.mediaTypes ?? ARTIFACT_MEDIA_TYPES
|
|
16
|
+
if (!allowed.includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
|
|
15
17
|
const maxBytes = options.maxBytes ?? ARTIFACT_LIMITS.blobBytes
|
|
16
18
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > ARTIFACT_LIMITS.blobBytes) {
|
|
17
19
|
throw new BlobInputError(`maxBytes must be 0–${ARTIFACT_LIMITS.blobBytes}`)
|