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,229 @@
1
+ /**
2
+ * Turning things a caller supplies into things that are safe to be a file
3
+ * name — and nothing else. No filesystem call happens in this file.
4
+ *
5
+ * ── Why the rules are strict here rather than at the point of writing ───
6
+ *
7
+ * `SCHEMA.md` §1.7a states the reasoning and it is the reason this module is
8
+ * separate from the one that writes files: **a file name travels further
9
+ * than a database column does.** A column is read by things that were
10
+ * written to read it. A name ends up in log lines, in terminal output
11
+ * somebody screenshots, in error messages, in a shell history, in the title
12
+ * bar of whatever opened the image. It leaks by default, and it leaks to
13
+ * places nobody enumerated.
14
+ *
15
+ * Four rules follow from that, and §1.7a puts them in an order that matters:
16
+ *
17
+ * 1. **Query strings are stripped entirely, before anything else.** A query
18
+ * string is where identifiers, tokens, search terms and session material
19
+ * live — the part of an address most likely to carry something that should
20
+ * never have been written down, and the part least likely to help anybody
21
+ * tell two files apart. It is stripped first so that no later rule can be
22
+ * the thing that happened to remove it: a collapse rule that turned `?` and
23
+ * `=` into hyphens would leave the token in the name, spelled differently.
24
+ * 2. **Safe characters only**, so the name is the same on every filesystem and
25
+ * survives being pasted anywhere.
26
+ * 3. **Truncated to a bounded length**, so a deep path does not produce a name
27
+ * that is unusable or unprintable. What truncation loses is recoverable
28
+ * from `captures.url` (§1.7); nothing depends on the name being complete.
29
+ * 4. **Never interpreted as a path.** The derivation produces **one path
30
+ * segment**, and the separators that would make it more than one are not in
31
+ * the safe set.
32
+ *
33
+ * **A label a caller supplies is subject to rules two, three and four as
34
+ * well** (§1.7a). It is a label, not a location.
35
+ *
36
+ * ── What this module structurally guarantees, and what it does not ──────
37
+ *
38
+ * **Structural.** Everything returned by {@link slugFromUrl} and
39
+ * {@link sanitiseLabel} is drawn from {@link SAFE_CHARACTERS} — the functions
40
+ * build their output by testing each character against that set rather than by
41
+ * removing a list of bad ones, so a character nobody thought of is excluded by
42
+ * default rather than included by default. Neither separator, `.` and `:` are
43
+ * outside the set, so no return value can be a traversal, an absolute path, a
44
+ * drive-qualified path, or more than one segment.
45
+ *
46
+ * **Not structural, and worth saying rather than implying.** Nothing here can
47
+ * stop a caller putting a secret in the *path* of an address, and the path is
48
+ * kept: only the query string is dropped. §1.7a's rule is about where secrets
49
+ * overwhelmingly live, not a claim that an address is safe once it is stripped.
50
+ * A service that wanted that guarantee would have to drop the address
51
+ * entirely, which would defeat what the slug is for.
52
+ */
53
+ /**
54
+ * The characters a derived name may contain. Lower-case letters, digits, and
55
+ * the hyphen — nothing else, and in particular neither separator, no dot, no
56
+ * colon and no tilde.
57
+ *
58
+ * An allowlist rather than a denylist, because a denylist has to be right
59
+ * about every filesystem's rules on every platform forever, and this has to be
60
+ * right once.
61
+ */
62
+ const SAFE_CHARACTERS = /^[a-z0-9-]$/;
63
+ /**
64
+ * How long a derived part may be.
65
+ *
66
+ * A bound rather than a limit anybody has to remember: §1.7a asks for
67
+ * truncation so a deep path cannot produce an unprintable name, and the
68
+ * number matters less than there being one. Chosen so that the five parts
69
+ * §1.7a's shape assembles — slug, label, width, when, identifier — stay well
70
+ * inside the shortest path-component limit in common use.
71
+ */
72
+ export const MAXIMUM_PART_LENGTH = 48;
73
+ /** What a name is when every character in it was unsafe. */
74
+ const EMPTY_PART = 'unnamed';
75
+ /**
76
+ * Reduce a string to the safe set, collapse runs, and truncate.
77
+ *
78
+ * **Runs collapse to one hyphen and the ends are trimmed**, so the result
79
+ * never begins or ends with a separator-looking character and never contains a
80
+ * run of them. That is presentation rather than safety — the safety is that
81
+ * nothing outside {@link SAFE_CHARACTERS} survives at all — but a name of
82
+ * forty hyphens is a name that tells a reader nothing, and truncating it would
83
+ * spend the whole budget on nothing.
84
+ *
85
+ * **Never empty.** A string that reduces to nothing returns {@link EMPTY_PART}
86
+ * rather than an empty string, because an empty part would collapse the shape
87
+ * §1.7a describes into one with a doubled separator and make two different
88
+ * captures produce the same name.
89
+ */
90
+ function reduce(value) {
91
+ const characters = [];
92
+ for (const character of value.toLowerCase()) {
93
+ if (SAFE_CHARACTERS.test(character)) {
94
+ characters.push(character);
95
+ continue;
96
+ }
97
+ // Anything outside the set becomes a separator rather than disappearing,
98
+ // so two distinguishable inputs do not collapse into one name.
99
+ if (characters.length > 0 && characters[characters.length - 1] !== '-') {
100
+ characters.push('-');
101
+ }
102
+ }
103
+ const collapsed = characters.join('').replace(/^-+|-+$/g, '');
104
+ if (collapsed === '')
105
+ return EMPTY_PART;
106
+ return collapsed.slice(0, MAXIMUM_PART_LENGTH).replace(/-+$/g, '') || EMPTY_PART;
107
+ }
108
+ /**
109
+ * Strip a query string, and a fragment, from an address — textually, before
110
+ * anything parses it.
111
+ *
112
+ * **Textual and first, on purpose.** Doing it by parsing would mean handing
113
+ * the whole address including its query to a parser, and the value of the rule
114
+ * is that the query is gone before anything else touches it. `?` opens a query
115
+ * and `#` opens a fragment; whichever comes first ends the part that is kept.
116
+ *
117
+ * A fragment is stripped alongside the query although §1.7a names only the
118
+ * query. It is the same class of material — client-side state a caller chose,
119
+ * frequently carrying identifiers — and keeping it would put it in a file
120
+ * name for no benefit, since it never distinguishes two pages the server
121
+ * served.
122
+ */
123
+ function withoutQuery(address) {
124
+ const end = Math.min(...['?', '#'].map((mark) => {
125
+ const at = address.indexOf(mark);
126
+ return at === -1 ? address.length : at;
127
+ }));
128
+ return address.slice(0, end);
129
+ }
130
+ /**
131
+ * The page slug: host and path, query stripped first, reduced to one safe
132
+ * segment.
133
+ *
134
+ * **Derived, never supplied** (§1.7a). A caller's own label describes what it
135
+ * was *doing*; the address describes what it was *looking at*, and only the
136
+ * second is reliably distinct between two pieces of work that happen to be
137
+ * described the same way.
138
+ *
139
+ * **An address this cannot parse still produces a name.** A capture is never
140
+ * refused for the sake of its file name — the whole shape of §3.11 is that the
141
+ * only refusals are argument mistakes — so an unparseable address falls back to
142
+ * reducing the stripped text directly. It is a worse name and it is still a
143
+ * name.
144
+ *
145
+ * ── TWO INDEPENDENT MECHANISMS KEEP THE QUERY OUT, and saying so matters ──
146
+ *
147
+ * Worth stating exactly, because a reader who assumes only one is at work will
148
+ * draw the wrong conclusion from a change to either:
149
+ *
150
+ * 1. **On the parseable path, taking `hostname` and `pathname` excludes the
151
+ * query by construction.** Neither property contains it, so even handed a
152
+ * full address the parser would not put a query in the name.
153
+ * 2. **{@link withoutQuery} strips it textually first**, which is what covers
154
+ * the **fallback path** — where there is no parser to be structural about
155
+ * it, and where the whole address would otherwise be reduced verbatim.
156
+ *
157
+ * So the strip is not redundant: it is the *only* mechanism on the branch that
158
+ * handles an address this cannot parse, and that is exactly the branch where an
159
+ * odd address ends up. Removing it leaks a token from any unparseable address.
160
+ * Belt and braces on one path, sole defence on the other.
161
+ */
162
+ export function slugFromUrl(address) {
163
+ if (address === undefined || address.trim() === '')
164
+ return EMPTY_PART;
165
+ const stripped = withoutQuery(address);
166
+ try {
167
+ const parsed = new URL(stripped);
168
+ // Host and path only. Not the scheme, not any credentials the address
169
+ // carries in front of the host, and not the port — none of which help
170
+ // anybody tell two pictures apart, and the first of which is the single
171
+ // most sensitive thing an address can hold.
172
+ return reduce(`${parsed.hostname}${parsed.pathname}`);
173
+ }
174
+ catch {
175
+ return reduce(stripped);
176
+ }
177
+ }
178
+ /**
179
+ * A caller's own label, made safe.
180
+ *
181
+ * Rules two, three and four of §1.7a apply here as they do to the slug: safe
182
+ * characters, bounded, and never a path. Rule one does not, because a label is
183
+ * not an address and has no query string to strip — but note that a label
184
+ * containing `?` is reduced by the same safe-character pass, so a caller that
185
+ * pasted an address into the label field does not smuggle one through.
186
+ */
187
+ export function sanitiseLabel(label) {
188
+ if (label === undefined || label.trim() === '')
189
+ return EMPTY_PART;
190
+ return reduce(label);
191
+ }
192
+ /**
193
+ * A sortable stamp for the moment a capture was taken.
194
+ *
195
+ * Digits only, most significant first, so a directory listing sorts into time
196
+ * order within a page and a view — which is the fourth of the five sorting
197
+ * levels §1.7a's shape is chosen for. Derived from the instant's own text
198
+ * rather than formatted by hand so that it is the same on every platform.
199
+ */
200
+ export function stampFromInstant(when) {
201
+ return when
202
+ .toISOString()
203
+ .replace(/[^0-9]/g, '')
204
+ .slice(0, 14);
205
+ }
206
+ /**
207
+ * Assemble the five parts §1.7a specifies, in the order it specifies them:
208
+ * `<page-slug>-<view-label>-<width>-<when>-<id>.png`.
209
+ *
210
+ * That order is what makes a directory listing readable: all the pictures of
211
+ * one page together, then within a page all the pictures of one view, then the
212
+ * widths, then the sequence in time, then an identifier that guarantees
213
+ * uniqueness without anybody coordinating.
214
+ *
215
+ * **Every variable part goes through the rules above**, including the
216
+ * identifier — which is generated rather than supplied and would pass anyway,
217
+ * but passing it through means there is no part of this name that a future
218
+ * change could make caller-controlled without also making it safe.
219
+ */
220
+ export function captureFileName(parts) {
221
+ const width = reduce(String(Math.trunc(parts.viewportWidth)));
222
+ return ([
223
+ slugFromUrl(parts.url),
224
+ sanitiseLabel(parts.label),
225
+ width,
226
+ stampFromInstant(parts.takenAt),
227
+ reduce(parts.id),
228
+ ].join('-') + '.png');
229
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { BrokerError } from "../errors.js";
4
+ /**
5
+ * Every kind, in the order §1.7a lists them.
6
+ *
7
+ * **Named entries, not a derived list.** A test that walks this array and
8
+ * asserts each directory exists is a test that goes green when an entry is
9
+ * deleted, because it would then be walking a shorter list. So the test that
10
+ * matters names each folder outright and this constant is for code that
11
+ * genuinely needs to iterate.
12
+ */
13
+ export const ARTIFACT_KINDS = [
14
+ 'images',
15
+ 'snapshots',
16
+ 'console',
17
+ 'network',
18
+ 'downloads',
19
+ ];
20
+ /**
21
+ * A path segment that identifies a lease, made safe.
22
+ *
23
+ * A claim identifier is generated by this service and is already safe, so this
24
+ * looks redundant — and it is not, for the reason rule two above gives: the
25
+ * value arriving here is the one thing in the assembled path that came from
26
+ * outside this module, and a check that only holds while an upstream promise
27
+ * holds is a check that fails silently the day the promise changes.
28
+ */
29
+ function safeSegment(value, what) {
30
+ const reduced = value.toLowerCase().replace(/[^a-z0-9-]/g, '');
31
+ if (reduced === '' || reduced !== value.toLowerCase()) {
32
+ throw new BrokerError('artifact.no_request_path', `${what} is not usable as a directory name. Expected letters, digits and hyphens; found ${JSON.stringify(value)}.`);
33
+ }
34
+ return reduced;
35
+ }
36
+ /**
37
+ * Is this path absolute in **either** namespace?
38
+ *
39
+ * ── Why the platform's own answer is the wrong one ──────────────────────
40
+ *
41
+ * `path.isAbsolute` asks the namespace the host process happens to be running
42
+ * in, and the two namespaces disagree about what an absolute path even is. On
43
+ * a platform whose separator is the forward slash, `C:` followed by a
44
+ * backslash is not a root — it is a perfectly legal **relative filename that
45
+ * contains backslashes**, so the refusal never fires and the path is joined
46
+ * onto the root as one long oddly-named file. The same string refuses on the
47
+ * other platform. Identical input, opposite outcome, from the same code.
48
+ *
49
+ * That is not a hypothetical, and this codebase already answers it in one
50
+ * place: `store/network-path.ts` reads its share root with
51
+ * {@link path.win32.parse} **unconditionally**, on every platform, for
52
+ * exactly this reason. This follows that approach rather than inventing a
53
+ * second one, so both guards recognise both spellings the same way.
54
+ *
55
+ * ── Both checks, and honesty about what the second one adds ─────────────
56
+ *
57
+ * **The two are not independently load-bearing, and pretending otherwise
58
+ * would mislead the next reader.** The drive-letter parser also reports a
59
+ * root for a leading forward slash, so deleting the first check changes no
60
+ * outcome any test observes — deleting the *second* is what turns the guard
61
+ * off for a drive-qualified name and a share.
62
+ *
63
+ * The first check stays because this rule is "absolute in either namespace"
64
+ * and one line per namespace is how that reads as code. Leaning on one
65
+ * parser's incidental coverage of the other's case would make the rule
66
+ * correct by coincidence rather than by statement.
67
+ *
68
+ * A guard that only fires on the platform it was written on is worse than no
69
+ * guard, because it reports a protection that does not exist.
70
+ */
71
+ function isAbsoluteInEitherNamespace(target) {
72
+ // POSIX: a leading forward slash. Named for its own sake — see above.
73
+ if (path.posix.isAbsolute(target)) {
74
+ return true;
75
+ }
76
+ // The other namespace: a drive letter followed by a colon and a separator,
77
+ // in either separator spelling; a two-separator share prefix naming a host
78
+ // and a share; and a bare leading backslash. Reading the parsed root covers
79
+ // every one of those spellings without this file having to enumerate them
80
+ // as patterns of its own.
81
+ return path.win32.parse(target).root !== '';
82
+ }
83
+ export class ArtifactStore {
84
+ #root;
85
+ /**
86
+ * @param root `BROKER_ARTIFACTS_ROOT`, already resolved by the environment
87
+ * snapshot. Taken as a string rather than read from the environment here,
88
+ * so that this class has no configuration of its own and a test does not
89
+ * have to arrange a process environment to use one.
90
+ */
91
+ constructor(root) {
92
+ this.#root = path.resolve(root);
93
+ }
94
+ /** Where the tree is rooted. Read by nothing that stores it. */
95
+ get root() {
96
+ return this.#root;
97
+ }
98
+ /**
99
+ * The directory for one lease's artefacts of one kind, created if absent.
100
+ *
101
+ * Created lazily rather than at claim time: a lease that takes no captures
102
+ * should not leave an empty tree behind, and there is no moment in a
103
+ * daemonless service at which a setup step could have run instead.
104
+ */
105
+ directoryFor(claimId, kind) {
106
+ const directory = path.join(this.#root, 'claims', safeSegment(claimId, 'A claim identifier'), kind);
107
+ fs.mkdirSync(directory, { recursive: true });
108
+ return directory;
109
+ }
110
+ /**
111
+ * Write one file into a lease's tree, and report where it went **relative to
112
+ * the root**.
113
+ *
114
+ * The refusal below is the second of this file's two rules, and it asks two
115
+ * questions rather than one. It compares the resolved destination against
116
+ * the resolved root, which catches a name that climbs out through `..`; and
117
+ * it asks {@link isAbsoluteInEitherNamespace} whether the supplied name was
118
+ * absolute in **either** namespace, which is what catches the spellings the
119
+ * host platform does not read as roots. Both are needed — see that
120
+ * function for why one platform's answer is not the answer.
121
+ */
122
+ write(claimId, kind, fileName, contents) {
123
+ const directory = this.directoryFor(claimId, kind);
124
+ const absolutePath = path.resolve(directory, fileName);
125
+ const relative = path.relative(this.#root, absolutePath);
126
+ // `..` at the front means the destination climbed out of the root; an
127
+ // absolute answer means it was never under it at all. Either is a bug in
128
+ // whatever composed the name, and neither may be written.
129
+ //
130
+ // **The supplied name is tested as well as the computed result**, and that
131
+ // is not belt and braces — it is the only check that catches a name
132
+ // absolute in the *other* namespace. Such a name is a legal relative
133
+ // filename to `path.resolve` here, so it resolves quietly under the root
134
+ // and the computed answer is relative and clean. Nothing downstream can
135
+ // notice; the name itself has to be asked.
136
+ if (relative.startsWith('..') ||
137
+ isAbsoluteInEitherNamespace(relative) ||
138
+ isAbsoluteInEitherNamespace(fileName)) {
139
+ throw new BrokerError('artifact.no_request_path', `A capture would have been written outside the artifact root. Refusing: ${JSON.stringify(fileName)} does not resolve under it.`);
140
+ }
141
+ fs.writeFileSync(absolutePath, contents);
142
+ return {
143
+ // Stored with forward slashes whatever platform wrote it, so a row
144
+ // written on one machine reads on another. A separator is presentation;
145
+ // making it part of the stored value would reintroduce, one layer down,
146
+ // exactly the machine-specificity rule one exists to remove.
147
+ relativePath: relative.split(path.sep).join('/'),
148
+ absolutePath,
149
+ bytes: contents.byteLength,
150
+ };
151
+ }
152
+ /**
153
+ * Turn a recorded relative path back into a location on this machine.
154
+ *
155
+ * **This is the only way bytes are ever served** (§1.9, `artifact.no_request_path`):
156
+ * a recorded path resolves under the root or nothing is served, so a caller
157
+ * never supplies a filesystem path and traversal has no input to arrive
158
+ * through. The refusal is the same one `write` makes, from the other
159
+ * direction, and it is deliberately duplicated rather than shared through a
160
+ * flag — the two callers are asking different questions and a shared
161
+ * parameterised helper would be one edit away from answering only one of
162
+ * them.
163
+ */
164
+ resolve(relativePath) {
165
+ const absolutePath = path.resolve(this.#root, relativePath);
166
+ const relative = path.relative(this.#root, absolutePath);
167
+ if (relative.startsWith('..') ||
168
+ isAbsoluteInEitherNamespace(relative) ||
169
+ isAbsoluteInEitherNamespace(relativePath)) {
170
+ throw new BrokerError('artifact.no_request_path', `A recorded path does not resolve under the artifact root. Refusing to serve ${JSON.stringify(relativePath)}.`);
171
+ }
172
+ return absolutePath;
173
+ }
174
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { serviceUnavailable } from "../cli/index.js";
3
+ import { BrokerError } from "../errors.js";
4
+ import { createRuntime } from "../service/runtime.js";
5
+ import { linesFrom, serveSession } from "../tool/session.js";
6
+ /**
7
+ * The executable shim for the tool surface: **the process a caller spawns.**
8
+ *
9
+ * `MILESTONES.md` #27: the service is spawned by its caller, serves that
10
+ * session, and exits with it. There is no port to bind, no daemon to start
11
+ * and nothing to leave running — this file reads standard input until it
12
+ * ends, and then the process has nothing left to do.
13
+ *
14
+ * ── Why the loop is not in this file ────────────────────────────────────
15
+ *
16
+ * Everything above the streams lives in `session.ts` and is importable, so
17
+ * the parity matrix drives the real handler in process rather than by
18
+ * spawning something per case. `MILESTONES.md` reserves spawning for the case
19
+ * where the process boundary is itself the thing under test — which is what
20
+ * this file's own smoke test does, and it is the only thing that does.
21
+ *
22
+ * ── The service it serves ───────────────────────────────────────────────
23
+ *
24
+ * The real one, built the same way the command line builds its own
25
+ * (`service/runtime.ts`), differing only in the adapter it records — §1.6
26
+ * keeps one row per decision and one column saying which door it came in
27
+ * through, and this door is `tool-stdio`.
28
+ *
29
+ * {@link serviceUnavailable} covers the one case where a service cannot be
30
+ * built at all: opening the store is what can fail, and it fails with a rule
31
+ * attached. Refusing every operation by name is the right answer there — a
32
+ * caller that spawned this gets an honest refusal and the whole wire path
33
+ * stays real — and it is reached only in that case, because a surface that
34
+ * refused everything unconditionally would be unusable rather than degraded.
35
+ *
36
+ * **Human text never goes to standard output**, because standard output is
37
+ * the protocol stream and one stray line would corrupt the framing for every
38
+ * message after it. The startup refusal below therefore goes to the error
39
+ * stream, and the session still serves — a caller mid-conversation gets
40
+ * refusals it can read rather than a process that vanished.
41
+ */
42
+ let runtime;
43
+ try {
44
+ runtime = await createRuntime({ adapter: 'tool-stdio' });
45
+ }
46
+ catch (error) {
47
+ if (!(error instanceof BrokerError)) {
48
+ throw error;
49
+ }
50
+ process.stderr.write(`refused (${error.rule}): ${error.message}\n`);
51
+ }
52
+ try {
53
+ await serveSession(linesFrom(process.stdin), {
54
+ service: runtime?.service ?? serviceUnavailable(),
55
+ streams: {
56
+ write: (line) => process.stdout.write(`${line}\n`),
57
+ log: (line) => process.stderr.write(`${line}\n`),
58
+ },
59
+ });
60
+ }
61
+ finally {
62
+ runtime?.close();
63
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../cli/index.js";
3
+ import { createRuntime } from "../service/runtime.js";
4
+ import { BrokerError } from "../errors.js";
5
+ /**
6
+ * The executable entry point. Argument vector in, exit code out.
7
+ *
8
+ * ── What this file adds, and why it is the only thing it adds ───────────
9
+ *
10
+ * The dispatcher stays importable and stays free of any decision, so the
11
+ * command line can be driven in process (`MILESTONES.md`). What it does not
12
+ * do is build itself a service — `RunOptions.service` is injected precisely
13
+ * so that a conformance driver can hand it one, and a dispatcher that
14
+ * constructed its own could only be tested by spawning.
15
+ *
16
+ * That leaves somebody to build the real one, and this is that somebody: the
17
+ * shipped executable, which is the only caller whose service should be the
18
+ * real one by default.
19
+ *
20
+ * ── The fallback is kept, and narrowed to what it was for ───────────────
21
+ *
22
+ * `serviceUnavailable()` remains the answer when a service genuinely cannot
23
+ * be built, and it is now reached by the path that name describes rather than
24
+ * by every path. Building one means opening a store, and a store can
25
+ * legitimately refuse to open — a location on a network filesystem, a
26
+ * malformed configuration variable, a directory nothing may write to. Those
27
+ * refusals are the service *declining*, and they already have their own
28
+ * sentences naming the rule that refused, so they are reported as themselves
29
+ * rather than flattened into "not built".
30
+ */
31
+ const argv = process.argv.slice(2);
32
+ /**
33
+ * Commands that must answer without a service, and therefore without a store.
34
+ *
35
+ * `doctor` is the one that matters most and the reason this list is not
36
+ * empty: a store that does not exist yet is the state it is most useful in,
37
+ * and `cli/index.ts` goes to some length not to create one while answering. A
38
+ * binary that built a runtime first would create the store *before* the
39
+ * command ran and hand `doctor` a fault it had itself caused — the exact
40
+ * failure that file's comment describes, reintroduced one layer up.
41
+ *
42
+ * **The bare spawn is here for a subtler reason, and it is the one this list
43
+ * exists to get right.** With no arguments the dispatcher opens the store,
44
+ * steps it and reports what stepping did — that report is the whole of what
45
+ * the command does. Building a runtime first would step the schema, so by the
46
+ * time the command ran there would be nothing left to step, and a spawn
47
+ * against a store that did not exist a moment ago would truthfully say
48
+ * "already at version 5, nothing to do". The command would still be honest;
49
+ * it would simply have been robbed of the thing it reports. Stepping is done
50
+ * once per spawn, by whoever is going to speak about it.
51
+ *
52
+ * `--help` and `--version` are here for the ordinary reason: neither reads
53
+ * anything, and opening a database to print a version string would make the
54
+ * two commands most likely to be run on a broken installation the two most
55
+ * likely to fail on one.
56
+ */
57
+ function needsNoService(words) {
58
+ if (words.includes('--help') || words.includes('-h'))
59
+ return true;
60
+ if (words.includes('--version') || words.includes('-v'))
61
+ return true;
62
+ // Only `--json` can accompany a bare spawn; anything else is a command.
63
+ if (words.every((word) => word.startsWith('-')))
64
+ return true;
65
+ return words[0] === 'doctor';
66
+ }
67
+ if (needsNoService(argv)) {
68
+ process.exitCode = await run(argv);
69
+ }
70
+ else {
71
+ let runtime;
72
+ try {
73
+ runtime = await createRuntime({ adapter: 'cli' });
74
+ }
75
+ catch (error) {
76
+ if (error instanceof BrokerError) {
77
+ // The store declined to open. Reported with the rule that refused it,
78
+ // in the same shape every other refusal takes, rather than as a stack
79
+ // trace or as a claim that the service was never built.
80
+ process.stderr.write(`refused (${error.rule}): ${error.message}\n`);
81
+ process.exitCode = 4;
82
+ }
83
+ else {
84
+ throw error;
85
+ }
86
+ }
87
+ if (runtime !== undefined) {
88
+ try {
89
+ process.exitCode = await run(argv, {
90
+ service: runtime.service,
91
+ // `broker login` is performed by a person and is not one of the ten
92
+ // operations the flat seam carries, so it reaches the service through
93
+ // the typed interface. Supplied here rather than constructed in the
94
+ // dispatcher for the reason `RunOptions.service` gives: a dispatcher
95
+ // that built its own service could only be tested by spawning.
96
+ broker: runtime.broker,
97
+ store: runtime.store,
98
+ environment: runtime.environment,
99
+ // `broker reconcile` asks a live browser what it has open (§2.6,
100
+ // §4.3). It is the runtime's own provider rather than one built in
101
+ // the dispatcher, so this process decides adoption once — and it is
102
+ // passed here, from the shipped executable, because a command reached
103
+ // only by a test is a command that does not exist.
104
+ session: runtime.session,
105
+ });
106
+ }
107
+ finally {
108
+ runtime.close();
109
+ }
110
+ }
111
+ }