@intentic/sandbox-contract 1.307.0 → 1.308.2

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 (260) hide show
  1. package/README.md +12 -0
  2. package/dist/contracts/accounts.contract.d.ts +6 -6
  3. package/dist/contracts/accounts.contract.js +7 -7
  4. package/dist/contracts/accounts.contract.js.map +1 -1
  5. package/dist/contracts/activity.contract.d.ts +2 -2
  6. package/dist/contracts/activity.contract.js +3 -3
  7. package/dist/contracts/activity.contract.js.map +1 -1
  8. package/dist/contracts/agent.contract.d.ts +10 -10
  9. package/dist/contracts/agent.contract.d.ts.map +1 -1
  10. package/dist/contracts/agent.contract.js +21 -11
  11. package/dist/contracts/agent.contract.js.map +1 -1
  12. package/dist/contracts/agents.contract.d.ts +41 -27
  13. package/dist/contracts/agents.contract.d.ts.map +1 -1
  14. package/dist/contracts/agents.contract.js +44 -28
  15. package/dist/contracts/agents.contract.js.map +1 -1
  16. package/dist/contracts/approvals.contract.d.ts +3 -3
  17. package/dist/contracts/approvals.contract.js +4 -4
  18. package/dist/contracts/approvals.contract.js.map +1 -1
  19. package/dist/contracts/areas.contract.d.ts +3 -3
  20. package/dist/contracts/areas.contract.d.ts.map +1 -1
  21. package/dist/contracts/areas.contract.js +5 -4
  22. package/dist/contracts/areas.contract.js.map +1 -1
  23. package/dist/contracts/automations.contract.d.ts +11 -11
  24. package/dist/contracts/automations.contract.js +12 -12
  25. package/dist/contracts/automations.contract.js.map +1 -1
  26. package/dist/contracts/capabilities.contract.d.ts +13 -13
  27. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  28. package/dist/contracts/capabilities.contract.js +18 -14
  29. package/dist/contracts/capabilities.contract.js.map +1 -1
  30. package/dist/contracts/chores.contract.d.ts +3 -3
  31. package/dist/contracts/chores.contract.js +4 -4
  32. package/dist/contracts/chores.contract.js.map +1 -1
  33. package/dist/contracts/ci.contract.d.ts +5 -5
  34. package/dist/contracts/ci.contract.js +6 -6
  35. package/dist/contracts/ci.contract.js.map +1 -1
  36. package/dist/contracts/diff.contract.d.ts +1 -1
  37. package/dist/contracts/diff.contract.js +2 -2
  38. package/dist/contracts/diff.contract.js.map +1 -1
  39. package/dist/contracts/endpoints.contract.d.ts +4 -4
  40. package/dist/contracts/endpoints.contract.js +5 -5
  41. package/dist/contracts/endpoints.contract.js.map +1 -1
  42. package/dist/contracts/exit.contract.d.ts +7 -7
  43. package/dist/contracts/exit.contract.d.ts.map +1 -1
  44. package/dist/contracts/exit.contract.js +9 -8
  45. package/dist/contracts/exit.contract.js.map +1 -1
  46. package/dist/contracts/extensions.contract.d.ts +17 -17
  47. package/dist/contracts/extensions.contract.js +18 -18
  48. package/dist/contracts/extensions.contract.js.map +1 -1
  49. package/dist/contracts/git.contract.d.ts +47 -45
  50. package/dist/contracts/git.contract.d.ts.map +1 -1
  51. package/dist/contracts/git.contract.js +46 -46
  52. package/dist/contracts/git.contract.js.map +1 -1
  53. package/dist/contracts/history.contract.d.ts +4 -4
  54. package/dist/contracts/history.contract.js +5 -5
  55. package/dist/contracts/history.contract.js.map +1 -1
  56. package/dist/contracts/intentic.contract.d.ts +3 -3
  57. package/dist/contracts/intentic.contract.d.ts.map +1 -1
  58. package/dist/contracts/intentic.contract.js +5 -4
  59. package/dist/contracts/intentic.contract.js.map +1 -1
  60. package/dist/contracts/inventory.contract.d.ts +3 -3
  61. package/dist/contracts/inventory.contract.js +4 -4
  62. package/dist/contracts/inventory.contract.js.map +1 -1
  63. package/dist/contracts/issues.contract.d.ts +5 -5
  64. package/dist/contracts/issues.contract.js +6 -6
  65. package/dist/contracts/issues.contract.js.map +1 -1
  66. package/dist/contracts/logs.contract.d.ts +3 -3
  67. package/dist/contracts/logs.contract.d.ts.map +1 -1
  68. package/dist/contracts/logs.contract.js +6 -4
  69. package/dist/contracts/logs.contract.js.map +1 -1
  70. package/dist/contracts/loops.contract.d.ts +6 -6
  71. package/dist/contracts/loops.contract.js +7 -7
  72. package/dist/contracts/loops.contract.js.map +1 -1
  73. package/dist/contracts/netdisk.contract.d.ts +3 -3
  74. package/dist/contracts/netdisk.contract.d.ts.map +1 -1
  75. package/dist/contracts/netdisk.contract.js +5 -4
  76. package/dist/contracts/netdisk.contract.js.map +1 -1
  77. package/dist/contracts/panels.contract.d.ts +3 -3
  78. package/dist/contracts/panels.contract.js +4 -4
  79. package/dist/contracts/panels.contract.js.map +1 -1
  80. package/dist/contracts/personas.contract.d.ts +8 -8
  81. package/dist/contracts/personas.contract.d.ts.map +1 -1
  82. package/dist/contracts/personas.contract.js +10 -9
  83. package/dist/contracts/personas.contract.js.map +1 -1
  84. package/dist/contracts/ports.contract.d.ts +3 -3
  85. package/dist/contracts/ports.contract.d.ts.map +1 -1
  86. package/dist/contracts/ports.contract.js +5 -4
  87. package/dist/contracts/ports.contract.js.map +1 -1
  88. package/dist/contracts/prepush.contract.d.ts +3 -3
  89. package/dist/contracts/prepush.contract.js +4 -4
  90. package/dist/contracts/prepush.contract.js.map +1 -1
  91. package/dist/contracts/providers.contract.d.ts +2 -2
  92. package/dist/contracts/providers.contract.d.ts.map +1 -1
  93. package/dist/contracts/providers.contract.js +5 -3
  94. package/dist/contracts/providers.contract.js.map +1 -1
  95. package/dist/contracts/public.contract.d.ts +3 -3
  96. package/dist/contracts/public.contract.js +4 -4
  97. package/dist/contracts/public.contract.js.map +1 -1
  98. package/dist/contracts/push.contract.d.ts +4 -4
  99. package/dist/contracts/push.contract.d.ts.map +1 -1
  100. package/dist/contracts/push.contract.js +9 -5
  101. package/dist/contracts/push.contract.js.map +1 -1
  102. package/dist/contracts/safety.contract.d.ts +3 -3
  103. package/dist/contracts/safety.contract.js +4 -4
  104. package/dist/contracts/safety.contract.js.map +1 -1
  105. package/dist/contracts/secrets.contract.d.ts +9 -9
  106. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  107. package/dist/contracts/secrets.contract.js +13 -10
  108. package/dist/contracts/secrets.contract.js.map +1 -1
  109. package/dist/contracts/sessions.contract.d.ts +2 -2
  110. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  111. package/dist/contracts/sessions.contract.js +5 -3
  112. package/dist/contracts/sessions.contract.js.map +1 -1
  113. package/dist/contracts/settings.contract.d.ts +11 -9
  114. package/dist/contracts/settings.contract.d.ts.map +1 -1
  115. package/dist/contracts/settings.contract.js +11 -10
  116. package/dist/contracts/settings.contract.js.map +1 -1
  117. package/dist/contracts/share.contract.d.ts +4 -4
  118. package/dist/contracts/share.contract.js +5 -5
  119. package/dist/contracts/share.contract.js.map +1 -1
  120. package/dist/contracts/skills.contract.d.ts +5 -5
  121. package/dist/contracts/skills.contract.js +6 -6
  122. package/dist/contracts/skills.contract.js.map +1 -1
  123. package/dist/contracts/system.contract.d.ts +22 -19
  124. package/dist/contracts/system.contract.d.ts.map +1 -1
  125. package/dist/contracts/system.contract.js +27 -20
  126. package/dist/contracts/system.contract.js.map +1 -1
  127. package/dist/contracts/translator.contract.d.ts +5 -5
  128. package/dist/contracts/translator.contract.js +6 -6
  129. package/dist/contracts/translator.contract.js.map +1 -1
  130. package/dist/contracts/usage.contract.d.ts +4 -4
  131. package/dist/contracts/usage.contract.js +5 -5
  132. package/dist/contracts/usage.contract.js.map +1 -1
  133. package/dist/contracts/vpn.contract.d.ts +4 -4
  134. package/dist/contracts/vpn.contract.d.ts.map +1 -1
  135. package/dist/contracts/vpn.contract.js +6 -5
  136. package/dist/contracts/vpn.contract.js.map +1 -1
  137. package/dist/contracts/workflows.contract.d.ts +9 -9
  138. package/dist/contracts/workflows.contract.js +10 -10
  139. package/dist/contracts/workflows.contract.js.map +1 -1
  140. package/dist/contracts/workspace.contract.d.ts +30 -30
  141. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  142. package/dist/contracts/workspace.contract.js +46 -31
  143. package/dist/contracts/workspace.contract.js.map +1 -1
  144. package/dist/events/land-breakage.d.ts +4 -0
  145. package/dist/events/land-breakage.d.ts.map +1 -0
  146. package/dist/events/land-breakage.js +4 -0
  147. package/dist/events/land-breakage.js.map +1 -0
  148. package/dist/events/system-events.d.ts +2 -0
  149. package/dist/events/system-events.d.ts.map +1 -1
  150. package/dist/index.d.ts +9379 -9350
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +13 -1
  153. package/dist/index.js.map +1 -1
  154. package/dist/protocol/procedure-types.d.ts +10 -0
  155. package/dist/protocol/procedure-types.d.ts.map +1 -0
  156. package/dist/protocol/procedure-types.js +2 -0
  157. package/dist/protocol/procedure-types.js.map +1 -0
  158. package/dist/protocol/raw-routes.d.ts +417 -0
  159. package/dist/protocol/raw-routes.d.ts.map +1 -0
  160. package/dist/protocol/raw-routes.js +140 -0
  161. package/dist/protocol/raw-routes.js.map +1 -0
  162. package/dist/protocol/route-meta.d.ts +18 -0
  163. package/dist/protocol/route-meta.d.ts.map +1 -0
  164. package/dist/protocol/route-meta.js +3 -0
  165. package/dist/protocol/route-meta.js.map +1 -0
  166. package/dist/protocol/routes.d.ts +3 -0
  167. package/dist/protocol/routes.d.ts.map +1 -1
  168. package/dist/protocol/routes.js +47 -14
  169. package/dist/protocol/routes.js.map +1 -1
  170. package/dist/schemas/agent.d.ts +33 -0
  171. package/dist/schemas/agent.d.ts.map +1 -1
  172. package/dist/schemas/agent.js +22 -3
  173. package/dist/schemas/agent.js.map +1 -1
  174. package/dist/schemas/agents.d.ts +4 -0
  175. package/dist/schemas/agents.d.ts.map +1 -1
  176. package/dist/schemas/agents.js +4 -0
  177. package/dist/schemas/agents.js.map +1 -1
  178. package/dist/schemas/automations.d.ts +1 -0
  179. package/dist/schemas/automations.d.ts.map +1 -1
  180. package/dist/schemas/devices.d.ts.map +1 -1
  181. package/dist/schemas/devices.js +2 -2
  182. package/dist/schemas/devices.js.map +1 -1
  183. package/dist/schemas/git/git.d.ts +3 -0
  184. package/dist/schemas/git/git.d.ts.map +1 -1
  185. package/dist/schemas/hosts.d.ts +1 -0
  186. package/dist/schemas/hosts.d.ts.map +1 -1
  187. package/dist/schemas/hosts.js +1 -0
  188. package/dist/schemas/hosts.js.map +1 -1
  189. package/dist/schemas/settings.d.ts +1 -0
  190. package/dist/schemas/settings.d.ts.map +1 -1
  191. package/dist/schemas/settings.js +4 -0
  192. package/dist/schemas/settings.js.map +1 -1
  193. package/dist/state/definition.d.ts +4 -0
  194. package/dist/state/definition.d.ts.map +1 -1
  195. package/dist/state/history-state.d.ts.map +1 -1
  196. package/dist/state/history-state.js +2 -12
  197. package/dist/state/history-state.js.map +1 -1
  198. package/dist/state/landed-commit.d.ts +4 -0
  199. package/dist/state/landed-commit.d.ts.map +1 -0
  200. package/dist/state/landed-commit.js +12 -0
  201. package/dist/state/landed-commit.js.map +1 -0
  202. package/package.json +5 -5
  203. package/src/contracts/accounts.contract.ts +7 -7
  204. package/src/contracts/activity.contract.ts +3 -3
  205. package/src/contracts/agent.contract.ts +25 -11
  206. package/src/contracts/agents.contract.ts +48 -30
  207. package/src/contracts/approvals.contract.ts +4 -4
  208. package/src/contracts/areas.contract.ts +5 -4
  209. package/src/contracts/automations.contract.ts +12 -12
  210. package/src/contracts/capabilities.contract.ts +23 -14
  211. package/src/contracts/chores.contract.ts +4 -4
  212. package/src/contracts/ci.contract.ts +6 -6
  213. package/src/contracts/diff.contract.ts +2 -2
  214. package/src/contracts/endpoints.contract.ts +5 -5
  215. package/src/contracts/exit.contract.ts +11 -8
  216. package/src/contracts/extensions.contract.ts +18 -18
  217. package/src/contracts/git.contract.ts +46 -46
  218. package/src/contracts/history.contract.ts +5 -5
  219. package/src/contracts/intentic.contract.ts +5 -4
  220. package/src/contracts/inventory.contract.ts +4 -4
  221. package/src/contracts/issues.contract.ts +6 -6
  222. package/src/contracts/logs.contract.ts +8 -4
  223. package/src/contracts/loops.contract.ts +7 -7
  224. package/src/contracts/netdisk.contract.ts +7 -4
  225. package/src/contracts/panels.contract.ts +4 -4
  226. package/src/contracts/personas.contract.ts +10 -9
  227. package/src/contracts/ports.contract.ts +6 -4
  228. package/src/contracts/prepush.contract.ts +4 -4
  229. package/src/contracts/providers.contract.ts +5 -3
  230. package/src/contracts/public.contract.ts +4 -4
  231. package/src/contracts/push.contract.ts +12 -5
  232. package/src/contracts/safety.contract.ts +4 -4
  233. package/src/contracts/secrets.contract.ts +16 -10
  234. package/src/contracts/sessions.contract.ts +6 -3
  235. package/src/contracts/settings.contract.ts +11 -10
  236. package/src/contracts/share.contract.ts +5 -5
  237. package/src/contracts/skills.contract.ts +6 -6
  238. package/src/contracts/system.contract.ts +32 -20
  239. package/src/contracts/translator.contract.ts +6 -6
  240. package/src/contracts/usage.contract.ts +5 -5
  241. package/src/contracts/vpn.contract.ts +8 -5
  242. package/src/contracts/workflows.contract.ts +10 -10
  243. package/src/contracts/workspace.contract.ts +49 -31
  244. package/src/events/land-breakage.ts +11 -0
  245. package/src/index.ts +23 -1
  246. package/src/models/capability-ledger.test.ts +1 -1
  247. package/src/protocol/procedure-types.ts +24 -0
  248. package/src/protocol/raw-routes.ts +191 -0
  249. package/src/protocol/route-meta.ts +39 -0
  250. package/src/protocol/routes.test.ts +65 -2
  251. package/src/protocol/routes.ts +88 -27
  252. package/src/schemas/agent.test.ts +66 -0
  253. package/src/schemas/agent.ts +184 -154
  254. package/src/schemas/agents.ts +6 -0
  255. package/src/schemas/devices.ts +2 -2
  256. package/src/schemas/hosts.ts +4 -0
  257. package/src/schemas/settings.ts +9 -0
  258. package/src/state/history-state.ts +14 -29
  259. package/src/state/landed-commit.test.ts +18 -0
  260. package/src/state/landed-commit.ts +17 -0
@@ -0,0 +1,39 @@
1
+ import { oc } from "@orpc/contract";
2
+ import type { MemberRole } from "../schemas/shared.js";
3
+
4
+ // Every route's policy, declared beside it: how a request authenticates, which member tier and which machine
5
+ // credentials reach it, and how the daemon's outer middleware treats it. Each field has a default, so most routes
6
+ // declare nothing; the daemon's bearer, boot, timer, role-floor and grant decisions all derive from this.
7
+
8
+ // How far a control token reaches a route beyond what its floor alone gives the `read`/`drive` rungs.
9
+ // `never`: no rung; `editor`: the editor slice too; `read`: the read rung, though no GET; `land`: the land rung too.
10
+ export type ControlReach = "never" | "editor" | "read" | "land";
11
+
12
+ export interface RouteMeta {
13
+ // `door`: the session middleware lets it through, and the handler checks its own credential or none.
14
+ readonly auth?: "session" | "door";
15
+ // Loaded by third-party pages: CORS reflects the caller's origin, and the route's own allowlist is the gate.
16
+ readonly embedded?: true;
17
+ // Answers while the boot chain is still converging; everything else waits for it.
18
+ readonly beforeBoot?: true;
19
+ // Held open by design, so the request timer leaves it out.
20
+ readonly stream?: true;
21
+ // The lowest member role that may call it. Absent: viewer for a read (GET, HEAD), maintainer for anything else.
22
+ readonly floor?: MemberRole;
23
+ // On a guest member's allowlist, which is the whole of what a guest reaches.
24
+ readonly guest?: true;
25
+ // The floor an upload aimed inside ATTACHMENTS_DIR gets instead, one a guest may also make.
26
+ readonly attachmentFloor?: MemberRole;
27
+ // A passkey-registration door: a proof with no passkey yet may pass the require-passkey policy.
28
+ readonly enrolment?: true;
29
+ // The agent token, which the CLIs on the agent's PATH carry, reaches it.
30
+ readonly agent?: true;
31
+ // The desktop-sync token reaches it; a `poll` refreshes the machine's heartbeat, the held-open `pipe` does not.
32
+ readonly sync?: "poll" | "pipe";
33
+ // Withheld from the panel token, which reaches every other route: it puts a stored credential in motion.
34
+ readonly panel?: false;
35
+ readonly control?: ControlReach;
36
+ }
37
+
38
+ // The builder every sandbox procedure starts from, so each carries a RouteMeta and whatever it leaves out defaults.
39
+ export const procedure = oc.$meta<RouteMeta>({});
@@ -2,7 +2,8 @@ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { describe, it, expect } from "bun:test";
3
3
  import { z } from "zod";
4
4
  import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "../index.js";
5
- import { contractRoutes, routeNameForRequest, routeShapes, streamOf } from "./routes.js";
5
+ import { procedure } from "./route-meta.js";
6
+ import { contractRoutes, routeNameForRequest, routeShapes, servedRoute, streamOf } from "./routes.js";
6
7
 
7
8
  const fixture = {
8
9
  vpn: {
@@ -27,9 +28,16 @@ describe(`contractRoutes`, () => {
27
28
  name: `vpn.connect`,
28
29
  method: `POST`,
29
30
  path: `/vpn/{id}/connect`,
31
+ meta: {},
30
32
  });
31
33
  });
32
34
 
35
+ it(`carries the policy a procedure declared, merged over its builder's`, () => {
36
+ const operated = procedure.meta({ agent: true, control: `never` });
37
+ const routes = contractRoutes({ vpn: { connect: operated.route({ method: "POST", path: "/vpn/{id}/connect" }).meta({ floor: `collaborator` }) } });
38
+ expect(routes).toEqual([{ name: `vpn.connect`, method: `POST`, path: `/vpn/{id}/connect`, meta: { agent: true, control: `never`, floor: `collaborator` } }]);
39
+ });
40
+
33
41
  it(`ignores non-procedure members rather than inventing routes for them`, () => {
34
42
  expect(contractRoutes({ vpn: { list: fixture.vpn.list, NOT_A_ROUTE: { hello: true } } }).map((r) => r.name)).toEqual([`vpn.list`]);
35
43
  });
@@ -72,15 +80,70 @@ describe(`routeNameForRequest`, () => {
72
80
  expect(routeNameForRequest(routes, `POST`, `/vpn/connect`)).toBeUndefined();
73
81
  });
74
82
 
75
- it(`does not let an empty segment stand in for a param`, () => {
83
+ it(`reads a trailing slash as the end of the path, never as an empty param`, () => {
76
84
  expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/`)).toBeUndefined();
77
85
  });
78
86
 
87
+ // What oRPC's router (rou3) dispatches: a spelling it serves must resolve to the route it serves, or a policy
88
+ // keyed on the route would not hold at that spelling.
89
+ it(`reads the path as oRPC's router does: two trailing slashes dropped, an empty inner segment still a param`, () => {
90
+ expect(routeNameForRequest(routes, `GET`, `/vpn/`)).toBe(`vpn.list`);
91
+ expect(routeNameForRequest(routes, `GET`, `/vpn//`)).toBe(`vpn.list`);
92
+ expect(routeNameForRequest(routes, `GET`, `/vpn///`)).toBeUndefined();
93
+ expect(routeNameForRequest(routes, `POST`, `/vpn//connect`)).toBe(`vpn.connect`);
94
+ expect(routeNameForRequest(routes, `POST`, `/vpn/corp-gw/connect/`)).toBe(`vpn.connect`);
95
+ expect(routeNameForRequest(routes, `GET`, `//vpn`)).toBeUndefined();
96
+ });
97
+
79
98
  it(`returns undefined for the daemon's hand-written non-contract routes`, () => {
80
99
  expect(routeNameForRequest(routes, `GET`, `/health`)).toBeUndefined();
81
100
  });
82
101
  });
83
102
 
103
+ describe(`servedRoute`, () => {
104
+ const contract = contractRoutes(fixture);
105
+ const raw = [
106
+ { name: `GET /health`, method: `GET`, path: `/health`, meta: { auth: `door` as const } },
107
+ { name: `POST /vpn/{id}/connect`, method: `POST`, path: `/vpn/{id}/connect`, meta: { auth: `door` as const } },
108
+ { name: `DELETE /vpn/{id}/connect`, method: `DELETE`, path: `/vpn/{id}/connect`, meta: {} },
109
+ { name: `ALL /x/*`, method: `ALL`, path: `/x/*`, meta: {} },
110
+ ];
111
+ const served = (method: string, path: string) => servedRoute(raw, contract, method, path)?.name;
112
+
113
+ it(`answers with a raw route before the contract, as the daemon registers them ahead of oRPC's catch-all`, () => {
114
+ expect(served(`POST`, `/vpn/corp-gw/connect`)).toBe(`POST /vpn/{id}/connect`);
115
+ expect(served(`GET`, `/vpn`)).toBe(`vpn.list`);
116
+ });
117
+
118
+ // Hono is strict where oRPC is not, so a raw route's variant spelling falls through to the contract.
119
+ it(`matches a raw route exactly: no trailing slash, no empty param`, () => {
120
+ expect(served(`POST`, `/vpn/corp-gw/connect/`)).toBe(`vpn.connect`);
121
+ expect(served(`POST`, `/vpn//connect`)).toBe(`vpn.connect`);
122
+ expect(served(`GET`, `/health/`)).toBeUndefined();
123
+ });
124
+
125
+ it(`answers HEAD with a raw GET route and every method with an ALL one, in registration order`, () => {
126
+ expect(served(`HEAD`, `/health`)).toBe(`GET /health`);
127
+ expect(served(`HEAD`, `/vpn`)).toBeUndefined();
128
+ expect(served(`PATCH`, `/x/a/b`)).toBe(`ALL /x/*`);
129
+ expect(served(`DELETE`, `/vpn/corp-gw/connect`)).toBe(`DELETE /vpn/{id}/connect`);
130
+ });
131
+
132
+ // The CORS middleware answers a preflight for whatever sits at the path, before any route runs.
133
+ it(`resolves a preflight to the most literal route at its path, whatever method that route serves`, () => {
134
+ expect(served(`OPTIONS`, `/vpn/corp-gw/connect`)).toBe(`POST /vpn/{id}/connect`);
135
+ expect(served(`OPTIONS`, `/vpn`)).toBe(`vpn.list`);
136
+ expect(served(`OPTIONS`, `/no/such/route`)).toBeUndefined();
137
+ });
138
+
139
+ it(`gives a trailing /* the segments after the prefix, never the bare prefix`, () => {
140
+ expect(served(`GET`, `/x/`)).toBe(`ALL /x/*`);
141
+ expect(served(`GET`, `/x/a`)).toBe(`ALL /x/*`);
142
+ expect(served(`GET`, `/x`)).toBeUndefined();
143
+ expect(served(`GET`, `/xy/a`)).toBeUndefined();
144
+ });
145
+ });
146
+
84
147
  describe(`routeShapes`, () => {
85
148
  it(`gives the same fingerprint for the same shape, twice`, () => {
86
149
  expect(routeShapes(shaped(z.object({ a: z.string() })))).toEqual(routeShapes(shaped(z.object({ a: z.string() }))));
@@ -1,5 +1,6 @@
1
1
  import { eventIterator } from "@orpc/contract";
2
2
  import { z } from "zod";
3
+ import type { RouteMeta } from "./route-meta.js";
3
4
 
4
5
  // Named route surface of the daemon's contract (`<group>.<route>`), derived automatically so nothing here is
5
6
  // hand-maintained. The daemon advertises which routes it implements (the /events hello frame); the browser diffs that
@@ -18,29 +19,30 @@ export const streamOf = <T extends z.ZodType>(frame: T) => Object.assign(eventIt
18
19
  const frameOf = (schema: unknown): z.ZodType | undefined =>
19
20
  typeof schema === "object" && schema !== null && FRAME in schema ? ((schema as Record<symbol, unknown>)[FRAME] as z.ZodType) : undefined;
20
21
 
21
- // Structural shape of `~orpc.route`, the metadata oRPC attaches to every `oc.route(...)` procedure; read this way since
22
- // oRPC's internal types aren't public.
22
+ // Structural shape of `~orpc`, what oRPC attaches to every procedure: its route, and the RouteMeta its builder carried
23
+ // (route-meta.ts). Read this way since oRPC's internal types aren't public.
23
24
  interface ContractProcedureLike {
24
- readonly "~orpc": { readonly route?: { readonly method?: string; readonly path?: string } };
25
+ readonly "~orpc": { readonly route?: { readonly method?: string; readonly path?: string }; readonly meta?: RouteMeta };
25
26
  }
26
27
 
27
- const procedureRoute = (value: unknown): { method: string; path: string } | undefined => {
28
+ const procedureRoute = (value: unknown): Omit<ContractRoute, "name"> | undefined => {
28
29
  if (typeof value !== "object" || value === null || !("~orpc" in value)) {
29
30
  return undefined;
30
31
  }
31
- const { route } = (value as ContractProcedureLike)["~orpc"];
32
+ const { route, meta } = (value as ContractProcedureLike)["~orpc"];
32
33
  if (route?.method === undefined || route.path === undefined) {
33
34
  return undefined;
34
35
  }
35
- return { method: route.method, path: route.path };
36
+ return { method: route.method, path: route.path, meta: meta ?? {} };
36
37
  };
37
38
 
38
- // One advertised route: contract name plus wire shape, matched back to a concrete request path by routeNameForRequest.
39
+ // One declared route: its name and wire shape, matched back to a concrete request by servedRoute, and its policy.
39
40
  export interface ContractRoute {
40
41
  readonly name: string;
41
42
  readonly method: string;
42
- // oRPC path template with `{param}` placeholders, e.g. `/system/terminals/{name}`.
43
+ // Path template with `{param}` placeholders, e.g. `/system/terminals/{name}`; a raw route's may end in `/*`.
43
44
  readonly path: string;
45
+ readonly meta: RouteMeta;
44
46
  }
45
47
 
46
48
  // Walks a contract object (group → procedure) into a flat route list, sorted by name for a stable diff.
@@ -53,7 +55,7 @@ export const contractRoutes = (contract: Record<string, unknown>): ContractRoute
53
55
  for (const [name, procedure] of Object.entries(procedures as Record<string, unknown>)) {
54
56
  const route = procedureRoute(procedure);
55
57
  if (route !== undefined) {
56
- routes.push({ name: `${group}.${name}`, method: route.method, path: route.path });
58
+ routes.push({ name: `${group}.${name}`, ...route });
57
59
  }
58
60
  }
59
61
  }
@@ -143,30 +145,89 @@ export const routeShapes = (contract: Record<string, unknown>): Record<string, s
143
145
  return shapes;
144
146
  };
145
147
 
146
- // Matches a concrete path against a route template segment-wise; `{param}` matches exactly one segment, so length must
147
- // match too.
148
- const pathMatches = (template: string, path: string): boolean => {
149
- const wanted = template.split("/");
150
- const actual = path.split("/");
151
- if (wanted.length !== actual.length) {
148
+ // A template split once: a literal segment, or undefined for a `{param}`; `tail` for a raw route's trailing `/*`.
149
+ interface Pattern {
150
+ readonly route: ContractRoute;
151
+ readonly segments: readonly (string | undefined)[];
152
+ readonly tail: boolean;
153
+ }
154
+
155
+ const compiled = new WeakMap<readonly ContractRoute[], readonly Pattern[]>();
156
+
157
+ const patternsOf = (routes: readonly ContractRoute[]): readonly Pattern[] => {
158
+ const known = compiled.get(routes);
159
+ if (known !== undefined) {
160
+ return known;
161
+ }
162
+ const patterns = routes.map((route) => {
163
+ const parts = route.path.split("/").slice(1);
164
+ const tail = parts.at(-1) === "*";
165
+ const segments = (tail ? parts.slice(0, -1) : parts).map((part) => (part.startsWith("{") && part.endsWith("}") ? undefined : part));
166
+ return { route, segments, tail };
167
+ });
168
+ compiled.set(routes, patterns);
169
+ return patterns;
170
+ };
171
+
172
+ // Whether a template takes a request's segments. Hono (`raw`) gives a `{param}` only a non-empty segment and a `/*` one
173
+ // or more further ones; oRPC's router gives a `{param}` any segment, the empty one included.
174
+ const takes = (pattern: Pattern, segments: readonly string[], raw: boolean): boolean => {
175
+ const { segments: wanted, tail } = pattern;
176
+ if (tail ? segments.length <= wanted.length : segments.length !== wanted.length) {
152
177
  return false;
153
178
  }
154
- return wanted.every((segment, index) => (segment.startsWith("{") && segment.endsWith("}") ? actual[index] !== "" : segment === actual[index]));
179
+ return wanted.every((segment, index) => (segment === undefined ? !raw || segments[index] !== "" : segment === segments[index]));
180
+ };
181
+
182
+ // oRPC's router tries a literal segment before a parameter, left to right: `/agents/search` is the search route, not
183
+ // `get` with an id of "search", whichever of the two the list happens to hold first.
184
+ const moreLiteral = (a: Pattern, b: Pattern): boolean => {
185
+ const at = a.segments.findIndex((segment, index) => (segment === undefined) !== (b.segments[index] === undefined));
186
+ return at >= 0 && a.segments[at] !== undefined;
155
187
  };
156
188
 
157
- // The contract route a request belongs to, or undefined for a hand-written daemon route (/health, /workspace/raw) never
158
- // gated by the contract. Query string stripped first.
159
- // How many segments a template leaves open; the tie-breaker below prefers the template that leaves fewest.
160
- const paramCount = (template: string): number => template.split("/").filter((segment) => segment.startsWith("{") && segment.endsWith("}")).length;
189
+ const mostLiteral = (patterns: readonly Pattern[]): Pattern | undefined =>
190
+ patterns.reduce<Pattern | undefined>((best, pattern) => (best === undefined || moreLiteral(pattern, best) ? pattern : best), undefined);
191
+
192
+ // Whether a route declared for one method answers a request's. The CORS middleware answers a preflight (OPTIONS) for
193
+ // every route at its path; Hono (`raw`) answers every method with an `ALL` route and HEAD with a GET one.
194
+ const answers = (declared: string, method: string, raw: boolean): boolean =>
195
+ method === "OPTIONS" || declared === method || (raw && (declared === "ALL" || (declared === "GET" && method === "HEAD")));
196
+
197
+ // A request path as oRPC's router reads it: one trailing slash dropped, then one trailing empty segment, so `/a/` and
198
+ // `/a//` are `/a` while `/a///` is not.
199
+ const orpcSegments = (path: string): string[] => {
200
+ const segments = (path.endsWith("/") ? path.slice(0, -1) : path).split("/").slice(1);
201
+ return segments.at(-1) === "" ? segments.slice(0, -1) : segments;
202
+ };
203
+
204
+ const contractRouteFor = (routes: readonly ContractRoute[], method: string, path: string): ContractRoute | undefined => {
205
+ const segments = orpcSegments(path);
206
+ return mostLiteral(patternsOf(routes).filter((pattern) => answers(pattern.route.method.toUpperCase(), method, false) && takes(pattern, segments, false)))
207
+ ?.route;
208
+ };
209
+
210
+ // Hono matches the path exactly, and among overlapping routes the first registered answers; a preflight belongs to none
211
+ // of them, so the most literal names it.
212
+ const rawRouteFor = (routes: readonly ContractRoute[], method: string, path: string): ContractRoute | undefined => {
213
+ const segments = path.split("/").slice(1);
214
+ const matching = patternsOf(routes).filter((pattern) => answers(pattern.route.method, method, true) && takes(pattern, segments, true));
215
+ return (method === "OPTIONS" ? mostLiteral(matching) : matching[0])?.route;
216
+ };
217
+
218
+ const pathOf = (pathWithQuery: string): string => pathWithQuery.split("?")[0] ?? pathWithQuery;
219
+
220
+ // The contract route a request belongs to, read as oRPC's router reads it, or undefined for a hand-written daemon route
221
+ // (/health, /workspace/raw) the contract never declared. Query string stripped first.
222
+ export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined =>
223
+ contractRouteFor(routes, method.toUpperCase(), pathOf(pathWithQuery))?.name;
161
224
 
162
- export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
163
- const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
225
+ // The route that serves a request to the daemon: a raw route as Hono matches it, since those are registered ahead of
226
+ // oRPC's catch-all, else a contract route as oRPC's router does; undefined when neither serves it.
227
+ export const servedRoute = (raw: readonly ContractRoute[], contract: readonly ContractRoute[], method: string, pathWithQuery: string): ContractRoute | undefined => {
164
228
  const upper = method.toUpperCase();
165
- // A literal segment outranks a parameter: `/agents/search` is the search route, not `get` with an id of "search",
166
- // whichever of the two the sorted list happens to hold first.
167
- return routes
168
- .filter((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))
169
- .toSorted((a, b) => paramCount(a.path) - paramCount(b.path))[0]?.name;
229
+ const path = pathOf(pathWithQuery);
230
+ return rawRouteFor(raw, upper, path) ?? contractRouteFor(contract, upper, path);
170
231
  };
171
232
 
172
233
  // The route a typed client call belongs to; oRPC addresses a procedure by contract position (`['git','stashApply']`),
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { AgentTurnSchema, profileOf, TurnProfileSchema } from "./agent.js";
3
+
4
+ // The profile is what every continuation carries whole, so its field set and its normalisation are the contract: a key
5
+ // dropped here is a knob every resume, wake and nudge silently resets.
6
+ describe("TurnProfile", () => {
7
+ test("names exactly the fields that say which turn this is", () => {
8
+ expect(Object.keys(TurnProfileSchema.shape).toSorted()).toEqual([
9
+ "account",
10
+ "actsAs",
11
+ "agent",
12
+ "effort",
13
+ "fast",
14
+ "harness",
15
+ "isolated",
16
+ "model",
17
+ "runRole",
18
+ "thinking",
19
+ "unattended",
20
+ ]);
21
+ });
22
+
23
+ test("takes every stated field off a turn, and nothing it says or where it goes", () => {
24
+ const turn = AgentTurnSchema.parse({
25
+ prompt: "Fix the login bug",
26
+ conversationId: "c-1",
27
+ sessionId: "s-1",
28
+ agent: "codex",
29
+ harness: "claude-code",
30
+ account: "acct-2",
31
+ model: "gpt-5",
32
+ effort: "high",
33
+ thinking: false,
34
+ fast: true,
35
+ actsAs: "support",
36
+ isolated: true,
37
+ unattended: true,
38
+ runRole: "loop-iteration",
39
+ permissionMode: "plan",
40
+ autoLand: false,
41
+ });
42
+ expect(profileOf(turn)).toStrictEqual({
43
+ agent: "codex",
44
+ harness: "claude-code",
45
+ account: "acct-2",
46
+ model: "gpt-5",
47
+ effort: "high",
48
+ thinking: false,
49
+ fast: true,
50
+ actsAs: "support",
51
+ isolated: true,
52
+ unattended: true,
53
+ runRole: "loop-iteration",
54
+ });
55
+ });
56
+
57
+ test("leaves absent fields absent rather than naming them undefined", () => {
58
+ expect(profileOf({ model: "sonnet", effort: undefined })).toStrictEqual({ model: "sonnet" });
59
+ expect(Object.keys(profileOf({}))).toEqual([]);
60
+ });
61
+
62
+ // `thinking: false` is a choice the next turn must keep; `isolated: false` only restates the default.
63
+ test("carries a false knob but not a false placement or audience", () => {
64
+ expect(profileOf({ thinking: false, fast: false, isolated: false, unattended: false })).toStrictEqual({ thinking: false, fast: false });
65
+ });
66
+ });