@huynle/hub 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. 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 may be sent to, and which approval
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); no user info, query, fragment or whitespace. Matches the
10
- * contract's hubURL check, and what src/config.ts normalize() keeps.
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
- if (!(url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK.has(url.hostname)))) return undefined
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,72 @@ 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. The hub's own link is used only when it is on the hub's origin (scheme, host and port)
41
- * and passes the URL policy: anything else could send the user (and their hub login) to a phishing page. Otherwise
42
- * the link is rebuilt on the hub URL, with a warning.
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 allowed = checkServerURL(`${link.origin}${link.pathname}`) !== undefined && !link.username && !link.password
51
- if (allowed && link.origin === base.origin) return { url: link.href }
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
+ }
@@ -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 never sent there (a new enrollment to it stays refused).
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 is never sent over plain http); enroll again with an https hub URL: hubctl machine enroll --server https://<hub> --reenroll`,
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)) {
@@ -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, 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] named and role-holding sessions
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: reported, never sent the credential)
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,20 +186,29 @@ 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
214
  6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired`
@@ -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}: ${url.slice(0, 200)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${url.slice(0, 200)}`)
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
- export function whoText(entries: readonly WhoEntry[]): string {
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
- return entries
152
- .map((e) => {
153
- const parts = [[e.name ? at(e.name) : undefined, e.sessionID].filter(Boolean).join(" "), e.online ? (e.state ?? "online") : "offline"]
154
- if (e.roles.length) parts.push(e.roles.map((r) => `@@${r.role}${r.project !== undefined ? `.${r.project}` : ""}`).join(" "))
155
- if (e.project) parts.push(e.project)
156
- if (e.title) parts.push(oneLine(e.title, 60))
157
- return parts.join(" ")
158
- })
159
- .join("\n")
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. */
@@ -24,6 +24,7 @@ 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,
@@ -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
+ }
@@ -4,12 +4,13 @@
4
4
  // out an app token of that user, once.
5
5
  //
6
6
  // Policy (the machine enrollment's, packages/cli/src/bootstrap/enrollment/policy.ts):
7
- // - The hub URL is https (plain http only for a loopback hub); redirects are refused, never followed.
8
- // - The approval link shown is on the hub's own origin (approvalURL).
7
+ // - The hub URL is https (plain http only for a loopback hub, or with allowInsecureHTTP a private-network IP address,
8
+ // with a warning each time); redirects are refused, never followed.
9
+ // - The approval link shown is on the hub's own origin, or a loopback hub's public URL (approvalURL).
9
10
  // - The wait is bounded by the hub's expiry and by maxWaitMs; `signal` cancels it.
10
11
  // - The token is returned to the caller only, never printed. A poll that fails in transit is retried until the
11
12
  // deadline (a reply lost after the hub handed the token out cannot be fetched again: the user revokes that token).
12
- import { approvalURL, checkServerURL, isUserCode } from "./bootstrap/enrollment/policy.ts"
13
+ import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode } from "./bootstrap/enrollment/policy.ts"
13
14
  import { UsageError } from "./args"
14
15
  import { CliError, EXIT } from "./http"
15
16
 
@@ -26,6 +27,10 @@ export interface LoginPrompt {
26
27
  /** The scopes the approval page offers ticked. */
27
28
  readonly scopes: readonly string[]
28
29
  readonly warning?: string
30
+ /** A loopback hub's own link on this machine, when approveURL is its public URL. */
31
+ readonly localURL?: string
32
+ /** Plain http to a private-network hub (--allow-insecure-http): said every time. */
33
+ readonly insecure?: string
29
34
  }
30
35
 
31
36
  export interface DeviceLoginOptions {
@@ -34,6 +39,8 @@ export interface DeviceLoginOptions {
34
39
  readonly clientName: string
35
40
  /** Asked for beyond the hub's defaults (sessions:read, messages:send, messages:read). */
36
41
  readonly scopes?: readonly string[]
42
+ /** --allow-insecure-http: also plain http to a private-network IP address (with a warning). Never stored. */
43
+ readonly allowInsecureHTTP?: boolean
37
44
  readonly fetch?: typeof fetch
38
45
  readonly signal?: AbortSignal
39
46
  readonly now?: () => number
@@ -90,8 +97,13 @@ const hubMessage = (body: unknown): string | undefined => (isRecord(body) && isR
90
97
 
91
98
  /** Runs the device-code login: the approved app token, or a CliError (nothing to store). */
92
99
  export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLogin> {
93
- const server = checkServerURL(options.server)
94
- if (!server) throw new UsageError(`the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${String(options.server).slice(0, 200)}`)
100
+ const allow = { allowInsecureHTTP: options.allowInsecureHTTP === true }
101
+ const server = checkServerURL(options.server, allow)
102
+ if (!server) {
103
+ const why = insecureHTTPRefusal(options.server, allow)
104
+ throw new UsageError(why ? `${why}: ${String(options.server).slice(0, 200)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${String(options.server).slice(0, 200)}`)
105
+ }
106
+ const insecure = isPlainHTTP(server) && !isLoopbackServer(server) ? insecureHTTPWarning(server) : undefined
95
107
  const fetcher = options.fetch ?? fetch
96
108
  const now = options.now ?? Date.now
97
109
  const { signal } = options
@@ -106,7 +118,7 @@ export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLo
106
118
  }
107
119
  const link = approvalURL(server, start.approveURL, start.userCode)
108
120
  const expiresAt = Math.min(start.expiresAt as number, now() + (options.maxWaitMs ?? LOGIN_MAX_WAIT_MS))
109
- options.onPrompt?.({ userCode: start.userCode, approveURL: link.url, expiresAt, scopes: stringList(start.scopes) ?? [], ...(link.warning ? { warning: link.warning } : {}) })
121
+ options.onPrompt?.({ userCode: start.userCode, approveURL: link.url, expiresAt, scopes: stringList(start.scopes) ?? [], ...(link.warning ? { warning: link.warning } : {}), ...(link.localURL ? { localURL: link.localURL } : {}), ...(insecure ? { insecure } : {}) })
110
122
 
111
123
  const interval = options.intervalMs ?? Math.min(Math.max(start.intervalMs as number, MIN_INTERVAL_MS), MAX_INTERVAL_MS)
112
124
  for (;;) {
@@ -140,8 +152,10 @@ export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLo
140
152
  export function promptText(prompt: LoginPrompt): string {
141
153
  const minutes = Math.max(1, Math.round((prompt.expiresAt - Date.now()) / 60_000))
142
154
  return [
155
+ ...(prompt.insecure ? [`hub: warning: ${prompt.insecure}`] : []),
143
156
  ...(prompt.warning ? [`hub: warning: ${prompt.warning}`] : []),
144
157
  `To log in, open ${prompt.approveURL}`,
158
+ ...(prompt.localURL ? [`(on this machine: ${prompt.localURL})`] : []),
145
159
  `and approve code ${prompt.userCode} as yourself (expires in ${minutes} min; Ctrl-C cancels).`,
146
160
  ...(prompt.scopes.length ? [`Requested scopes: ${prompt.scopes.join(", ")} (you choose on the page).`] : []),
147
161
  "Waiting for approval…",