browser-broker 0.1.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 (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { captureFileName } from "../artifacts/names.js";
3
+ import { BrokerError } from "../errors.js";
4
+ import { captureWarning } from "./accounting.js";
5
+ import { decodePng, downscale, encodePng } from "./image.js";
6
+ import { DEFAULT_TIER, REASON_MAXIMUM_LENGTH, REASON_MINIMUM_LENGTH, TIER_LONGEST_EDGE, TIER_REQUIRING_REASON, estimateTokens, } from "./tiers.js";
7
+ /**
8
+ * The escalation guidance, built from the tier names themselves.
9
+ *
10
+ * `SCHEMA.md` §3.11 is specific about what this must say, and the specificity
11
+ * is the point: *"Not merely that higher tiers exist — **which fields to
12
+ * pass**, naming `tier` and its two values, **and that the top tier requires a
13
+ * written reason.**"* A caller that cannot read the fine print out of a
14
+ * specification it does not have open is a caller that either never escalates
15
+ * or escalates by trial and error, and both waste a call.
16
+ *
17
+ * Composed from {@link TIER_LONGEST_EDGE} and {@link TIER_REQUIRING_REASON}
18
+ * rather than written out, so this text always quotes the rungs the pipeline
19
+ * actually shrinks to, whatever the study (#34) sets them to.
20
+ */
21
+ function escalationGuidance() {
22
+ return (`This is the default resolution (about ${String(TIER_LONGEST_EDGE.default)}px on the long ` +
23
+ `edge), which is what you get for passing no tier. For more detail pass ` +
24
+ `tier="detail" (about ${String(TIER_LONGEST_EDGE.detail)}px). For the most, pass ` +
25
+ `tier="max" (about ${String(TIER_LONGEST_EDGE.max)}px) — and note that "max" additionally ` +
26
+ `requires reason, a written explanation of ${String(REASON_MINIMUM_LENGTH)}–` +
27
+ `${String(REASON_MAXIMUM_LENGTH)} characters in your own words. The reason is recorded for ` +
28
+ `review rather than used to decide anything: escalating is allowed, and saying why is how ` +
29
+ `the default gets tuned.`);
30
+ }
31
+ /**
32
+ * The three argument mistakes §3.11 names, and **the only refusals here.**
33
+ *
34
+ * *"The only refusals are argument mistakes: an unknown or closed tab, the top
35
+ * tier without a reason, and a selector combined with a full page."* The first
36
+ * belongs to the layer that resolves a tab identifier; the other two are this
37
+ * module's.
38
+ *
39
+ * **Nothing in this function considers cost, budget, count or resolution**,
40
+ * and that absence is what `capture.never_refused_for_cost` (§7.3) asserts.
41
+ * Every refusal it can make is about the arguments disagreeing with each
42
+ * other, never about how many pictures anybody has taken.
43
+ */
44
+ function refuseArgumentMistakes(options) {
45
+ if (options.fullPage === true && options.selector !== undefined) {
46
+ throw new BrokerError('capture.arguments_consistent', 'A selector cannot be combined with a full page: one asks for a single element and the other for the whole document. Pass one or the other.');
47
+ }
48
+ const tier = options.tier ?? DEFAULT_TIER;
49
+ if (tier !== TIER_REQUIRING_REASON) {
50
+ return;
51
+ }
52
+ const reason = options.reason?.trim() ?? '';
53
+ if (reason.length < REASON_MINIMUM_LENGTH || reason.length > REASON_MAXIMUM_LENGTH) {
54
+ throw new BrokerError('capture.reason_required', `tier="${TIER_REQUIRING_REASON}" requires reason: a written explanation of ${String(REASON_MINIMUM_LENGTH)}–${String(REASON_MAXIMUM_LENGTH)} characters, in your own words rather than chosen from a list. It is recorded for review, not used to decide whether to serve the capture — nothing here is refused on cost grounds.`);
55
+ }
56
+ }
57
+ /**
58
+ * Take one capture.
59
+ *
60
+ * @param capturesTakenBefore how many captures this lease had already taken.
61
+ * Supplied by the layer that counts them, because counting is a query
62
+ * against the store and this module reaches no store.
63
+ *
64
+ * The order below is the specification and is not incidental:
65
+ *
66
+ * 1. **Refuse the argument mistakes first**, before anything physical happens.
67
+ * `DECISIONS.md` §5: *a guard that returns "denied" after the tab has
68
+ * already opened is worse than no guard.* The refusals above run before the
69
+ * driver is touched at all, and the test for each asserts the driver's call
70
+ * log is empty rather than merely that an error was thrown.
71
+ * 2. **Settle the page, then take the picture** (§3.11). Two calls in that
72
+ * order, on the seam, so the ordering is assertable from outside.
73
+ * 3. **Downscale to the tier**, never up.
74
+ * 4. **Write it**, through the artifact store, which is the only thing that
75
+ * decides where a file may go.
76
+ */
77
+ export async function takeCapture(dependencies, claimId, tab, options, capturesTakenBefore) {
78
+ refuseArgumentMistakes(options);
79
+ const now = dependencies.now ?? (() => new Date());
80
+ const newId = dependencies.newId ?? randomUUID;
81
+ const tier = options.tier ?? DEFAULT_TIER;
82
+ const fullPage = options.fullPage ?? false;
83
+ // Settling comes before the shutter and is a separate call, so that "every
84
+ // capture settles the page first" is one assertion on the driver's call log
85
+ // rather than a claim about a driver's internals (§3.11).
86
+ await dependencies.tabs.settlePage(tab);
87
+ const raw = await dependencies.tabs.capture(tab, {
88
+ fullPage,
89
+ selector: options.selector,
90
+ // Passed straight through to the driver, because masking *before* the
91
+ // pixels exist beats filtering afterwards: a region that was never
92
+ // captured cannot be reported as changed (§3.11).
93
+ mask: options.mask,
94
+ });
95
+ const decoded = decodePng(raw.image);
96
+ const shrunk = downscale(decoded, TIER_LONGEST_EDGE[tier]);
97
+ // Re-encoded only when the pixels actually changed. A picture already inside
98
+ // the rung is written as the browser produced it, which keeps
99
+ // `width === source_width` meaning "nothing was shrunk" without a re-encode
100
+ // silently changing the bytes underneath that claim.
101
+ const encoded = shrunk === decoded ? raw.image : encodePng(shrunk);
102
+ const takenAt = now();
103
+ const id = newId();
104
+ const stored = dependencies.artifacts.write(claimId, 'images', captureFileName({
105
+ // Derived from the address, with the query string stripped first — see
106
+ // `names.ts` for why a file name is held to a stricter rule than a
107
+ // column is.
108
+ url: raw.url,
109
+ label: options.label,
110
+ viewportWidth: raw.viewportWidth,
111
+ takenAt,
112
+ id,
113
+ }), encoded);
114
+ const warning = captureWarning(capturesTakenBefore);
115
+ const kind = options.selector !== undefined ? 'element' : fullPage ? 'full_page' : 'viewport';
116
+ const telemetry = {
117
+ id,
118
+ kind,
119
+ tier,
120
+ // Only ever recorded on the tier that requires it: a reason attached to a
121
+ // capture nobody had to justify would put noise into the one column the
122
+ // resolution study reads (§1.7).
123
+ reason: tier === TIER_REQUIRING_REASON ? options.reason?.trim() : undefined,
124
+ sourceWidth: raw.width,
125
+ sourceHeight: raw.height,
126
+ width: shrunk.width,
127
+ height: shrunk.height,
128
+ bytes: stored.bytes,
129
+ path: stored.relativePath,
130
+ selector: options.selector,
131
+ viewportWidth: raw.viewportWidth,
132
+ url: raw.url,
133
+ warned: warning !== undefined,
134
+ takenAt,
135
+ };
136
+ return {
137
+ captureId: id,
138
+ path: stored.relativePath,
139
+ width: shrunk.width,
140
+ height: shrunk.height,
141
+ bytes: stored.bytes,
142
+ sourceWidth: raw.width,
143
+ sourceHeight: raw.height,
144
+ tier,
145
+ estimatedTokens: estimateTokens(shrunk.width, shrunk.height),
146
+ capturesThisLease: capturesTakenBefore + 1,
147
+ // Present exactly when the caller landed on the default rung, because that
148
+ // is exactly the caller who has not been told what the alternatives are.
149
+ ...(tier === DEFAULT_TIER ? { escalation: escalationGuidance() } : {}),
150
+ ...(warning !== undefined ? { warning } : {}),
151
+ telemetry,
152
+ };
153
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The three resolution rungs, the default, and the estimate of what a picture
3
+ * costs to look at.
4
+ *
5
+ * ── The default is the lever, and this file is where it lives ───────────
6
+ *
7
+ * `DECISIONS.md` §13d reverses the research recommendation, which was a hard
8
+ * service-enforced ceiling with a refusal past a per-lease budget. What ships
9
+ * is a **low default, an explicit opt-in to go higher, and a warning that
10
+ * never becomes a wall** — and the reason is that *most callers never pass an
11
+ * optional parameter*, so a low default does nearly all the work of a ceiling
12
+ * **without blocking anyone**. `MILESTONES.md` #31 puts it plainly: this row
13
+ * carries the lever, not #33.
14
+ *
15
+ * The property underneath, stated as a property because a property survives
16
+ * somebody raising the default and a coincidence does not: **text legibility
17
+ * breaks at a higher resolution than layout critique does.** So a low default
18
+ * naturally pushes a caller that needs to *read* something toward the snapshot
19
+ * or the evaluation, which return text and cost almost nothing. The policy does
20
+ * not have to argue anyone into the cheaper tool.
21
+ *
22
+ * ── These numbers have been MEASURED, and they survived it ──────────────
23
+ *
24
+ * `MILESTONES.md` #34's resolution-ladder study has been run — the harness is
25
+ * `ladder.ts`, the instruments are `legibility.ts`, and the measurements are
26
+ * `tests/capture/ladder.test.ts` (everywhere) and
27
+ * `tests/capture/ladder-rendered.test.ts` (where a browser exists).
28
+ * **The measurement kept all three rungs below**, and the evidence for each is
29
+ * published beside it. A change to any of them needs a measurement rather than
30
+ * an argument.
31
+ *
32
+ * **The property the default rests on held.** *Text legibility breaks at a
33
+ * higher resolution than layout critique does* is now measured rather than
34
+ * asserted, and the mechanism is nameable: a downscale destroys a feature when
35
+ * the feature's period falls below roughly two and a half **destination**
36
+ * pixels, so what a rung costs a picture depends on the size of the feature
37
+ * rather than on the rung. A block-scale feature — the scale a layout
38
+ * judgement is made at — survives every rung on this ladder, including well
39
+ * below the cheapest one. Fine text detail does not.
40
+ *
41
+ * Stem retention on rendered prose, at a viewport wide enough that all three
42
+ * rungs genuinely shrink:
43
+ *
44
+ * | Font | `default` | `detail` | `max` |
45
+ * |---|---|---|---|
46
+ * | 11px | 35% | 41% | 100% |
47
+ * | 12px | 24% | 50% | 100% |
48
+ * | 14px | 7% | 83% | 100% |
49
+ * | 16px | 63% | 93% | 100% |
50
+ * | 20px | 87% | 100% | 100% |
51
+ * | 32px | 98% | 100% | 100% |
52
+ *
53
+ * Each rung does the job its name claims: **`max` returns everything**, which
54
+ * is why it is the one that costs a written reason; **`detail` recovers
55
+ * ordinary body copy**; and **`default` keeps headings and layout intact while
56
+ * damaging small body copy** — which is not a defect but the lever working,
57
+ * since a caller that needs to *read* something is pushed toward the snapshot
58
+ * or the evaluation, which return text and cost almost nothing.
59
+ *
60
+ * ── ⚠️ What the study did NOT settle ────────────────────────────────────
61
+ *
62
+ * **The absolute legibility floor is still open**, and it is worth being exact
63
+ * about why. The instruments measure what the *pipeline* destroys — stroke
64
+ * contrast, and whether the gaps between strokes survive — which **bounds**
65
+ * what any reader could recover but does not predict what one will. What an
66
+ * agent looking at a picture can actually read is a property of that model,
67
+ * not of these pixels, and no test here can establish it. So the rungs are
68
+ * settled as *"these deliver the structure they claim to"*, not as *"this is
69
+ * the smallest picture a reader can use"*. §9.3 keeps that second question
70
+ * open.
71
+ *
72
+ * They remain named constants in one module: **nothing hard-codes a rung
73
+ * anywhere a later study cannot change it**, and a capture records which rung
74
+ * it was taken at (`captures.tier`) rather than having its rung inferred from
75
+ * its dimensions, so a rung moving invalidates nothing already stored (§6.2).
76
+ *
77
+ * ── The token estimate is fixed by the version, deliberately ────────────
78
+ *
79
+ * `SCHEMA.md` §6.4: the formula that estimates an image's token cost is fixed
80
+ * by the version and **is not configurable**. *"An estimate is only comparable
81
+ * across time if it was computed the same way, and letting an operator change
82
+ * the formula would silently make old and new numbers incomparable — which
83
+ * would break the one study they exist for."* So there is no environment
84
+ * variable here and no parameter: {@link estimateTokens} takes dimensions and
85
+ * nothing else.
86
+ */
87
+ /**
88
+ * What a caller gets for asking for nothing.
89
+ *
90
+ * The single most consequential value in this milestone — `MILESTONES.md`
91
+ * #31: *"getting 'cheapest tier when nothing is asked for' right matters more
92
+ * than any threshold downstream of it."*
93
+ */
94
+ export const DEFAULT_TIER = 'default';
95
+ /**
96
+ * The tier that costs a written reason.
97
+ *
98
+ * Named rather than spelled `'max'` at each site that checks it, so that the
99
+ * rule *"the top tier requires a reason"* has one definition and the message
100
+ * telling a caller so cannot drift from the check that enforces it.
101
+ */
102
+ export const TIER_REQUIRING_REASON = 'max';
103
+ /**
104
+ * The long edge each rung shrinks to, in pixels (`SCHEMA.md` §6.2,
105
+ * `DECISIONS.md` §13d). **Measured by #34 and kept** — see the evidence table
106
+ * in this file's header.
107
+ *
108
+ * | Tier | Long edge | How a caller gets it | What the study measured it delivering |
109
+ * |---|---|---|---|
110
+ * | `default` | 1024 | passes nothing | layout and headings intact; small body copy damaged |
111
+ * | `detail` | 1568 | asks for it — the ceiling of the cheap vision tier | ordinary body copy recovered |
112
+ * | `max` | 2576 | asks for it **and gives a written reason**, which is recorded | everything, at every font size tested |
113
+ */
114
+ export const TIER_LONGEST_EDGE = {
115
+ default: 1024,
116
+ detail: 1568,
117
+ max: 2576,
118
+ };
119
+ /**
120
+ * The bounds on a written reason (`SCHEMA.md` §3.11: 8–200 characters).
121
+ *
122
+ * **The minimum is not a deterrent and must not be tuned as one.** §3.11 is
123
+ * explicit: *a caller asked to justify itself will always produce a
124
+ * justification*, so the friction is not the mechanism, and making the field
125
+ * longer or the wording sterner pursues an effect it was never going to have.
126
+ * **The value is the record** — every escalation leaves a reviewable row with
127
+ * a reason attached. The minimum exists only to make the empty answer
128
+ * slightly harder to give than a real one.
129
+ */
130
+ export const REASON_MINIMUM_LENGTH = 8;
131
+ export const REASON_MAXIMUM_LENGTH = 200;
132
+ /**
133
+ * How many captures a lease may take before every subsequent one carries a
134
+ * warning (`SCHEMA.md` §6.2: **12**).
135
+ *
136
+ * *"Roughly a five-view sweep at two breakpoints plus slack."* **Never a
137
+ * refusal** — see `accounting.ts`, which is where that promise is kept and
138
+ * where the reason it can never become a wall is written down.
139
+ */
140
+ export const CAPTURES_BEFORE_WARNING = 12;
141
+ /**
142
+ * The divisor in the token estimate. **Fixed by the version** (§6.4).
143
+ *
144
+ * `SCHEMA.md` §1.7 declines to store the result: *"It is width times height
145
+ * divided by a fixed constant — a calculation over two columns on the same
146
+ * row … computed when asked for rather than frozen into a column that could
147
+ * disagree with the dimensions beside it."*
148
+ *
149
+ * The constant is consistent with the measured figures `DECISIONS.md` records
150
+ * — roughly 1,600 tokens at the 1568-pixel rung and roughly 4,800 at the
151
+ * 2576-pixel one — and it is an **estimate**, which is the word used
152
+ * everywhere on purpose. `captures.bytes` is what it is sanity-checked
153
+ * against (§1.7): a file whose size is wildly out of step with its dimensions
154
+ * is the signal that a picture was not what the numbers said.
155
+ */
156
+ const TOKENS_PER_PIXEL_DIVISOR = 750;
157
+ /**
158
+ * What looking at a picture of these dimensions is estimated to cost.
159
+ *
160
+ * Takes dimensions and nothing else — no tier, no configuration, no options
161
+ * object with a divisor in it. That signature is the enforcement of §6.4:
162
+ * there is no position in which an operator's value could arrive.
163
+ */
164
+ export function estimateTokens(width, height) {
165
+ return Math.ceil((width * height) / TOKENS_PER_PIXEL_DIVISOR);
166
+ }
@@ -0,0 +1,233 @@
1
+ import { OPERATION_COMMANDS } from "./commands.js";
2
+ /**
3
+ * The command line as an adapter: a thin shell over one service call.
4
+ *
5
+ * `SCHEMA.md` §5.1: "The command line is a full route in, and it is worth
6
+ * building even if no agent ever calls it. It is the cheapest available proof
7
+ * that the rules live in one place rather than inside a tool handler — a rule
8
+ * inside a handler is a rule that holds on one route and nowhere else."
9
+ *
10
+ * ── What this file may and may not do ───────────────────────────────────
11
+ *
12
+ * It resolves an argument vector into arguments, calls **one** operation, and
13
+ * shapes the outcome for a terminal. It reaches no database and no guard: it
14
+ * is handed a {@link BrokerService} rather than finding one, so there is no
15
+ * store handle in scope to be tempted by. Every rule that decides whether an
16
+ * operation is allowed lives behind that seam, or it holds on this route and
17
+ * not the others (`CLAUDE.md`).
18
+ *
19
+ * §5.2 is the consequence worth stating because it surprises people once: in
20
+ * process, **any command that goes through arbitration performs the lazy
21
+ * sweep** — so a listing command can close somebody else's lapsed tabs. That
22
+ * is correct, and it is why no command reads the tables directly.
23
+ */
24
+ /** Exit codes, chosen so opposite responses are distinguishable (§5.6). */
25
+ export const EXIT = {
26
+ /**
27
+ * Accepted — **including queued**. §5.6 is explicit: "queuing is an
28
+ * outcome, not a failure". A caller that treated a queue place as an error
29
+ * would abandon exactly the wait the queue exists to make orderly.
30
+ */
31
+ accepted: 0,
32
+ /** Something this service did not anticipate. */
33
+ unexpected: 1,
34
+ /** The command itself was malformed. */
35
+ malformed: 2,
36
+ /**
37
+ * Refused by a rule — distinct, "because a refusal is the service
38
+ * working". A caller can retry a refusal intelligently; it cannot retry a
39
+ * typo.
40
+ */
41
+ refused: 3,
42
+ /** Not configured. */
43
+ notConfigured: 4,
44
+ };
45
+ /**
46
+ * Fields never printed by any command, on any stream, in any mode.
47
+ *
48
+ * §5.6: "**The lease key is never printed by any command**, including in
49
+ * error output and in the machine-readable mode, where the field is absent
50
+ * rather than masked." Absent rather than masked is the specification — a
51
+ * masked field tells a reader a key exists and is being withheld, which is an
52
+ * invitation, and a masked value is one careless format change away from
53
+ * being the real one.
54
+ */
55
+ export const NEVER_PRINTED = ['lease_key', 'leaseKey', 'key'];
56
+ /**
57
+ * Strip anything that must never be printed, at every depth.
58
+ *
59
+ * Applied on the way out rather than trusted not to be included, because the
60
+ * shape of an operation's result belongs to the operation and this route
61
+ * cannot know what a later row will put in one. A rule enforced at the exit
62
+ * holds for results that did not exist when it was written.
63
+ */
64
+ export function withoutSecrets(value) {
65
+ if (Array.isArray(value)) {
66
+ return value.map((entry) => withoutSecrets(entry));
67
+ }
68
+ if (value !== null && typeof value === 'object') {
69
+ const output = {};
70
+ for (const [key, entry] of Object.entries(value)) {
71
+ if (NEVER_PRINTED.includes(key)) {
72
+ continue;
73
+ }
74
+ output[key] = withoutSecrets(entry);
75
+ }
76
+ return output;
77
+ }
78
+ return value;
79
+ }
80
+ /**
81
+ * Turn the words after a command into arguments.
82
+ *
83
+ * `--name value` and `--name=value`, plus bare `--flag` for a boolean. No
84
+ * argument-parsing library: the manifest carries exactly one runtime
85
+ * dependency and a command line of this shape does not need a second
86
+ * (`MILESTONES.md`'s binding, and a framework here would be a design
87
+ * regression rather than a convenience).
88
+ *
89
+ * Keys are normalised from the terminal's spelling to the service's:
90
+ * `--session-id` becomes `session_id`, so a caller types what a terminal
91
+ * reads and the service receives what §3 names.
92
+ *
93
+ * ── Why a value is not rejected merely for starting with `--` ───────────
94
+ *
95
+ * A lease key is 32 random bytes rendered as base64url, and **the base64url
96
+ * alphabet contains `-`**, so about one key in 6,250 begins with `--`. Read
97
+ * as "the next word looks like a flag, so this one is a boolean", such a key
98
+ * is swallowed: `lease_key` arrives as `true`, the key never reaches the
99
+ * service, and the caller is told its key is missing — for a key it holds and
100
+ * typed correctly. It is a real defect on the shipped route rather than a
101
+ * curiosity, because there is no way for that caller to succeed and nothing
102
+ * in the message points at the cause.
103
+ *
104
+ * So "looks like a flag" is decided by **shape rather than by the leading
105
+ * dashes**: a flag's name is lower-case letters, digits and hyphens, which is
106
+ * every option this command line actually has. A base64url key contains
107
+ * upper-case letters or underscores, or is far longer than any option name,
108
+ * so it fails that test and is correctly read as a value. A genuine adjacent
109
+ * flag — `broker read --lease-key K --json` — still parses as two arguments,
110
+ * because `json` matches the shape.
111
+ *
112
+ * This is deliberately a **narrow** rule and not an argument-parsing library:
113
+ * it separates the two cases that actually occur without adding a dependency
114
+ * the manifest's one-runtime-dependency binding forbids.
115
+ */
116
+ export function parseArguments(rest) {
117
+ const parsed = {};
118
+ for (let index = 0; index < rest.length; index += 1) {
119
+ const word = rest[index];
120
+ if (word === undefined || !word.startsWith('--')) {
121
+ continue;
122
+ }
123
+ const body = word.slice(2);
124
+ const equals = body.indexOf('=');
125
+ if (equals !== -1) {
126
+ parsed[normaliseKey(body.slice(0, equals))] = body.slice(equals + 1);
127
+ continue;
128
+ }
129
+ const next = rest[index + 1];
130
+ if (next === undefined || looksLikeFlag(next)) {
131
+ parsed[normaliseKey(body)] = true;
132
+ continue;
133
+ }
134
+ parsed[normaliseKey(body)] = next;
135
+ index += 1;
136
+ }
137
+ return parsed;
138
+ }
139
+ function normaliseKey(key) {
140
+ return key.replaceAll('-', '_');
141
+ }
142
+ /**
143
+ * Whether a word is an option rather than a value that begins with dashes.
144
+ *
145
+ * See {@link parseArguments} for why the leading `--` cannot decide this on
146
+ * its own. The name after the dashes must look like an option name: lower-case
147
+ * letters and digits, hyphen-separated, and non-empty. `--json` and
148
+ * `--full-page` match; a base64url lease key such as
149
+ * `--SgvBJ5qVwX2T8B9XEhfQHsD2iZ2maYPC0sflBlFjg` does not, because of its
150
+ * upper-case letters.
151
+ */
152
+ function looksLikeFlag(word) {
153
+ if (!word.startsWith('--')) {
154
+ return false;
155
+ }
156
+ const body = word.slice(2);
157
+ // `--name=value` is unambiguously an option however its value is spelled.
158
+ const name = body.indexOf('=') === -1 ? body : body.slice(0, body.indexOf('='));
159
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name);
160
+ }
161
+ /**
162
+ * The waivers this route carries (§5.5).
163
+ *
164
+ * Every one of these is a **command that exists** with no service operation
165
+ * behind it, which is why it is a written waiver rather than an absence: the
166
+ * command line genuinely offers `snapshot`, `doctor`, `login` and `init`, and
167
+ * none of them is a §3 operation to be at parity about.
168
+ *
169
+ * They do not name §3 operations, so nothing here is exempting this route
170
+ * from an operation it should offer — the waiver rule in the runner refuses
171
+ * that, and this list is deliberately empty of operation names for that
172
+ * reason. It is kept as prose the reviewer can read rather than being
173
+ * discarded, because "quietly absent from the matrix" is the failure
174
+ * `MILESTONES.md` names.
175
+ */
176
+ export const CLI_COMMAND_WAIVERS = [
177
+ {
178
+ command: 'snapshot',
179
+ reason: 'Writes the operations document and exits. It performs no service operation and refuses nothing, so it has nothing to be at parity with.',
180
+ },
181
+ {
182
+ command: 'doctor',
183
+ reason: 'Reports preconditions and exits with a distinct code. It is a readiness check rather than an operation, and refuses no caller.',
184
+ },
185
+ {
186
+ command: 'login',
187
+ reason: 'Hands a browser to a person to sign in to. A person drives it, no lease is granted, and the tool surface deliberately has no equivalent.',
188
+ },
189
+ {
190
+ command: 'init',
191
+ reason: 'Shows the setup handshake report. Every spawn performs the handshake anyway, so the command causes no effect of its own.',
192
+ },
193
+ ];
194
+ /**
195
+ * Operations this route does not offer, with the reason each is absent.
196
+ *
197
+ * **Empty, and that is the claim.** §5.3: every §3 operation has a command,
198
+ * so parity is real rather than claimed. The array exists so that a later row
199
+ * removing a command has somewhere to write down why — and so the runner's
200
+ * waiver rule has something to check rather than an absence to interpret.
201
+ */
202
+ export const CLI_OPERATION_WAIVERS = [];
203
+ /** Every operation this route offers — all ten, from the command table. */
204
+ export const CLI_OPERATIONS = OPERATION_COMMANDS.map((command) => command.operation);
205
+ /**
206
+ * The command-line adapter.
207
+ *
208
+ * `readOnly` is false because this route performs writes, and that
209
+ * declaration is what makes the waiver rule bite: a route exposing a write
210
+ * operation may not waive an operation any rule can refuse
211
+ * (`MILESTONES.md`). Declaring it read-only to buy waivers would be the
212
+ * loophole, so the declaration is made honestly and the waiver list is empty.
213
+ */
214
+ export const cliAdapter = {
215
+ id: 'cli',
216
+ description: 'The command line. In process, because there is nothing else for a command to talk to.',
217
+ readOnly: false,
218
+ operations: CLI_OPERATIONS,
219
+ waivers: CLI_OPERATION_WAIVERS,
220
+ invoke: async (service, operation, input) => {
221
+ // The route's own vocabulary is an argument vector. Anything else is a
222
+ // caller reaching past the transport, so it is refused rather than
223
+ // coerced — a shell only ever produces an array of strings.
224
+ if (!Array.isArray(input) || input.some((word) => typeof word !== 'string')) {
225
+ throw new TypeError('the command-line adapter takes an argument vector');
226
+ }
227
+ return service.perform({
228
+ operation,
229
+ adapter: 'cli',
230
+ arguments: parseArguments(input),
231
+ });
232
+ },
233
+ };