@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
|
@@ -1,15 +1,74 @@
|
|
|
1
|
-
// The network policy of machine enrollment: which hub URLs a machine credential
|
|
2
|
-
// link may be shown to the user.
|
|
1
|
+
// The network policy of machine enrollment (and of `hub login`'s device code): which hub URLs a machine credential
|
|
2
|
+
// or a login may be sent to, and which approval link may be shown to the user.
|
|
3
|
+
//
|
|
4
|
+
// Plain http (RP-10, Brain decision m5bzlmfq): refused unless the hub is on loopback, or the caller opted in with
|
|
5
|
+
// --allow-insecure-http (allowInsecureHTTP) AND the hub is a private-network IP address: RFC 1918 (10/8,
|
|
6
|
+
// 172.16/12, 192.168/16), IPv4 link-local (169.254/16), IPv6 unique-local (fc00::/7) or link-local (fe80::/10).
|
|
7
|
+
// Host names never qualify, `.local` included: what a name resolves to is not known here, and a name could point at
|
|
8
|
+
// a public address. Neither do public, shared (100.64/10, CGNAT/Tailscale), unspecified or IPv4-mapped addresses.
|
|
9
|
+
// The opt-in is per command and never stored; every use warns (insecureHTTPWarning).
|
|
10
|
+
import { networkInterfaces } from "node:os"
|
|
3
11
|
|
|
4
12
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]"])
|
|
5
13
|
const MAX_URL = 2048
|
|
6
14
|
|
|
15
|
+
export interface ServerURLOptions {
|
|
16
|
+
/** --allow-insecure-http: also plain http to a private-network IP address (isPrivateNetworkHost). */
|
|
17
|
+
readonly allowInsecureHTTP?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An IPv4 address in dotted-quad form (as the URL parser normalizes it), or undefined. */
|
|
21
|
+
function ipv4(host: string): number[] | undefined {
|
|
22
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
23
|
+
if (!m) return undefined
|
|
24
|
+
const parts = m.slice(1).map(Number)
|
|
25
|
+
return parts.every((n) => n <= 255) ? parts : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a URL host name (URL.hostname: IPv6 in brackets) is a private-network IP address: RFC 1918, IPv4
|
|
30
|
+
* link-local, IPv6 unique-local (fc00::/7) or link-local (fe80::/10). Never a name, loopback or a public address.
|
|
31
|
+
*/
|
|
32
|
+
export function isPrivateNetworkHost(host: string): boolean {
|
|
33
|
+
const v4 = ipv4(host)
|
|
34
|
+
if (v4) {
|
|
35
|
+
const [a, b] = v4 as [number, number]
|
|
36
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254)
|
|
37
|
+
}
|
|
38
|
+
const v6 = /^\[([0-9a-f:]+)\]$/i.exec(host)?.[1]?.toLowerCase()
|
|
39
|
+
if (!v6) return false
|
|
40
|
+
// The URL parser compresses IPv6 (no leading zeros, the longest zero run as ::): the first group leads.
|
|
41
|
+
const first = v6.startsWith(":") ? 0 : Number.parseInt(v6.split(":")[0]!, 16)
|
|
42
|
+
if (!Number.isFinite(first)) return false
|
|
43
|
+
return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What every command that uses a plain-http hub (allowed by the opt-in) says, each time. */
|
|
47
|
+
export function insecureHTTPWarning(server: string): string {
|
|
48
|
+
return `plain http to ${server} (--allow-insecure-http): the code, the credential and everything after it travel unencrypted, so anyone on this network can read or change them; use https unless this network is trusted`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Why a plain-http hub URL was refused, for the usage error (undefined when it was not refused for that). */
|
|
52
|
+
export function insecureHTTPRefusal(value: unknown, options: ServerURLOptions = {}): string | undefined {
|
|
53
|
+
if (typeof value !== "string" || checkServerURL(value, options)) return undefined
|
|
54
|
+
let url: URL
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(value)
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
if (url.protocol !== "http:") return undefined
|
|
61
|
+
if (!isPrivateNetworkHost(url.hostname)) return "the hub URL must be https: plain http is allowed only for a loopback hub, or with --allow-insecure-http for a private-network IP address (10.x, 172.16–31.x, 192.168.x, 169.254.x, fc00::/7, fe80::/10), never a public address or a host name"
|
|
62
|
+
return options.allowInsecureHTTP ? undefined : "plain http to a private-network address needs --allow-insecure-http (the traffic is unencrypted; prefer https)"
|
|
63
|
+
}
|
|
64
|
+
|
|
7
65
|
/**
|
|
8
66
|
* The normalized hub URL (origin + path, no trailing slash), or undefined when it is not allowed: https only, plain
|
|
9
|
-
* http only for a loopback hub (a local hub, and the tests)
|
|
10
|
-
* contract's hubURL check, and what
|
|
67
|
+
* http only for a loopback hub (a local hub, and the tests) or, with allowInsecureHTTP, a private-network IP address;
|
|
68
|
+
* no user info, query, fragment or whitespace. Without the opt-in this matches the contract's hubURL check, and what
|
|
69
|
+
* src/config.ts normalize() keeps.
|
|
11
70
|
*/
|
|
12
|
-
export function checkServerURL(value: unknown): string | undefined {
|
|
71
|
+
export function checkServerURL(value: unknown, options: ServerURLOptions = {}): string | undefined {
|
|
13
72
|
if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL) return undefined
|
|
14
73
|
if (/[\s?#]/.test(value)) return undefined
|
|
15
74
|
let url: URL
|
|
@@ -19,7 +78,8 @@ export function checkServerURL(value: unknown): string | undefined {
|
|
|
19
78
|
return undefined
|
|
20
79
|
}
|
|
21
80
|
if (url.username || url.password) return undefined
|
|
22
|
-
|
|
81
|
+
const plainOK = LOOPBACK.has(url.hostname) || (options.allowInsecureHTTP === true && isPrivateNetworkHost(url.hostname))
|
|
82
|
+
if (!(url.protocol === "https:" || (url.protocol === "http:" && plainOK))) return undefined
|
|
23
83
|
if (!/^https?:\/\/[^\s/]+/.test(value)) return undefined
|
|
24
84
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`
|
|
25
85
|
}
|
|
@@ -36,19 +96,85 @@ const USER_CODE = /^[A-Za-z0-9-]{4,32}$/
|
|
|
36
96
|
|
|
37
97
|
export const isUserCode = (value: unknown): value is string => typeof value === "string" && USER_CODE.test(value)
|
|
38
98
|
|
|
99
|
+
/** Whether a URL is plain http (an opted-in insecure hub gets a warning). */
|
|
100
|
+
export const isPlainHTTP = (server: string): boolean => server.startsWith("http:")
|
|
101
|
+
|
|
102
|
+
/** This machine's IP addresses (IPv6 without brackets or zone). */
|
|
103
|
+
export function localAddresses(): string[] {
|
|
104
|
+
return Object.values(networkInterfaces())
|
|
105
|
+
.flat()
|
|
106
|
+
.flatMap((a) => (a ? [a.address.replace(/%.*$/, "")] : []))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An address without IPv6 brackets, lower case, for comparing a URL host with localAddresses(). */
|
|
110
|
+
const bare = (host: string): string => host.replace(/^\[(.*)\]$/, "$1").toLowerCase()
|
|
111
|
+
|
|
112
|
+
const portOf = (url: URL): string => url.port || (url.protocol === "https:" ? "443" : "80")
|
|
113
|
+
|
|
114
|
+
export interface ApprovalLinkOptions {
|
|
115
|
+
/** This machine's addresses (default: localAddresses()). */
|
|
116
|
+
readonly localAddresses?: readonly string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
39
119
|
/**
|
|
40
|
-
* The approval link to show
|
|
41
|
-
*
|
|
42
|
-
* the
|
|
120
|
+
* The approval link to show (`server` must already have passed checkServerURL, with the caller's opt-in). The hub's
|
|
121
|
+
* own link is used when it is on the hub's origin (scheme, host and port). A loopback hub (on this machine) whose public URL is this machine's own private-network
|
|
122
|
+
* address on the same port (a LAN preview: `HUB_PUBLIC_URL=http://<lan ip>:<port>`, the hub listening on all
|
|
123
|
+
* addresses) gets its link shown, with the loopback one as `localURL`, so a user on another device of the LAN can
|
|
124
|
+
* open it: it reaches the same listener, so it is no other party's page. Anything else could send the user (and their
|
|
125
|
+
* hub login) to a phishing page: the link is rebuilt on the hub URL, with a warning.
|
|
43
126
|
*/
|
|
44
|
-
export function approvalURL(server: string, approveURL: unknown, userCode: string): { url: string; warning?: string } {
|
|
127
|
+
export function approvalURL(server: string, approveURL: unknown, userCode: string, options: ApprovalLinkOptions = {}): { url: string; warning?: string; localURL?: string } {
|
|
45
128
|
const own = `${server}/approve?code=${encodeURIComponent(userCode)}`
|
|
46
129
|
if (typeof approveURL !== "string" || approveURL.length > MAX_URL || /\s/.test(approveURL)) return { url: own, warning: "the hub sent no usable approval link; showing one on the hub URL" }
|
|
47
130
|
try {
|
|
48
131
|
const link = new URL(approveURL)
|
|
49
132
|
const base = new URL(server)
|
|
50
|
-
const
|
|
51
|
-
|
|
133
|
+
const clean = !link.username && !link.password
|
|
134
|
+
// The hub URL itself already passed the caller's policy (with or without the opt-in): its own origin is fine.
|
|
135
|
+
if (clean && link.origin === base.origin && checkServerURL(`${link.origin}${link.pathname}`, { allowInsecureHTTP: true }) !== undefined) return { url: link.href }
|
|
136
|
+
const sameListener =
|
|
137
|
+
clean &&
|
|
138
|
+
isLoopbackServer(server) &&
|
|
139
|
+
link.protocol === "http:" &&
|
|
140
|
+
base.protocol === "http:" &&
|
|
141
|
+
portOf(link) === portOf(base) &&
|
|
142
|
+
isPrivateNetworkHost(link.hostname) &&
|
|
143
|
+
(options.localAddresses ?? localAddresses()).some((a) => bare(a) === bare(link.hostname))
|
|
144
|
+
if (sameListener) return { url: link.href, localURL: own }
|
|
52
145
|
} catch {}
|
|
53
146
|
return { url: own, warning: "the hub's approval link is on another origin; showing one on the hub URL instead" }
|
|
54
147
|
}
|
|
148
|
+
|
|
149
|
+
/** A stored login URL that is plain http and not on loopback (an opted-in LAN hub, or an older login). */
|
|
150
|
+
export function isInsecureStoredURL(value: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
const url = new URL(value)
|
|
153
|
+
return url.protocol === "http:" && !LOOPBACK.has(url.hostname)
|
|
154
|
+
} catch {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What every command that uses such a stored login says on stderr (verify D5). */
|
|
160
|
+
export function storedInsecureWarning(value: string): string {
|
|
161
|
+
let shown = value.slice(0, 200)
|
|
162
|
+
try {
|
|
163
|
+
const url = new URL(value)
|
|
164
|
+
shown = `${url.origin}${url.pathname.replace(/\/+$/, "")}`
|
|
165
|
+
} catch {}
|
|
166
|
+
return `the stored hub URL ${shown} is plain http: your app token and everything this command sends or receives travel unencrypted on the network; log in again with an https URL (hub login --url https://<hub>)`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A URL as typed, for a refusal message, at most 200 characters: anything before an `@` in its authority-looking
|
|
171
|
+
* part (user info: a password or token) is replaced by ***. The authority starts after an http(s) scheme or any
|
|
172
|
+
* scheme followed by slashes or backslashes (`http:u:p@h`, `http:/u:p@h`, `http:\\u:p@h`), else at the start
|
|
173
|
+
* (`user:pass@h`), and ends at the first /, \\, ? or #. Leading whitespace is dropped.
|
|
174
|
+
*/
|
|
175
|
+
export function shownURL(value: string): string {
|
|
176
|
+
return value
|
|
177
|
+
.trimStart()
|
|
178
|
+
.replace(/^((?:https?:|[a-z][a-z0-9+.-]*:(?=[\/\\]))?[\/\\]*)[^\/\\?#]*@/i, "$1***@")
|
|
179
|
+
.slice(0, 200)
|
|
180
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// upgrades, so it answers 426 (Expected a WebSocket upgrade) to a token it accepts and 401 to one it rejects. No
|
|
4
4
|
// central change is needed; nothing is opened. The token goes only to a hub URL that passes checkServerURL, never
|
|
5
5
|
// across a redirect, and never into the output. v1.1: a credential already stored for a plain-http hub that is not on
|
|
6
|
-
// loopback is reported as `insecure`, with a warning, and
|
|
6
|
+
// loopback is reported as `insecure`, with a warning, and not sent there by `machine status` (the spoke and hub node do
|
|
7
|
+
// use it there; a new enrollment to it needs --allow-insecure-http and a private-network address).
|
|
7
8
|
import { enrollmentStatusFrom, isInsecureHubURL, validateEnrollmentStatus, type CredentialProbe, type CredentialStore, type EnrollmentStatus, type MachineCredential } from "../contracts/index.ts"
|
|
8
9
|
import { checkServerURL } from "./policy.ts"
|
|
9
10
|
import { failure, line, success, type MachineResult } from "./result.ts"
|
|
@@ -102,7 +103,7 @@ export async function machineStatus(options: MachineStatusOptions): Promise<Mach
|
|
|
102
103
|
const status = statusOf(credential, undefined, now())
|
|
103
104
|
if (!validateEnrollmentStatus(status).ok) return failure(COMMAND, "internal", `${store.location} holds a machine identity this hubctl cannot report`)
|
|
104
105
|
return success(COMMAND, status, [
|
|
105
|
-
`the stored hub URL ${line(credential.server, 300)} is plain http and not on loopback, so the credential was not checked (it
|
|
106
|
+
`the stored hub URL ${line(credential.server, 300)} is plain http and not on loopback, so the credential was not checked here (machine status does not send it there); the OpenCode spoke and hub node do send this machine token to that hub, unencrypted (allowed with --allow-insecure-http); enroll again with an https hub URL: hubctl machine enroll --server https://<hub> --reenroll`,
|
|
106
107
|
])
|
|
107
108
|
}
|
|
108
109
|
if (!checkServerURL(credential.server)) {
|
package/packages/cli/src/cli.ts
CHANGED
|
@@ -8,10 +8,12 @@ import { loginFile, readLogin, removeLogin, resolveCredentials, writeLogin, type
|
|
|
8
8
|
import { atName, logText, whoamiText, type Actor, roleText, sessionsText, showText, statusText, whoText } from "./format"
|
|
9
9
|
import { CliError, EXIT, withHub } from "./http"
|
|
10
10
|
import { deviceLogin, promptText, scopeNotes } from "./login"
|
|
11
|
+
import { checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isInsecureStoredURL, isLoopbackServer, isPlainHTTP, shownURL, storedInsecureWarning } from "./bootstrap/enrollment/policy.ts"
|
|
11
12
|
import { defaultMachineName } from "./bootstrap/enrollment/enroll.ts"
|
|
12
13
|
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
13
14
|
import { hostname } from "node:os"
|
|
14
15
|
import { serveMcp } from "./mcp"
|
|
16
|
+
import { intentLine, writeIntent } from "./intents"
|
|
15
17
|
import { ASK_EXPIRES_DEFAULT_S, askOutcome, declinedText, durationArg, inboxText, mutedText, repliedText, sentText, textArg } from "./messaging"
|
|
16
18
|
import { watch } from "./watch"
|
|
17
19
|
import { DECISION_FLAGS, decisionOptions, decisionsText, openedText, stateArg } from "./decisions"
|
|
@@ -20,6 +22,7 @@ import { answeredText, approvedText, deliveryArg, deniedText, formAnswerArg, nee
|
|
|
20
22
|
import { currentProgram, installNode, nodeStatus, nodeStatusText, runNodeCommand, uninstallNode, type NodeIo, type Program } from "./node"
|
|
21
23
|
import { checkOpenShell, openShellCheckText } from "./openshell"
|
|
22
24
|
import { LAUNCH_FLAGS, launchedText, launchesText, launchSpecArg, launchText, stoppedLaunchText } from "./launches"
|
|
25
|
+
import { watchlistText, watchlistVerbText } from "./watchlist"
|
|
23
26
|
import { claudeCodeMain } from "../../claude-code/src/main.ts"
|
|
24
27
|
import { runClaude } from "../../claude-code/src/wrapper.ts"
|
|
25
28
|
import { presetMain } from "../../preset/src/main.ts"
|
|
@@ -70,8 +73,10 @@ Reading
|
|
|
70
73
|
|
|
71
74
|
Identity
|
|
72
75
|
hub whoami who this credential is
|
|
73
|
-
hub who [--project P] [--role R]
|
|
76
|
+
hub who [--project P] [--role R] [--team T] named and role-holding sessions (team members: their teams)
|
|
74
77
|
hub name <@name> [--session <addr>]
|
|
78
|
+
hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters, untrusted);
|
|
79
|
+
no text: show it (else its title); a session token sets only its own
|
|
75
80
|
hub role claim <role[.project]> [--exclusive] [--session <addr>]
|
|
76
81
|
hub role release <role[.project]> [--session <addr>]
|
|
77
82
|
|
|
@@ -121,10 +126,13 @@ Launching (users; agents and app tokens with sessions:launch under a grant with
|
|
|
121
126
|
hub launches show <launchID> · hub launches stop <launchID> (stop: exit 8 when it already ended)
|
|
122
127
|
|
|
123
128
|
Machine (this machine's one identity; no harness needed)
|
|
124
|
-
hub machine enroll --server <hub URL> [--name N] [--reenroll] [--no-verify]
|
|
125
|
-
device-code enrollment: approve the code on the hub (Ctrl-C cancels)
|
|
129
|
+
hub machine enroll --server <hub URL> [--name N] [--reenroll] [--no-verify] [--allow-insecure-http]
|
|
130
|
+
device-code enrollment: approve the code on the hub (Ctrl-C cancels);
|
|
131
|
+
--allow-insecure-http: plain http to a private-network IP address (status
|
|
132
|
+
insecure: the credential is never checked over it; not stored as a default)
|
|
126
133
|
hub machine status [--no-verify] unconfigured, configured, verified, revoked (exit 9), offline (exit 7)
|
|
127
|
-
or insecure (a plain-http hub:
|
|
134
|
+
or insecure (a plain-http hub: status does not check it; the spoke and
|
|
135
|
+
hub node still send the machine token to it, unencrypted)
|
|
128
136
|
|
|
129
137
|
Setup (discover → preview → explicit apply; docs/bootstrap-contracts.md)
|
|
130
138
|
hub setup discover what this host has (read-only)
|
|
@@ -178,23 +186,32 @@ Codex (app-server spoke)
|
|
|
178
186
|
hub codex-spoke install|uninstall [--dry-run] | status
|
|
179
187
|
the spoke service as a launchd agent / systemd user unit (printed, never loaded)
|
|
180
188
|
|
|
189
|
+
Concierge watchlist (users; app tokens: sessions:read to show, sessions:manage to change)
|
|
190
|
+
hub watchlist [show] what the Concierge watches now, and why; its config and revision
|
|
191
|
+
hub watchlist watch|unwatch <addr> watch a session (a subagent: its root), or stop
|
|
192
|
+
hub watchlist mute|unmute <addr> never watch it (not a notification mute), or undo
|
|
193
|
+
|
|
181
194
|
MCP
|
|
182
195
|
hub mcp an MCP server on stdio: these verbs as tools, with this CLI's credential
|
|
196
|
+
plus workspaces, artifacts and teams (teams, team_show, team_status,
|
|
197
|
+
team_create, team_publish, team_staff, team_submit, …: docs/teams.md)
|
|
183
198
|
|
|
184
199
|
Login (outside a hub session; a machine enrollment is not a user login)
|
|
185
|
-
hub login [--url URL] [--scope S]... [--name N]
|
|
200
|
+
hub login [--url URL] [--scope S]... [--name N] [--allow-insecure-http]
|
|
186
201
|
device code: approve it in the console as yourself and choose the scopes
|
|
187
202
|
(default sessions:read, messages:send, messages:read; launching needs
|
|
188
203
|
--scope sessions:launch, or tick it on the page, plus a grant with verb launch);
|
|
189
|
-
the URL defaults to HUB_URL, the last login, else this machine's enrollment
|
|
204
|
+
the URL defaults to HUB_URL, the last login, else this machine's enrollment;
|
|
205
|
+
https, or plain http on loopback (--allow-insecure-http: also a private-network
|
|
206
|
+
IP address such as a LAN preview, unencrypted, with a warning; never stored)
|
|
190
207
|
hub login [--url URL] --token APP_TOKEN|- store an app token (- or a pipe: read from stdin)
|
|
191
208
|
hub logout
|
|
192
209
|
|
|
193
210
|
Options: --json (raw responses), --help, --version
|
|
194
|
-
Addresses: ses_… (or an unambiguous prefix), @name, @@role, @name.project, @me (the user)
|
|
211
|
+
Addresses: ses_… (or an unambiguous prefix), @name, @@role, @@role#team (quote it: '@@dev#team'), @name.project, @me (the user)
|
|
195
212
|
Credentials: HUB_URL + HUB_SESSION_TOKEN (set in a session's shells), else the \`hub login\` file.
|
|
196
213
|
Exit codes: 0 ok, 1 other, 2 usage, 3 not found/ambiguous/taken, 4 needs user, 5 forbidden/paused,
|
|
197
|
-
6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired`
|
|
214
|
+
6 rate limited, 7 unsupported/offline/busy, 8 gone, 9 unauthorized/expired`
|
|
198
215
|
|
|
199
216
|
interface Context {
|
|
200
217
|
readonly io: Io
|
|
@@ -326,12 +343,12 @@ const COMMANDS: Record<string, Command> = {
|
|
|
326
343
|
},
|
|
327
344
|
who: {
|
|
328
345
|
args: ["@@role?"],
|
|
329
|
-
flags: { project: "string", role: "string" },
|
|
346
|
+
flags: { project: "string", role: "string", team: "string" },
|
|
330
347
|
async handle(context) {
|
|
331
348
|
const { flags, args } = context.parsed
|
|
332
349
|
const role = str(flags, "role") ?? args[0]
|
|
333
|
-
const body = await context.hub((hub) => hub.who({ project: str(flags, "project"), role }))
|
|
334
|
-
answer(context, body, (b) => whoText(b.sessions ?? []))
|
|
350
|
+
const body = await context.hub((hub) => hub.who({ project: str(flags, "project"), role, team: str(flags, "team") }))
|
|
351
|
+
answer(context, body, (b) => whoText(b.sessions ?? [], b))
|
|
335
352
|
},
|
|
336
353
|
},
|
|
337
354
|
name: {
|
|
@@ -343,6 +360,18 @@ const COMMANDS: Record<string, Command> = {
|
|
|
343
360
|
answer(context, body, (b) => `${b.sessionID} is ${atName(b.name)}${b.alias ? ` (was ${atName(b.alias)}, kept as an alias for 24 h)` : ""}`)
|
|
344
361
|
},
|
|
345
362
|
},
|
|
363
|
+
intent: {
|
|
364
|
+
args: ["<addr>", "text..."],
|
|
365
|
+
flags: { clear: "bool" },
|
|
366
|
+
async handle(context) {
|
|
367
|
+
const [addr, ...words] = context.parsed.args
|
|
368
|
+
const clear = context.parsed.flags.clear === true
|
|
369
|
+
if (clear && words.length) throw new UsageError("Give a text or --clear, not both")
|
|
370
|
+
const text = words.join(" ")
|
|
371
|
+
const body = await context.hub(async (hub) => (clear || text ? { intent: await writeIntent(hub, addr!, clear ? null : text) } : hub.intent(addr!)))
|
|
372
|
+
answer(context, body, (b) => intentLine(addr!, b.intent))
|
|
373
|
+
},
|
|
374
|
+
},
|
|
346
375
|
"role claim": {
|
|
347
376
|
args: ["<role>"],
|
|
348
377
|
flags: { exclusive: "bool", session: "string" },
|
|
@@ -651,7 +680,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
651
680
|
},
|
|
652
681
|
},
|
|
653
682
|
login: {
|
|
654
|
-
flags: { url: "string", token: "string", scope: "multi", name: "string" },
|
|
683
|
+
flags: { url: "string", token: "string", scope: "multi", name: "string", "allow-insecure-http": "bool" },
|
|
655
684
|
async handle(context) {
|
|
656
685
|
const { io, parsed } = context
|
|
657
686
|
const file = loginFile(io.env, io.home)
|
|
@@ -672,6 +701,15 @@ const COMMANDS: Record<string, Command> = {
|
|
|
672
701
|
let scopes: readonly string[] | undefined
|
|
673
702
|
if (given !== undefined || piped) {
|
|
674
703
|
if (parsed.flags.scope !== undefined) throw new UsageError("--scope is for the device code (without --token); an app token's scopes are set when it is created")
|
|
704
|
+
// The device code's URL policy (decision m5bzlmfq): https, plain http on loopback, or with
|
|
705
|
+
// --allow-insecure-http a private-network IP address (warned); checked before the token is read or sent.
|
|
706
|
+
const allow = { allowInsecureHTTP: parsed.flags["allow-insecure-http"] === true }
|
|
707
|
+
const server = checkServerURL(url, allow)
|
|
708
|
+
if (!server) {
|
|
709
|
+
const why = insecureHTTPRefusal(url, allow)
|
|
710
|
+
throw new UsageError(why ? `${why}: ${shownURL(url)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${shownURL(url)}`)
|
|
711
|
+
}
|
|
712
|
+
if (isPlainHTTP(server) && !isLoopbackServer(server)) io.stderr(`hub: warning: ${insecureHTTPWarning(server)}\n`)
|
|
675
713
|
token = (given !== undefined && given !== "-" ? given : io.stdin ? await io.stdin() : "").trim()
|
|
676
714
|
if (!token) throw new UsageError("needs an app token: --token, or on stdin (create one in the console: Settings → Agents), or run `hub login` in a terminal for a device code")
|
|
677
715
|
} else {
|
|
@@ -680,6 +718,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
680
718
|
server: url,
|
|
681
719
|
clientName: str(parsed.flags, "name") ?? io.login?.clientName ?? defaultMachineName(hostname()),
|
|
682
720
|
scopes: requested,
|
|
721
|
+
...(parsed.flags["allow-insecure-http"] === true ? { allowInsecureHTTP: true } : {}),
|
|
683
722
|
...(io.signal ? { signal: io.signal } : {}),
|
|
684
723
|
...(io.login?.fetch ? { fetch: io.login.fetch } : {}),
|
|
685
724
|
...(io.login?.intervalMs !== undefined ? { intervalMs: io.login.intervalMs } : {}),
|
|
@@ -689,12 +728,46 @@ const COMMANDS: Record<string, Command> = {
|
|
|
689
728
|
scopes = login.scopes
|
|
690
729
|
notes = scopeNotes(login.scopes, login.tokenID)
|
|
691
730
|
}
|
|
692
|
-
const credentials: Credentials = { url, token, source: "login" }
|
|
731
|
+
const credentials: Credentials = { url, token, source: "login", ...(io.login?.fetch ? { fetch: io.login.fetch } : {}) }
|
|
693
732
|
const body = await withHub(credentials, (hub) => hub.whoami())
|
|
694
733
|
writeLogin(file, { url, token })
|
|
695
734
|
answer(context, body, (b) => [`logged in to ${url}`, whoamiText(b), ...(scopes ? [`scopes: ${scopes.join(", ")}`] : []), `saved in ${file}`, ...notes].join("\n"))
|
|
696
735
|
},
|
|
697
736
|
},
|
|
737
|
+
"watchlist show": {
|
|
738
|
+
async handle(context) {
|
|
739
|
+
const body = await context.hub((hub) => hub.concierge.watchlist.get())
|
|
740
|
+
answer(context, body, watchlistText)
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
"watchlist watch": {
|
|
744
|
+
args: ["<addr>"],
|
|
745
|
+
async handle(context) {
|
|
746
|
+
const body = await context.hub((hub) => hub.concierge.watchlist.watch(context.parsed.args[0]!))
|
|
747
|
+
answer(context, body, (b) => watchlistVerbText("watch", b))
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
"watchlist unwatch": {
|
|
751
|
+
args: ["<addr>"],
|
|
752
|
+
async handle(context) {
|
|
753
|
+
const body = await context.hub((hub) => hub.concierge.watchlist.unwatch(context.parsed.args[0]!))
|
|
754
|
+
answer(context, body, (b) => watchlistVerbText("unwatch", b))
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
"watchlist mute": {
|
|
758
|
+
args: ["<addr>"],
|
|
759
|
+
async handle(context) {
|
|
760
|
+
const body = await context.hub((hub) => hub.concierge.watchlist.mute(context.parsed.args[0]!))
|
|
761
|
+
answer(context, body, (b) => watchlistVerbText("mute", b))
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
"watchlist unmute": {
|
|
765
|
+
args: ["<addr>"],
|
|
766
|
+
async handle(context) {
|
|
767
|
+
const body = await context.hub((hub) => hub.concierge.watchlist.unmute(context.parsed.args[0]!))
|
|
768
|
+
answer(context, body, (b) => watchlistVerbText("unmute", b))
|
|
769
|
+
},
|
|
770
|
+
},
|
|
698
771
|
logout: {
|
|
699
772
|
async handle(context) {
|
|
700
773
|
const file = loginFile(context.io.env, context.io.home)
|
|
@@ -723,7 +796,7 @@ for (const label of Object.keys(BOOTSTRAP_FLAGS))
|
|
|
723
796
|
}
|
|
724
797
|
|
|
725
798
|
/** Commands with sub-verbs (`hub role claim`). */
|
|
726
|
-
const GROUPS: Record<string, string[]> = { machine: [...MACHINE_VERBS], setup: [...SETUP_VERBS], "claude-code": [...SERVICE_VERBS], "codex-spoke": [...SERVICE_VERBS], role: ["claim", "release"], decisions: ["list"], grant: ["list", "show", "create", "revoke", "request"], launches: ["list", "show", "stop"], node: ["run", "install", "uninstall", "status", "openshell"], "node openshell": ["check"] }
|
|
799
|
+
const GROUPS: Record<string, string[]> = { machine: [...MACHINE_VERBS], setup: [...SETUP_VERBS], "claude-code": [...SERVICE_VERBS], "codex-spoke": [...SERVICE_VERBS], role: ["claim", "release"], decisions: ["list"], grant: ["list", "show", "create", "revoke", "request"], launches: ["list", "show", "stop"], watchlist: ["show", "watch", "unwatch", "mute", "unmute"], node: ["run", "install", "uninstall", "status", "openshell"], "node openshell": ["check"] }
|
|
727
800
|
|
|
728
801
|
const groupVerbs = (label: string): string[] | undefined => (Object.hasOwn(GROUPS, label) ? GROUPS[label] : undefined)
|
|
729
802
|
|
|
@@ -766,6 +839,8 @@ export async function run(argv: string[], io: Io): Promise<number> {
|
|
|
766
839
|
// `hub codex` passes every other argument to Codex; `hub codex-spoke serve` is the spoke service it starts.
|
|
767
840
|
if (name === "codex") return runCodex(rest, { env: io.env, home: io.home, stderr: io.stderr, program: io.program ?? currentProgram() })
|
|
768
841
|
if (name === "codex-spoke" && !isServiceVerb(rest[0])) return codexMain(rest, io)
|
|
842
|
+
// `hub watchlist` alone (or with only flags) shows it.
|
|
843
|
+
if (name === "watchlist" && (rest[0] === undefined || rest[0].startsWith("-"))) rest.unshift("show")
|
|
769
844
|
let label = name
|
|
770
845
|
// Groups nest (`hub node openshell check`): each level picks one of its sub-verbs.
|
|
771
846
|
for (let verbs = groupVerbs(label); verbs; verbs = groupVerbs(label)) {
|
|
@@ -793,9 +868,16 @@ export async function run(argv: string[], io: Io): Promise<number> {
|
|
|
793
868
|
return EXIT.ok
|
|
794
869
|
}
|
|
795
870
|
json = parsed.flags.json === true
|
|
871
|
+
let warned = false
|
|
796
872
|
const credentials = () => {
|
|
797
873
|
const found = resolveCredentials(io.env, io.home)
|
|
798
874
|
if (!found) throw notLoggedIn(io, label)
|
|
875
|
+
// A stored login on a plain-http, non-loopback hub (--allow-insecure-http, or an older login): every command
|
|
876
|
+
// says so, once, on stderr (never in --json output).
|
|
877
|
+
if (found.source === "login" && !warned && isInsecureStoredURL(found.url)) {
|
|
878
|
+
warned = true
|
|
879
|
+
io.stderr(`hub: warning: ${storedInsecureWarning(found.url)}\n`)
|
|
880
|
+
}
|
|
799
881
|
return found
|
|
800
882
|
}
|
|
801
883
|
return (await command.handle({ io, parsed, json, credentials, hub: (use) => withHub(credentials(), use) })) ?? EXIT.ok
|
|
@@ -136,6 +136,11 @@ export function logText(rows: readonly Row[]): string {
|
|
|
136
136
|
.join("\n")
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
interface WhoReach {
|
|
140
|
+
readonly send: readonly string[]
|
|
141
|
+
readonly receive: readonly string[]
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
interface WhoEntry {
|
|
140
145
|
readonly sessionID: string
|
|
141
146
|
readonly name?: string
|
|
@@ -144,19 +149,52 @@ interface WhoEntry {
|
|
|
144
149
|
readonly state?: string
|
|
145
150
|
readonly online: boolean
|
|
146
151
|
readonly roles: readonly { role: string; project?: string }[]
|
|
152
|
+
/** HWA-40 (additive). */
|
|
153
|
+
readonly teams?: readonly { team: string; roles: readonly string[]; reach?: WhoReach }[]
|
|
154
|
+
readonly reach?: WhoReach
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** HWA-40: the team groups of a who answer. */
|
|
158
|
+
interface WhoGroups {
|
|
159
|
+
readonly teams?: readonly { name: string; mode: string; paused?: boolean; yourRoles?: readonly string[]; members: readonly { sessionID: string }[] }[]
|
|
160
|
+
readonly scope?: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const reachText = (r: WhoReach | undefined): string | undefined =>
|
|
164
|
+
!r ? undefined : r.send.length === 2 && r.receive.length === 2 ? "two-way" : `${r.send.length ? `send ${r.send.join("/")}` : "unreachable"}, receive ${r.receive.length ? r.receive.join("/") : "none"}`
|
|
165
|
+
|
|
166
|
+
function whoLine(e: WhoEntry, roles: string[], reach: WhoReach | undefined): string {
|
|
167
|
+
const parts = [[e.name ? at(e.name) : undefined, e.sessionID].filter(Boolean).join(" "), e.online ? (e.state ?? "online") : "offline"]
|
|
168
|
+
if (roles.length) parts.push(roles.join(" "))
|
|
169
|
+
const r = reachText(reach)
|
|
170
|
+
if (r) parts.push(r)
|
|
171
|
+
if (e.project) parts.push(e.project)
|
|
172
|
+
if (e.title) parts.push(oneLine(e.title, 60))
|
|
173
|
+
return parts.join(" ")
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
|
|
176
|
+
/** One line per session; with team groups (HWA-40) a section per team (roles there, your reach), then the rest. */
|
|
177
|
+
export function whoText(entries: readonly WhoEntry[], groups: WhoGroups = {}): string {
|
|
150
178
|
if (!entries.length) return "no sessions"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
const globalRoles = (e: WhoEntry) => e.roles.map((r) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`)
|
|
180
|
+
if (!groups.teams?.length) return entries.map((e) => whoLine(e, globalRoles(e), e.reach)).join("\n")
|
|
181
|
+
const byID = new Map(entries.map((e) => [e.sessionID, e]))
|
|
182
|
+
const grouped = new Set<string>()
|
|
183
|
+
const lines: string[] = []
|
|
184
|
+
for (const t of groups.teams) {
|
|
185
|
+
lines.push(`#${t.name} (${t.mode}${t.paused ? ", paused" : ""})${t.yourRoles?.length ? ` you: ${t.yourRoles.map((r) => `@@${r}`).join(" ")}` : ""}`)
|
|
186
|
+
for (const id of new Set(t.members.map((m) => m.sessionID))) {
|
|
187
|
+
const e = byID.get(id)
|
|
188
|
+
if (!e) continue
|
|
189
|
+
grouped.add(id)
|
|
190
|
+
const ref = e.teams?.find((x) => x.team === t.name)
|
|
191
|
+
lines.push(` ${whoLine(e, (ref?.roles ?? []).map((r) => `@@${r}`), ref?.reach)}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const rest = entries.filter((e) => !grouped.has(e.sessionID))
|
|
195
|
+
if (rest.length) lines.push(groups.scope === "team" ? "outside your teams" : "in no team", ...rest.map((e) => ` ${whoLine(e, globalRoles(e), e.reach)}`))
|
|
196
|
+
if (groups.scope === "team") lines.push("@me the user always reachable")
|
|
197
|
+
return lines.join("\n")
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
/** A caller as whoami / status `you` report it. */
|
package/packages/cli/src/http.ts
CHANGED
|
@@ -24,12 +24,14 @@ const EXIT_OF: Record<string, number> = {
|
|
|
24
24
|
not_found: EXIT.notFound,
|
|
25
25
|
ambiguous: EXIT.notFound,
|
|
26
26
|
name_taken: EXIT.notFound,
|
|
27
|
+
conflict: EXIT.notFound,
|
|
27
28
|
needs_user: EXIT.needsUser,
|
|
28
29
|
forbidden: EXIT.forbidden,
|
|
29
30
|
paused: EXIT.forbidden,
|
|
30
31
|
rate_limited: EXIT.rateLimited,
|
|
31
32
|
unsupported: EXIT.unavailable,
|
|
32
33
|
offline: EXIT.unavailable,
|
|
34
|
+
busy: EXIT.unavailable,
|
|
33
35
|
gone: EXIT.gone,
|
|
34
36
|
unauthorized: EXIT.unauthorized,
|
|
35
37
|
token_expired: EXIT.unauthorized,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Session intents in the CLI and `hub mcp` (CON-02): `hub intent <addr> [text… | --clear]` and the MCP tools
|
|
2
|
+
// session_intent_get / session_intent_set. The intent is untrusted text from the hub: printed sanitised on one line.
|
|
3
|
+
import { sanitizeLabel, type HubClient, type SessionIntent } from "../../sdk/src/index.ts"
|
|
4
|
+
|
|
5
|
+
/** INTENT_MAX of the hub (packages/protocol). */
|
|
6
|
+
const INTENT_MAX = 280
|
|
7
|
+
|
|
8
|
+
/** `ses_1 intent (user, r2): Ship it`, or that it has none. */
|
|
9
|
+
export function intentLine(addr: string, intent: SessionIntent | null): string {
|
|
10
|
+
if (!intent) return `${addr} has no intent (and no title to fall back on)`
|
|
11
|
+
const by = intent.by ? `, by ${sanitizeLabel(intent.by)}` : ""
|
|
12
|
+
return `${intent.sessionID} intent (${intent.source}${by}, r${intent.revision}): ${sanitizeLabel(intent.text, INTENT_MAX)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sets (text) or clears (null) the caller's intent layer. Without `expectedRevision` the current revision is read
|
|
17
|
+
* first (a concurrent change between the two still answers conflict).
|
|
18
|
+
*/
|
|
19
|
+
export async function writeIntent(hub: HubClient, addr: string, text: string | null, options: { readonly expectedRevision?: number; readonly signal?: AbortSignal } = {}): Promise<SessionIntent | null> {
|
|
20
|
+
const signal = options.signal ? { signal: options.signal } : {}
|
|
21
|
+
const revision = options.expectedRevision ?? (await hub.intent(addr, signal)).intent?.revision ?? 0
|
|
22
|
+
return (await hub.setIntent(addr, text, revision, signal)).intent
|
|
23
|
+
}
|
|
@@ -121,10 +121,15 @@ export function launchedText(launch: LaunchView, created: boolean, key: string |
|
|
|
121
121
|
|
|
122
122
|
export const stoppedLaunchText = (launch: LaunchView): string => `stopped ${summary(launch)}`
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/** The app token that launched it: `app "label"` or `app <token id>`. */
|
|
125
|
+
const launcherText = (l: LaunchView) => (l.byLabel ? `app ${JSON.stringify(l.byLabel)}` : `app ${l.by?.replace(/^app:/, "") ?? "?"}`)
|
|
126
|
+
|
|
127
|
+
/** `lch_1 running @api ses_1 /repo (branch) app=brain`; a queued one says why it waits, a revoked launcher is flagged. */
|
|
125
128
|
export function launchLine(l: LaunchView): string {
|
|
126
129
|
const where = l.directory ?? `${l.spec.workspace.repo}${l.spec.workspace.branch ? ` (${l.spec.workspace.branch})` : ""}`
|
|
127
|
-
|
|
130
|
+
const waits = l.state === "queued" && l.waiting ? `waits: ${l.waiting.reason}` : undefined
|
|
131
|
+
const revoked = l.launcherRevoked && l.state !== "ended" ? `launcher revoked (${launcherText(l)}): stop it with hub launches stop ${l.id}` : undefined
|
|
132
|
+
return [l.id, stateText(l), sessionText(l) || undefined, where, labelsText(l.spec.labels) || undefined, waits, revoked].filter(Boolean).join(" ")
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
export const launchesText = (launches: readonly LaunchView[]): string => (launches.length ? launches.map(launchLine).join("\n") : "no launches")
|
|
@@ -149,7 +154,8 @@ export function launchText(l: LaunchView): string {
|
|
|
149
154
|
const run = [l.spec.harness, l.spec.agent, l.spec.model, l.spec.runtime ? `${l.spec.runtime.kind}${l.spec.runtime.profile ? `/${l.spec.runtime.profile}` : ""}` : undefined].filter(Boolean)
|
|
150
155
|
if (run.length) lines.push(`runs: ${run.join(" ")}`)
|
|
151
156
|
if (l.spec.end) lines.push(`ends: ${[l.spec.end.when, l.spec.end.timeout ? `timeout ${l.spec.end.timeout}` : undefined, l.spec.end.cleanup ? `cleanup ${l.spec.end.cleanup}` : undefined].filter(Boolean).join(", ")}`)
|
|
152
|
-
if (l.
|
|
157
|
+
if (l.state === "queued" && l.waiting) lines.push(`waits (${l.waiting.wait}) since ${new Date(l.waiting.since).toISOString()}: ${l.waiting.reason}`)
|
|
158
|
+
if (l.by) lines.push(`by: ${l.by}${l.byLabel ? ` (${l.byLabel})` : ""}${l.launcherRevoked ? ", launcher revoked" : ""}`)
|
|
153
159
|
if (labelsText(l.spec.labels)) lines.push(`labels: ${labelsText(l.spec.labels)}`)
|
|
154
160
|
if (l.artifacts && artifactsText(l.artifacts)) lines.push(`artifacts: ${artifactsText(l.artifacts)}`)
|
|
155
161
|
if (l.history?.length) lines.push("steps:", ...l.history.map(stepText))
|