@huynle/hub 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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
package/src/central/main.ts
CHANGED
|
@@ -68,7 +68,7 @@ Usage: hub-server [--help | --version] (also installed as opencode-hub and ope
|
|
|
68
68
|
Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
|
|
69
69
|
HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
|
|
70
70
|
HUB_PUBLIC_URL origin used in links and cookies (default http://<host>:<port>)
|
|
71
|
-
HUB_STATE_DIR machines, devices, keys (default ~/.local/state/hub)
|
|
71
|
+
HUB_STATE_DIR machines, devices, keys (default $XDG_STATE_HOME/hub or ~/.local/state/hub)
|
|
72
72
|
OPENROUTER_API_KEY default speech key; or set speech providers in the web app (Settings → Speech)
|
|
73
73
|
HUB_CONFIG path of hub.json
|
|
74
74
|
|
package/src/central/mcp-http.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
|
|
11
11
|
import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
|
|
12
12
|
import { readCredentials } from "./agent/actor"
|
|
13
|
+
import { bodyError, readBounded } from "./agent/body"
|
|
13
14
|
|
|
14
15
|
export const MCP_PATH = "/mcp"
|
|
15
16
|
|
|
@@ -44,25 +45,12 @@ const answer = (response: RpcResponse) =>
|
|
|
44
45
|
// A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
|
|
45
46
|
Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
|
|
46
47
|
|
|
47
|
-
/** The body as text, or
|
|
48
|
-
async function readCapped(request: Request, max: number): Promise<string |
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
const chunks: Uint8Array[] = []
|
|
54
|
-
let size = 0
|
|
55
|
-
for (;;) {
|
|
56
|
-
const { done, value } = await reader.read()
|
|
57
|
-
if (done) break
|
|
58
|
-
size += value.byteLength
|
|
59
|
-
if (size > max) {
|
|
60
|
-
void reader.cancel().catch(() => {})
|
|
61
|
-
return undefined
|
|
62
|
-
}
|
|
63
|
-
chunks.push(value)
|
|
64
|
-
}
|
|
65
|
-
return new TextDecoder().decode(Buffer.concat(chunks))
|
|
48
|
+
/** The body as text, or why not (over `max` bytes or too slow; agent/body.ts bounds the bytes and time read). */
|
|
49
|
+
async function readCapped(request: Request, max: number): Promise<{ readonly text: string } | { readonly status: 400 | 413; readonly message: string; readonly close: boolean }> {
|
|
50
|
+
const body = await readBounded(request, max)
|
|
51
|
+
if (body.ok) return { text: new TextDecoder().decode(body.bytes) }
|
|
52
|
+
if (body.reason === "timeout") return { status: 400, message: bodyError(body, max).message, close: true }
|
|
53
|
+
return { status: 413, message: `Request body over ${max} bytes`, close: body.close }
|
|
66
54
|
}
|
|
67
55
|
|
|
68
56
|
const acceptsJSON = (accept: string | null) => {
|
|
@@ -89,8 +77,9 @@ export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Respon
|
|
|
89
77
|
const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
|
|
90
78
|
if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
|
|
91
79
|
if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
|
|
92
|
-
const
|
|
93
|
-
if (text
|
|
80
|
+
const read = await readCapped(request, max)
|
|
81
|
+
if (!("text" in read)) return failure(read.status, "invalid", read.message, read.close ? { connection: "close" } : {})
|
|
82
|
+
const text = read.text
|
|
94
83
|
let message: unknown
|
|
95
84
|
try {
|
|
96
85
|
message = JSON.parse(text)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Profile application (HWA-15; contract: Profiles in docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md and
|
|
2
|
+
// packages/protocol/src/profiles.ts). Plugs into ProfileStore's `ack` seam and applies a session's pinned version
|
|
3
|
+
// through the session's spoke:
|
|
4
|
+
//
|
|
5
|
+
// - Only the spoke that owns the session (registry.owner) and belongs to the pin's tenant is ever sent the op, and
|
|
6
|
+
// only when it announced the spoke feature `profileApply` (never implied for older spokes). Otherwise the pin is
|
|
7
|
+
// recorded `unsupported` (naming the harness) or `pending` with `offline` (no spoke reports the session now).
|
|
8
|
+
// - The PUT answers at once with `pending`; the spoke's ProfileApplyResult is recorded when it arrives: applied,
|
|
9
|
+
// partial, rejected, restart_required or unsupported. A result that names another session, profile, version or
|
|
10
|
+
// digest, or is malformed, is recorded as `rejected` ("invalid acknowledgment"), never as applied. A result for a
|
|
11
|
+
// pin that changed meanwhile is dropped (recordAck checks the revision). applied/partial become the session's
|
|
12
|
+
// `effective` version; restart_required does not (it becomes effective when the restarted session's spoke is
|
|
13
|
+
// sent the pin again).
|
|
14
|
+
// - Per session, sends are serialized: a newer pin waits for the one in flight, and pins superseded while waiting
|
|
15
|
+
// are skipped (only the latest is sent). Sends overall are bounded (`concurrency`).
|
|
16
|
+
// - Reconnect: when a sessions frame shows a session's owner on a connection the pin was not last sent through, the
|
|
17
|
+
// pin is sent again (adapters treat a repeated digest as idempotent), except a `rejected` one (the harness refused
|
|
18
|
+
// that digest; the user re-pins) and an `unsupported` one the new connection still cannot serve. The last-sent
|
|
19
|
+
// map is in memory and bounded (`trackedSessions`); losing it (hub restart, another pod) only costs an idempotent
|
|
20
|
+
// re-send. Each pod handles the spokes connected to it; requests to another pod's spoke go through SpokeHub's pod
|
|
21
|
+
// RPC with the pin's tenant.
|
|
22
|
+
// - Errors: the spoke's own error is `rejected` with its reason (bounded, control characters removed); a timeout
|
|
23
|
+
// or unreachable spoke stays `pending` (re-sent on the spoke's next sessions frame or reconnect); a disconnect is
|
|
24
|
+
// `pending` + `offline`.
|
|
25
|
+
// - Events: `profile.ack` (family manage) carries IDs, version, revision and status only, never notes.
|
|
26
|
+
// - Launch: applyAtLaunch pins the launch's explicit version and waits (bounded) for the acknowledgment so the
|
|
27
|
+
// launch's prompt runs with it; every outcome becomes a launch note, never a failed launch.
|
|
28
|
+
// - Authority: pinning confers nothing. An app token pinning a profile also needs a usable prompt grant on the
|
|
29
|
+
// session (pinAuthorizer), which is spent like a prompt; the acting switch pauses it.
|
|
30
|
+
// - Secrets: the op carries `{{secret:NAME}}` references only. Central never resolves them; the spoke/adapter
|
|
31
|
+
// resolves them on its machine when a command is rendered, and never reports values back (notes name secrets at
|
|
32
|
+
// most).
|
|
33
|
+
import {
|
|
34
|
+
type AgentProfileVersion,
|
|
35
|
+
type LaunchAgentProfile,
|
|
36
|
+
PROFILE_APPLY_FEATURE,
|
|
37
|
+
PROFILE_APPLY_LIMITS,
|
|
38
|
+
type ProfileAck,
|
|
39
|
+
type ProfileApplyRequest,
|
|
40
|
+
type ProfileApplyResult,
|
|
41
|
+
profileApplyResultProblem,
|
|
42
|
+
type ProfileAssignment,
|
|
43
|
+
type ServerRequest,
|
|
44
|
+
type SpokeFeature,
|
|
45
|
+
} from "../../protocol"
|
|
46
|
+
import type { ActingSwitch } from "../agent/acting-switch"
|
|
47
|
+
import type { AgentActor } from "../agent/actor"
|
|
48
|
+
import { AgentError } from "../agent/errors"
|
|
49
|
+
import type { AgentEvents } from "../agent/events"
|
|
50
|
+
import type { Grants } from "../agent/grants"
|
|
51
|
+
import type { Clock } from "../backend/types"
|
|
52
|
+
import { RequestTimeoutError, RequestTooLargeError, SpokeError } from "../spoke-ws"
|
|
53
|
+
import { profileAckEvent } from "./events"
|
|
54
|
+
import type { ProfileStore } from "./store"
|
|
55
|
+
|
|
56
|
+
/** What the service reads of the registry (Registry implements it). */
|
|
57
|
+
export interface ApplyRegistry {
|
|
58
|
+
owner(sessionID: string): string | undefined
|
|
59
|
+
supports(connID: string, feature: SpokeFeature): boolean
|
|
60
|
+
harnessOf(connID: string): string | undefined
|
|
61
|
+
sessionTenant(sessionID: string): string | undefined
|
|
62
|
+
reportedSessions(connID: string): string[]
|
|
63
|
+
session(sessionID: string): { readonly directory: string } | undefined
|
|
64
|
+
onSessions(listener: (connID: string) => void): () => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Hub → spoke requests (SpokeHub.request). */
|
|
68
|
+
export interface ApplySpokes {
|
|
69
|
+
request(connID: string, req: ServerRequest, options?: { readonly tenant?: string }): Promise<unknown>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ProfileApplyLimits {
|
|
73
|
+
/** Spoke requests in flight at once (per hub process). */
|
|
74
|
+
readonly concurrency: number
|
|
75
|
+
/** Sessions whose last-sent connection is remembered for reconnects. */
|
|
76
|
+
readonly trackedSessions: number
|
|
77
|
+
/** How long a launch waits for its profile's acknowledgment before prompting anyway. */
|
|
78
|
+
readonly launchWaitMs: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const PROFILE_APPLY_SERVICE_LIMITS: ProfileApplyLimits = { concurrency: 4, trackedSessions: 10_000, launchWaitMs: 30_000 }
|
|
82
|
+
|
|
83
|
+
export interface ProfileApplierDeps {
|
|
84
|
+
readonly profiles: Pick<ProfileStore, "assignment" | "version" | "recordAck" | "assign">
|
|
85
|
+
readonly registry: ApplyRegistry
|
|
86
|
+
readonly spokes: ApplySpokes
|
|
87
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
88
|
+
readonly now?: Clock
|
|
89
|
+
readonly limits?: Partial<ProfileApplyLimits>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Target = { readonly kind: "offline" } | { readonly kind: "unsupported"; readonly connID: string; readonly harness: string } | { readonly kind: "online"; readonly connID: string }
|
|
93
|
+
|
|
94
|
+
interface SendOptions {
|
|
95
|
+
/** Send only while the pin still has this revision (a pin's own send); absent: the current pin (reconnect). */
|
|
96
|
+
readonly revision?: number
|
|
97
|
+
/** Sent because the session showed up on a new connection. */
|
|
98
|
+
readonly reconnect?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const OFFLINE_NOTE = "no spoke reports this session now; the hub applies the pin when one does"
|
|
102
|
+
const TIMEOUT_NOTE = "the spoke did not answer in time; the hub sends the pin again with the spoke's next sessions update or when it reconnects"
|
|
103
|
+
|
|
104
|
+
/** Plain text of at most noteChars characters (control and format characters become spaces). */
|
|
105
|
+
export function noteText(text: string): string {
|
|
106
|
+
const clean = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
|
|
107
|
+
return clean.length <= PROFILE_APPLY_LIMITS.noteChars ? clean : `${clean.slice(0, PROFILE_APPLY_LIMITS.noteChars - 1)}…`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const key = (userID: string, sessionID: string) => `${userID}\u0000${sessionID}`
|
|
111
|
+
|
|
112
|
+
export class ProfileApplier {
|
|
113
|
+
private readonly deps: ProfileApplierDeps
|
|
114
|
+
private readonly now: Clock
|
|
115
|
+
private readonly limits: ProfileApplyLimits
|
|
116
|
+
/** Per session: the tail of its serialized sends. */
|
|
117
|
+
private readonly chains = new Map<string, Promise<void>>()
|
|
118
|
+
private readonly inflight = new Set<Promise<void>>()
|
|
119
|
+
/** Per session: the connection its pin was last sent through (or checked against); insertion order = age. */
|
|
120
|
+
private readonly sentVia = new Map<string, string>()
|
|
121
|
+
private active = 0
|
|
122
|
+
private readonly waiting: Array<() => void> = []
|
|
123
|
+
private unsubscribe: (() => void) | undefined
|
|
124
|
+
|
|
125
|
+
constructor(deps: ProfileApplierDeps) {
|
|
126
|
+
this.deps = deps
|
|
127
|
+
this.now = deps.now ?? Date.now
|
|
128
|
+
this.limits = { ...PROFILE_APPLY_SERVICE_LIMITS, ...deps.limits }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** ProfileStore's `ack` option: the immediate status of a new pin; the spoke's answer is recorded later. */
|
|
132
|
+
readonly ackFor = async (userID: string, assignment: ProfileAssignment, version: AgentProfileVersion): Promise<ProfileAck> => {
|
|
133
|
+
const at = this.now()
|
|
134
|
+
const target = this.target(userID, assignment.sessionID)
|
|
135
|
+
if (target.kind === "offline") return offlineAck(version.digest, at)
|
|
136
|
+
if (target.kind === "unsupported") {
|
|
137
|
+
this.remember(key(userID, assignment.sessionID), target.connID)
|
|
138
|
+
return unsupportedAck(version.digest, target.harness, at)
|
|
139
|
+
}
|
|
140
|
+
void this.schedule(userID, assignment.sessionID, { revision: assignment.revision })
|
|
141
|
+
return { status: "pending", digest: version.digest, at }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Starts re-applying pins when sessions show up on a new connection. */
|
|
145
|
+
start(): void {
|
|
146
|
+
this.unsubscribe ??= this.deps.registry.onSessions((connID) => this.sessionsFrom(connID))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
stop(): void {
|
|
150
|
+
this.unsubscribe?.()
|
|
151
|
+
this.unsubscribe = undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolves when every send started so far has finished (tests, shutdown). */
|
|
155
|
+
async idle(): Promise<void> {
|
|
156
|
+
while (this.inflight.size) await Promise.allSettled([...this.inflight])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sessions whose last-sent connection is remembered (bounded by trackedSessions). */
|
|
160
|
+
tracked(): number {
|
|
161
|
+
return this.sentVia.size
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pins the launch's explicit profile version to its new session and waits (at most launchWaitMs) for the
|
|
166
|
+
* acknowledgment. Returns notes for the launch reason; never throws.
|
|
167
|
+
*/
|
|
168
|
+
async applyAtLaunch(userID: string, sessionID: string, ref: LaunchAgentProfile): Promise<string[]> {
|
|
169
|
+
const label = `profile ${ref.profileID} v${ref.version}`
|
|
170
|
+
try {
|
|
171
|
+
const current = await this.deps.profiles.assignment(userID, sessionID)
|
|
172
|
+
const pinned = await this.deps.profiles.assign(userID, sessionID, { profileID: ref.profileID, version: ref.version, expectedRevision: current?.revision ?? 0 })
|
|
173
|
+
const chain = this.chains.get(key(userID, sessionID))
|
|
174
|
+
if (chain) await withTimeout(chain, this.limits.launchWaitMs)
|
|
175
|
+
const after = await this.deps.profiles.assignment(userID, sessionID)
|
|
176
|
+
const ack = after?.revision === pinned.revision ? after.ack : pinned.ack
|
|
177
|
+
return [`${label} ${ack?.status ?? "pending"}${ack?.offline ? " (offline)" : ""}`]
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
return [`${label} not applied: ${noteText(cause instanceof Error ? cause.message : String(cause))}`]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- internals ----------
|
|
184
|
+
|
|
185
|
+
private target(userID: string, sessionID: string): Target {
|
|
186
|
+
const { registry } = this.deps
|
|
187
|
+
const connID = registry.owner(sessionID)
|
|
188
|
+
if (!connID || registry.sessionTenant(sessionID) !== userID) return { kind: "offline" }
|
|
189
|
+
if (!registry.supports(connID, PROFILE_APPLY_FEATURE)) return { kind: "unsupported", connID, harness: registry.harnessOf(connID) ?? "unknown" }
|
|
190
|
+
return { kind: "online", connID }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A sessions frame: re-applies pins of sessions this connection now owns and was not last sent through. */
|
|
194
|
+
private sessionsFrom(connID: string): void {
|
|
195
|
+
const { registry } = this.deps
|
|
196
|
+
for (const sessionID of registry.reportedSessions(connID)) {
|
|
197
|
+
if (registry.owner(sessionID) !== connID) continue
|
|
198
|
+
const userID = registry.sessionTenant(sessionID)
|
|
199
|
+
if (!userID) continue
|
|
200
|
+
const k = key(userID, sessionID)
|
|
201
|
+
if (this.sentVia.get(k) === connID) continue
|
|
202
|
+
this.remember(k, connID)
|
|
203
|
+
void this.schedule(userID, sessionID, { reconnect: true })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private remember(k: string, connID: string): void {
|
|
208
|
+
this.sentVia.delete(k)
|
|
209
|
+
this.sentVia.set(k, connID)
|
|
210
|
+
while (this.sentVia.size > this.limits.trackedSessions) this.sentVia.delete(this.sentVia.keys().next().value!)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Queues a send behind the session's previous one. */
|
|
214
|
+
private schedule(userID: string, sessionID: string, options: SendOptions): Promise<void> {
|
|
215
|
+
const k = key(userID, sessionID)
|
|
216
|
+
const previous = this.chains.get(k) ?? Promise.resolve()
|
|
217
|
+
const next = previous
|
|
218
|
+
.then(() => this.limited(() => this.send(userID, sessionID, options)))
|
|
219
|
+
.catch((cause) => console.error(`[hub] profile apply for ${sessionID} failed: ${cause instanceof Error ? cause.message : String(cause)}`))
|
|
220
|
+
this.chains.set(k, next)
|
|
221
|
+
this.inflight.add(next)
|
|
222
|
+
void next.finally(() => {
|
|
223
|
+
this.inflight.delete(next)
|
|
224
|
+
if (this.chains.get(k) === next) this.chains.delete(k)
|
|
225
|
+
})
|
|
226
|
+
return next
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async limited<T>(run: () => Promise<T>): Promise<T> {
|
|
230
|
+
if (this.active >= this.limits.concurrency) await new Promise<void>((resolve) => this.waiting.push(resolve))
|
|
231
|
+
this.active++
|
|
232
|
+
try {
|
|
233
|
+
return await run()
|
|
234
|
+
} finally {
|
|
235
|
+
this.active--
|
|
236
|
+
this.waiting.shift()?.()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async send(userID: string, sessionID: string, options: SendOptions): Promise<void> {
|
|
241
|
+
const { profiles, spokes } = this.deps
|
|
242
|
+
const assignment = await profiles.assignment(userID, sessionID)
|
|
243
|
+
if (!assignment || (options.revision !== undefined && assignment.revision !== options.revision)) return
|
|
244
|
+
if (options.reconnect && assignment.ack?.status === "rejected") return
|
|
245
|
+
const version = await profiles.version(userID, assignment.profileID, assignment.version)
|
|
246
|
+
if (!version) return
|
|
247
|
+
const k = key(userID, sessionID)
|
|
248
|
+
const target = this.target(userID, sessionID)
|
|
249
|
+
if (target.kind === "offline") {
|
|
250
|
+
this.sentVia.delete(k)
|
|
251
|
+
return this.record(userID, assignment, offlineAck(version.digest, this.now()))
|
|
252
|
+
}
|
|
253
|
+
this.remember(k, target.connID)
|
|
254
|
+
if (target.kind === "unsupported") {
|
|
255
|
+
if (options.reconnect && assignment.ack?.status === "unsupported") return
|
|
256
|
+
return this.record(userID, assignment, unsupportedAck(version.digest, target.harness, this.now()))
|
|
257
|
+
}
|
|
258
|
+
const req: ProfileApplyRequest = { op: PROFILE_APPLY_FEATURE, sessionID, profileID: assignment.profileID, version: assignment.version, digest: version.digest, content: version.content }
|
|
259
|
+
let result: unknown
|
|
260
|
+
try {
|
|
261
|
+
result = await spokes.request(target.connID, req, { tenant: userID })
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
const ack = this.failure(cause, version.digest)
|
|
264
|
+
if (ack.status === "pending") this.sentVia.delete(k)
|
|
265
|
+
return this.record(userID, assignment, ack)
|
|
266
|
+
}
|
|
267
|
+
const problem = profileApplyResultProblem(result, req)
|
|
268
|
+
if (problem) return this.record(userID, assignment, { status: "rejected", digest: version.digest, notes: [noteText(`invalid acknowledgment from the spoke: ${problem}`)], at: this.now() })
|
|
269
|
+
const answer = result as ProfileApplyResult
|
|
270
|
+
await this.record(userID, assignment, { status: answer.status, digest: version.digest, ...(answer.notes?.length ? { notes: [...answer.notes] } : {}), at: this.now() })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private failure(cause: unknown, digest: string): ProfileAck {
|
|
274
|
+
const at = this.now()
|
|
275
|
+
if (cause instanceof RequestTimeoutError) return { status: "pending", digest, notes: [TIMEOUT_NOTE], at }
|
|
276
|
+
if (cause instanceof RequestTooLargeError) return { status: "rejected", digest, notes: ["the profile version is too large to send in one frame"], at }
|
|
277
|
+
if (cause instanceof SpokeError) {
|
|
278
|
+
if (/disconnected/i.test(cause.message)) return offlineAck(digest, at)
|
|
279
|
+
return { status: "rejected", digest, notes: [noteText(`the spoke refused: ${cause.message}`)], at }
|
|
280
|
+
}
|
|
281
|
+
return { status: "pending", digest, notes: [noteText(`the spoke could not be reached (${cause instanceof Error ? cause.name : "error"}); the hub sends the pin again when it reconnects`)], at }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async record(userID: string, assignment: ProfileAssignment, ack: ProfileAck): Promise<void> {
|
|
285
|
+
const written = await this.deps.profiles.recordAck(userID, assignment.sessionID, assignment.revision, ack)
|
|
286
|
+
if (!written || written.ack?.status !== ack.status || written.ack.at !== ack.at) return
|
|
287
|
+
await this.deps.events
|
|
288
|
+
?.append(userID, profileAckEvent(assignment.sessionID, assignment.profileID, assignment.version, assignment.revision, ack.status))
|
|
289
|
+
.catch((cause) => console.error(`[hub] profile.ack event failed: ${(cause as Error).message}`))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const offlineAck = (digest: string, at: number): ProfileAck => ({ status: "pending", digest, notes: [OFFLINE_NOTE], at, offline: true })
|
|
294
|
+
const unsupportedAck = (digest: string, harness: string, at: number): ProfileAck => ({
|
|
295
|
+
status: "unsupported",
|
|
296
|
+
digest,
|
|
297
|
+
notes: [noteText(`the ${harness} spoke does not announce ${PROFILE_APPLY_FEATURE}; update it or its harness adapter`)],
|
|
298
|
+
at,
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
async function withTimeout(promise: Promise<unknown>, ms: number): Promise<void> {
|
|
302
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
303
|
+
await Promise.race([promise, new Promise<void>((resolve) => (timer = setTimeout(resolve, ms)))])
|
|
304
|
+
clearTimeout(timer)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- authority ----------
|
|
308
|
+
|
|
309
|
+
export interface PinAuthorizerDeps {
|
|
310
|
+
readonly registry: Pick<ApplyRegistry, "session" | "sessionTenant">
|
|
311
|
+
readonly grants: Pick<Grants, "usable" | "consume">
|
|
312
|
+
readonly acting?: Pick<ActingSwitch, "ensureActing">
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Checks a pin's caller beyond its scopes: (actor, userID, sessionID) → throws the agent error. */
|
|
316
|
+
export type PinAuthorizer = (actor: AgentActor, userID: string, sessionID: string) => Promise<void>
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* An app token that pins a profile (which changes what a running session is told) also needs a usable prompt grant
|
|
320
|
+
* on the session, and spends one action of it. Users need nothing more; sessions never pin (routes refuse them).
|
|
321
|
+
*/
|
|
322
|
+
export function pinAuthorizer(deps: PinAuthorizerDeps): PinAuthorizer {
|
|
323
|
+
return async (actor, userID, sessionID) => {
|
|
324
|
+
if (actor.kind === "user") return
|
|
325
|
+
if (actor.kind !== "app") throw new AgentError("forbidden", "Only users and app tokens pin profiles")
|
|
326
|
+
deps.acting?.ensureActing(userID)
|
|
327
|
+
const live = deps.registry.sessionTenant(sessionID) === userID ? deps.registry.session(sessionID) : undefined
|
|
328
|
+
if (!live) throw new AgentError("offline", "No spoke reports this session now, so the prompt grant a pin needs cannot be checked")
|
|
329
|
+
const usable = await deps.grants.usable(actor, "prompt", { directory: live.directory })
|
|
330
|
+
if (!usable.length) throw new AgentError("forbidden", "Pinning a profile as an app also needs a prompt grant on the session")
|
|
331
|
+
await deps.grants.consume(usable[0]!)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Profile content rules of the registry (HWA-14), on top of the frozen protocol check (profileContentProblem):
|
|
2
|
+
//
|
|
3
|
+
// - Command templates: literal text with `{{name}}` parameters (lowercase, digits, `_`; `{{arguments}}` is the
|
|
4
|
+
// whole argument string by convention) and `{{secret:NAME}}` secret references. Nothing else: no expressions,
|
|
5
|
+
// conditionals, loops, filters or includes. `\{{` writes a literal `{{`. Rendering substitutes each value
|
|
6
|
+
// literally, once; a value is never parsed again, so it cannot inject placeholders.
|
|
7
|
+
// - Secrets: profiles carry secret REFERENCES only (`{{secret:NAME}}`), which central never resolves; the harness
|
|
8
|
+
// side resolves them, or leaves them, as it reports. A literal credential anywhere (instructions, skills,
|
|
9
|
+
// commands) is refused, and the refusal names where and what kind, never the value.
|
|
10
|
+
// - No setup hooks or dependency installation: such fields are `unsupported` (refused, never ignored); other
|
|
11
|
+
// unknown fields are `invalid`. Content is normalized to the known fields.
|
|
12
|
+
// - The digest is the sha256 hex of the canonical JSON (keys sorted at every level) of the normalized content.
|
|
13
|
+
import { createHash } from "node:crypto"
|
|
14
|
+
import { canonicalProfileJSON, isText, PROFILE_LIMITS, profileContentProblem, profileTemplateParts, type ProfileCommand, type ProfileSkill, type ProfileTemplatePart, type ProfileVersionContent } from "../../protocol"
|
|
15
|
+
|
|
16
|
+
export const TEMPLATE_LIMITS = {
|
|
17
|
+
/** Distinct parameters per template. */
|
|
18
|
+
params: 16,
|
|
19
|
+
/** Characters of one parameter value. */
|
|
20
|
+
valueChars: 4_000,
|
|
21
|
+
/** Characters of a rendered command. */
|
|
22
|
+
renderedChars: 16_000,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/** A refused profile body: `invalid` (400) or `unsupported` (422, feature `setupHooks`). */
|
|
26
|
+
export class ProfileContentError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
readonly code: "invalid" | "unsupported",
|
|
29
|
+
message: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = "ProfileContentError"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PARAM = /^[a-z][a-z0-9_]{0,39}$/
|
|
37
|
+
|
|
38
|
+
export type TemplateParse = { readonly ok: true; readonly params: string[]; readonly secrets: string[] } | { readonly ok: false; readonly problem: string }
|
|
39
|
+
|
|
40
|
+
type Piece = ProfileTemplatePart
|
|
41
|
+
|
|
42
|
+
/** Splits a template into literal text, parameters and secret references (the shared protocol tokenizer). */
|
|
43
|
+
const pieces = (template: string): Piece[] | string => profileTemplateParts(template)
|
|
44
|
+
|
|
45
|
+
/** The parameters and secret references of a template, or why it is refused. */
|
|
46
|
+
export function parseTemplate(template: string): TemplateParse {
|
|
47
|
+
const parsed = pieces(template)
|
|
48
|
+
if (typeof parsed === "string") return { ok: false, problem: parsed }
|
|
49
|
+
const params: string[] = []
|
|
50
|
+
const secrets: string[] = []
|
|
51
|
+
for (const piece of parsed) {
|
|
52
|
+
if ("param" in piece && !params.includes(piece.param)) params.push(piece.param)
|
|
53
|
+
if ("secret" in piece && !secrets.includes(piece.secret)) secrets.push(piece.secret)
|
|
54
|
+
}
|
|
55
|
+
if (params.length > TEMPLATE_LIMITS.params) return { ok: false, problem: `at most ${TEMPLATE_LIMITS.params} parameters` }
|
|
56
|
+
return { ok: true, params, secrets }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Renders a template with exactly its parameters. Values are text up to TEMPLATE_LIMITS.valueChars; secret
|
|
61
|
+
* references stay as written (central never resolves them); the result is bounded by renderedChars.
|
|
62
|
+
*/
|
|
63
|
+
export function renderTemplate(template: string, values: Readonly<Record<string, unknown>>): string {
|
|
64
|
+
const parsed = pieces(template)
|
|
65
|
+
if (typeof parsed === "string") throw new ProfileContentError("invalid", `template: ${parsed}`)
|
|
66
|
+
const { params } = parseTemplate(template) as { params: string[] }
|
|
67
|
+
const missing = params.filter((name) => !Object.hasOwn(values, name))
|
|
68
|
+
if (missing.length) throw new ProfileContentError("invalid", `missing parameter(s) ${missing.join(", ")}`)
|
|
69
|
+
const unknown = Object.keys(values).filter((name) => !params.includes(name))
|
|
70
|
+
if (unknown.length) throw new ProfileContentError("invalid", `unknown parameter(s) ${unknown.map((n) => (PARAM.test(n) ? n : "?")).join(", ")}`)
|
|
71
|
+
for (const name of params) {
|
|
72
|
+
const value = values[name]
|
|
73
|
+
if (typeof value !== "string" || !isText(value, TEMPLATE_LIMITS.valueChars)) throw new ProfileContentError("invalid", `parameter ${name} must be text of at most ${TEMPLATE_LIMITS.valueChars} characters`)
|
|
74
|
+
}
|
|
75
|
+
let out = ""
|
|
76
|
+
for (const piece of parsed) {
|
|
77
|
+
out += "text" in piece ? piece.text : "param" in piece ? (values[piece.param] as string) : piece.raw
|
|
78
|
+
if (out.length > TEMPLATE_LIMITS.renderedChars) throw new ProfileContentError("invalid", `rendered command is too long (more than ${TEMPLATE_LIMITS.renderedChars} characters)`)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Distinctive credential formats; prose about secrets ("set API_KEY=…") is not matched. */
|
|
84
|
+
const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
|
|
85
|
+
["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
86
|
+
["hub app token", /\bhat_[0-9a-f]{16}_[A-Za-z0-9_-]{43,}/],
|
|
87
|
+
["github token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
|
|
88
|
+
["github token", /\bgithub_pat_[A-Za-z0-9_]{22,}/],
|
|
89
|
+
["api key", /\bsk-[A-Za-z0-9_-]{20,}/],
|
|
90
|
+
["api key", /\bAIza[0-9A-Za-z_-]{35}/],
|
|
91
|
+
["aws access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
92
|
+
["slack token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
|
|
93
|
+
["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
|
|
94
|
+
["bearer token", /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}/],
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
/** The kind of literal credential in a text, or undefined. */
|
|
98
|
+
export function credentialFinding(text: string): string | undefined {
|
|
99
|
+
for (const [kind, pattern] of CREDENTIALS) if (pattern.test(text)) return kind
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fields that would run code or install things on a machine: refused as unsupported. */
|
|
104
|
+
const HOOK_FIELDS = new Set(["hooks", "hook", "setup", "install", "postinstall", "preinstall", "dependencies", "devdependencies", "requirements", "scripts"])
|
|
105
|
+
const CONTENT_FIELDS = new Set(["instructions", "model", "skills", "commands"])
|
|
106
|
+
const SKILL_FIELDS = new Set(["name", "description", "body"])
|
|
107
|
+
const COMMAND_FIELDS = new Set(["name", "description", "template"])
|
|
108
|
+
|
|
109
|
+
const fieldLabel = (key: string) => (/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)")
|
|
110
|
+
|
|
111
|
+
function checkFields(record: Record<string, unknown>, allowed: ReadonlySet<string>, where: string): void {
|
|
112
|
+
for (const key of Object.keys(record)) {
|
|
113
|
+
if (allowed.has(key)) continue
|
|
114
|
+
if (HOOK_FIELDS.has(key.toLowerCase())) {
|
|
115
|
+
throw new ProfileContentError("unsupported", `${where}${fieldLabel(key)}: profiles carry no setup hooks, scripts or dependency installation`)
|
|
116
|
+
}
|
|
117
|
+
throw new ProfileContentError("invalid", `${where}unknown field ${fieldLabel(key)}`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
122
|
+
|
|
123
|
+
/** Validates a profile version body and returns it normalized to the known fields; throws ProfileContentError. */
|
|
124
|
+
export function checkContent(v: unknown): ProfileVersionContent {
|
|
125
|
+
if (!isRecord(v)) throw new ProfileContentError("invalid", "content must be an object")
|
|
126
|
+
checkFields(v, CONTENT_FIELDS, "")
|
|
127
|
+
if (Array.isArray(v.skills)) for (const item of v.skills) if (isRecord(item)) checkFields(item, SKILL_FIELDS, "skills: ")
|
|
128
|
+
if (Array.isArray(v.commands)) for (const item of v.commands) if (isRecord(item)) checkFields(item, COMMAND_FIELDS, "commands: ")
|
|
129
|
+
const problem = profileContentProblem(v)
|
|
130
|
+
if (problem) throw new ProfileContentError("invalid", problem)
|
|
131
|
+
const skills = (v.skills as ProfileSkill[]).map(({ name, description, body }) => ({ name, description, body }))
|
|
132
|
+
const commands = (v.commands as ProfileCommand[]).map(({ name, description, template }) => ({ name, description, template }))
|
|
133
|
+
const content: ProfileVersionContent = {
|
|
134
|
+
...(v.instructions !== undefined ? { instructions: v.instructions as string } : {}),
|
|
135
|
+
...(v.model !== undefined ? { model: v.model as string } : {}),
|
|
136
|
+
skills,
|
|
137
|
+
commands,
|
|
138
|
+
}
|
|
139
|
+
for (const command of commands) {
|
|
140
|
+
const parsed = parseTemplate(command.template)
|
|
141
|
+
if (!parsed.ok) throw new ProfileContentError("invalid", `commands.${command.name}.template: ${parsed.problem}`)
|
|
142
|
+
}
|
|
143
|
+
const texts: [string, string][] = [
|
|
144
|
+
...(content.instructions !== undefined ? [["instructions", content.instructions] as [string, string]] : []),
|
|
145
|
+
...skills.flatMap((s): [string, string][] => [[`skills.${s.name}.description`, s.description], [`skills.${s.name}.body`, s.body]]),
|
|
146
|
+
...commands.flatMap((c): [string, string][] => [[`commands.${c.name}.description`, c.description], [`commands.${c.name}.template`, c.template]]),
|
|
147
|
+
]
|
|
148
|
+
for (const [where, text] of texts) {
|
|
149
|
+
const kind = credentialFinding(text)
|
|
150
|
+
if (kind) throw new ProfileContentError("invalid", `${where} contains what looks like a ${kind}; keep credentials out of profiles and use a secret reference {{secret:NAME}}`)
|
|
151
|
+
}
|
|
152
|
+
return content
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON with object keys sorted at every level (arrays keep their order); shared with spokes through the protocol. */
|
|
156
|
+
export const canonicalJSON = canonicalProfileJSON
|
|
157
|
+
|
|
158
|
+
/** sha256 hex of the canonical JSON of a version's content (what adapters echo in their acknowledgment). */
|
|
159
|
+
export const contentDigest = (content: unknown): string => createHash("sha256").update(canonicalJSON(content)).digest("hex")
|
|
160
|
+
|
|
161
|
+
/** Serialized size of a version's content in bytes (the storage quota counts it). */
|
|
162
|
+
export const contentBytes = (content: ProfileVersionContent): number => new TextEncoder().encode(JSON.stringify(content)).length
|
|
163
|
+
|
|
164
|
+
/** Secret names a version references (all command templates); for views, never values. */
|
|
165
|
+
export function secretReferences(content: ProfileVersionContent): string[] {
|
|
166
|
+
const out = new Set<string>()
|
|
167
|
+
for (const command of content.commands) {
|
|
168
|
+
const parsed = parseTemplate(command.template)
|
|
169
|
+
if (parsed.ok) for (const name of parsed.secrets) out.add(name)
|
|
170
|
+
}
|
|
171
|
+
return [...out]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { PROFILE_LIMITS }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Compact journal events of the profile registry (contract: PROFILE_EVENT_TYPES, family `manage`). Allowlisted
|
|
2
|
+
// payloads only: IDs, revisions, version numbers and flags; never names, titles, descriptions, instructions,
|
|
3
|
+
// skills, command templates or parameters.
|
|
4
|
+
import type { EventInput } from "../agent/events"
|
|
5
|
+
|
|
6
|
+
export const profileCreatedEvent = (profileID: string, revision: number): EventInput => ({
|
|
7
|
+
type: "profile.created",
|
|
8
|
+
family: "manage",
|
|
9
|
+
subject: { profileID },
|
|
10
|
+
data: { version: 1, revision },
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const profileVersionEvent = (profileID: string, version: number, revision: number): EventInput => ({
|
|
14
|
+
type: "profile.version.created",
|
|
15
|
+
family: "manage",
|
|
16
|
+
subject: { profileID },
|
|
17
|
+
data: { version, revision },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const profileArchivedEvent = (profileID: string, archived: boolean, revision: number): EventInput => ({
|
|
21
|
+
type: "profile.archived",
|
|
22
|
+
family: "manage",
|
|
23
|
+
subject: { profileID },
|
|
24
|
+
data: { archived, revision },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const profileAssignedEvent = (sessionID: string, profileID: string, version: number, revision: number): EventInput => ({
|
|
28
|
+
type: "profile.assigned",
|
|
29
|
+
family: "manage",
|
|
30
|
+
subject: { sessionID, profileID },
|
|
31
|
+
data: { version, revision },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** A spoke's (or the hub's) application status of a pin (HWA-15): never its notes, which may quote the harness. */
|
|
35
|
+
export const profileAckEvent = (sessionID: string, profileID: string, version: number, revision: number, status: string): EventInput => ({
|
|
36
|
+
type: "profile.ack",
|
|
37
|
+
family: "manage",
|
|
38
|
+
subject: { sessionID, profileID },
|
|
39
|
+
data: { version, revision, status },
|
|
40
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Idempotent profile creates: the shared route-level helper (../agent/idempotency.ts, extracted by HWA-06) over
|
|
2
|
+
// the StateStore collection "profile-idempotency", so records written before the extraction keep replaying.
|
|
3
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
4
|
+
import { RouteIdempotency } from "../agent/idempotency"
|
|
5
|
+
|
|
6
|
+
export { IDEMPOTENCY_LIMITS, type Answer } from "../agent/idempotency"
|
|
7
|
+
|
|
8
|
+
export const PROFILE_IDEMPOTENCY = "profile-idempotency"
|
|
9
|
+
|
|
10
|
+
export class Idempotency extends RouteIdempotency {
|
|
11
|
+
constructor(options: { readonly state: StateStore; readonly now?: Clock }) {
|
|
12
|
+
super({ ...options, collection: PROFILE_IDEMPOTENCY })
|
|
13
|
+
}
|
|
14
|
+
}
|