unitbob 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/proxyHint.js +133 -0
- package/dist/runner/provision.js +109 -8
- package/dist/wire.js +45 -6
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// What to say when a wire call fails on a machine that reaches the network
|
|
2
|
+
// through a proxy.
|
|
3
|
+
//
|
|
4
|
+
// a2time, 2026-08-17. The Unitbob host was added to the sandbox's allowlist,
|
|
5
|
+
// `curl` started answering 200, and the connector went on failing with 403 for
|
|
6
|
+
// another round of debugging. Both facts were true at once: `curl` reads
|
|
7
|
+
// `HTTPS_PROXY` from the environment, and Node's `fetch` does not, so the two
|
|
8
|
+
// were not talking to the same place. Nothing in the failure said so — the
|
|
9
|
+
// connector printed the status it got and left the reader to guess that the
|
|
10
|
+
// variable sitting in their own environment was being ignored.
|
|
11
|
+
//
|
|
12
|
+
// This module is one sentence, and deliberately not a fix. Routing our requests
|
|
13
|
+
// through the proxy ourselves would mean either a runtime dependency (this
|
|
14
|
+
// package has none, on purpose — `npx unitbob` installs in a second) or
|
|
15
|
+
// re-spawning ourselves with the variable set, which works on some Node versions
|
|
16
|
+
// and silently does not on others. Naming the cause costs nothing and cannot
|
|
17
|
+
// break a working machine.
|
|
18
|
+
//
|
|
19
|
+
// What it *can* do is send somebody to break one, which is why so much of this
|
|
20
|
+
// file is about staying quiet. Measured on Node 25.2.1 against a CONNECT-logging
|
|
21
|
+
// proxy: with `NODE_USE_ENV_PROXY=1` set, Node tunnels `http://127.0.0.1:19999`
|
|
22
|
+
// through the proxy too — it does not exempt loopback. So this sentence, said to
|
|
23
|
+
// somebody whose brain runs on `http://localhost:3000` and is merely down, would
|
|
24
|
+
// talk them into breaking the one setup that works.
|
|
25
|
+
// The variables that mean "this machine has a proxy", in the order they answer
|
|
26
|
+
// for an `https://` server. The lowercase spellings are not a nicety: on Unix
|
|
27
|
+
// they are the older convention and plenty of environments still set only those.
|
|
28
|
+
const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'];
|
|
29
|
+
// The first Node that can be told to use the environment's proxy for `fetch`.
|
|
30
|
+
// Below it the variable is inert, so naming it would be a second round of the
|
|
31
|
+
// exact confusion this file exists to end. Held at major-version granularity on
|
|
32
|
+
// purpose: 18 and 20 plainly have nothing, 22 has it (a2time ran 22.22.3 and the
|
|
33
|
+
// variable cured it), and pinning a patch number this file cannot verify would
|
|
34
|
+
// be a guess dressed as a fact.
|
|
35
|
+
const FIRST_NODE_WITH_ENV_PROXY = 22;
|
|
36
|
+
// The sentence to add to a message that has already failed, or null when there
|
|
37
|
+
// is nothing worth saying: no proxy configured, one Node is already using, or a
|
|
38
|
+
// target that would not go through it anyway.
|
|
39
|
+
//
|
|
40
|
+
// `target` is the server the failed call was aimed at. It is required rather
|
|
41
|
+
// than optional because every rule below that keeps this quiet needs it, and an
|
|
42
|
+
// optional argument here would be a hint that is silently louder at half its
|
|
43
|
+
// call sites.
|
|
44
|
+
export function proxyHint(target, options = {}) {
|
|
45
|
+
const env = options.env ?? process.env;
|
|
46
|
+
const nodeVersion = options.nodeVersion ?? process.version;
|
|
47
|
+
if (nodeAlreadyUsesTheProxy(env))
|
|
48
|
+
return null;
|
|
49
|
+
const found = PROXY_VARS.map((name) => ({ name, value: (env[name] ?? '').trim() })).find((candidate) => candidate.value.length > 0);
|
|
50
|
+
if (!found)
|
|
51
|
+
return null;
|
|
52
|
+
if (!goesThroughTheProxy(target, env))
|
|
53
|
+
return null;
|
|
54
|
+
const shown = withoutCredentials(found.value);
|
|
55
|
+
const where = shown ? `\`${found.name}=${shown}\`` : `\`${found.name}\``;
|
|
56
|
+
const cause = `This machine reaches the network through a proxy (${where}), and Node does not send its own ` +
|
|
57
|
+
'requests through it unless it is told to — which is why `curl` can reach a host that this command cannot.';
|
|
58
|
+
return majorVersion(nodeVersion) < FIRST_NODE_WITH_ENV_PROXY
|
|
59
|
+
? `${cause} This Node (${nodeVersion}) has no way to be told: run the command under Node ` +
|
|
60
|
+
`${FIRST_NODE_WITH_ENV_PROXY} or newer.`
|
|
61
|
+
: `${cause} Re-run the same command with \`NODE_USE_ENV_PROXY=1\` in front of it.`;
|
|
62
|
+
}
|
|
63
|
+
// Whether Node is already routing `fetch` through the environment's proxy, in
|
|
64
|
+
// either of the two ways it can be asked to. The words that mean "off" are
|
|
65
|
+
// spelled out rather than "anything but 0", so that `NODE_USE_ENV_PROXY=false`
|
|
66
|
+
// does not quietly buy silence from a machine that needs the sentence.
|
|
67
|
+
function nodeAlreadyUsesTheProxy(env) {
|
|
68
|
+
const flag = (env.NODE_USE_ENV_PROXY ?? '').trim().toLowerCase();
|
|
69
|
+
if (flag.length > 0 && flag !== '0' && flag !== 'false' && flag !== 'no')
|
|
70
|
+
return true;
|
|
71
|
+
return (env.NODE_OPTIONS ?? '').includes('--use-env-proxy');
|
|
72
|
+
}
|
|
73
|
+
// Whether this target would travel through the proxy at all — the two exemptions
|
|
74
|
+
// Node itself applies, checked here so the advice matches what setting the
|
|
75
|
+
// variable would actually do.
|
|
76
|
+
//
|
|
77
|
+
// `NO_PROXY` is matched the way every tool that reads it matches: `*` exempts
|
|
78
|
+
// everything, and an entry is a host or a domain suffix, with an optional
|
|
79
|
+
// leading dot that means the same thing.
|
|
80
|
+
function goesThroughTheProxy(target, env) {
|
|
81
|
+
let host;
|
|
82
|
+
try {
|
|
83
|
+
host = new URL(target).hostname.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A server we cannot parse is one we cannot reason about. Silence is the
|
|
87
|
+
// safe half of that, since the message it would join is already printed.
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// Measured, not assumed: Node tunnels loopback through the proxy like anything
|
|
91
|
+
// else. Nobody's proxy has a route back to their own machine, so this is the
|
|
92
|
+
// one place where following the advice makes a working setup fail.
|
|
93
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]' || /^127\./.test(host))
|
|
94
|
+
return false;
|
|
95
|
+
const noProxy = (env.NO_PROXY ?? env.no_proxy ?? '').trim();
|
|
96
|
+
if (noProxy === '*')
|
|
97
|
+
return false;
|
|
98
|
+
return !noProxy
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((entry) => entry.trim().toLowerCase().replace(/^\./, ''))
|
|
101
|
+
.filter((entry) => entry.length > 0)
|
|
102
|
+
.some((entry) => host === entry || host.endsWith(`.${entry}`));
|
|
103
|
+
}
|
|
104
|
+
// The major of a `v25.2.1`, or 0 when it is not a version string we can read —
|
|
105
|
+
// which reports "too old" and sends nobody to set a variable that may be inert.
|
|
106
|
+
function majorVersion(version) {
|
|
107
|
+
const major = /^v?(\d+)\./.exec(version.trim());
|
|
108
|
+
return major ? Number(major[1]) : 0;
|
|
109
|
+
}
|
|
110
|
+
// The proxy as it is safe to print: the address, never the credentials.
|
|
111
|
+
//
|
|
112
|
+
// A proxy URL is one of the last places a password is still written in plain
|
|
113
|
+
// text, and this sentence ends up in terminals, transcripts and bug reports. The
|
|
114
|
+
// address is the part that helps somebody recognise which proxy this is; the
|
|
115
|
+
// credentials never are.
|
|
116
|
+
//
|
|
117
|
+
// Null when the value does not parse as a URL. Then it still means "this machine
|
|
118
|
+
// has a proxy" — worth saying — but nothing in it can be echoed, because a
|
|
119
|
+
// password cannot be found in a string whose shape we could not read.
|
|
120
|
+
function withoutCredentials(value) {
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(value);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (url.username.length === 0 && url.password.length === 0)
|
|
129
|
+
return value;
|
|
130
|
+
url.username = '';
|
|
131
|
+
url.password = '';
|
|
132
|
+
return url.toString();
|
|
133
|
+
}
|
package/dist/runner/provision.js
CHANGED
|
@@ -295,6 +295,92 @@ async function provisionVitest(projectRoot, deps) {
|
|
|
295
295
|
// project's own bundler settings are never touched, and no other bundler
|
|
296
296
|
// invocation carries this.
|
|
297
297
|
const UNFROZEN_SIDECAR = { BUNDLE_FROZEN: 'false', BUNDLE_DEPLOYMENT: 'false' };
|
|
298
|
+
// One gem line for the sidecar, asked for only if the project has not asked for
|
|
299
|
+
// it already.
|
|
300
|
+
//
|
|
301
|
+
// `eval_gemfile` runs the project's own Gemfile inside *this* Dsl object — that
|
|
302
|
+
// is the whole point of it, and it is also the trap. Every `gem` line we add
|
|
303
|
+
// afterwards lands in the same dependency list the project just filled, so a gem
|
|
304
|
+
// the project already names is declared twice, and bundler's rules for that are
|
|
305
|
+
// strict: identical requirements warn, differing ones raise `GemfileError` while
|
|
306
|
+
// the Gemfile is still being parsed.
|
|
307
|
+
//
|
|
308
|
+
// Measured on bundler 2.4.22 and 4.0.1 after A2.Time (Rails 5.0, Ruby 2.7.8)
|
|
309
|
+
// could not generate a behavioral suite at all, 2026-08-20. It pins
|
|
310
|
+
// `webmock "~> 3.23"`; we asked for `webmock (>= 0)`:
|
|
311
|
+
//
|
|
312
|
+
// You cannot specify the same gem twice with different version requirements.
|
|
313
|
+
// You specified: webmock (~> 3.23) and webmock (>= 0). Bundler cannot continue.
|
|
314
|
+
//
|
|
315
|
+
// Parsing fails before resolution begins, so there is no lock and no versions to
|
|
316
|
+
// negotiate — and `suite-prepare` rewrote the same conflicting file on every
|
|
317
|
+
// retry, which left the vibecoder with nothing to patch either. The comment that
|
|
318
|
+
// used to sit on the webmock line had this exactly backwards: it promised the
|
|
319
|
+
// project's own version would win "because bundler starts from the project's own
|
|
320
|
+
// resolution". True of resolution. Parsing never reached it.
|
|
321
|
+
//
|
|
322
|
+
// `dependencies` is Bundler::Dsl's own reader and the Gemfile is instance_eval'd
|
|
323
|
+
// on the Dsl, so the list is in scope and already holds everything the project
|
|
324
|
+
// declared. Checked in the dsl.rb of 2.1.4, 2.2.33, 2.3.27, 2.4.22 and 4.0.1 —
|
|
325
|
+
// 2.1.4 because it is what Ruby 2.7.8 ships, and Ruby 2.7.8 is what the
|
|
326
|
+
// application that found this bug runs.
|
|
327
|
+
//
|
|
328
|
+
// What we would have added is dropped rather than merged, version and all: a
|
|
329
|
+
// project pinning `cucumber "~> 8.0"` gets a sidecar on cucumber 8 instead of a
|
|
330
|
+
// hard failure. The suite then runs on the version that project already trusts,
|
|
331
|
+
// which is the bargain the rest of this sidecar strikes anyway — it inherits the
|
|
332
|
+
// project's Gemfile precisely so the two cannot drift apart.
|
|
333
|
+
//
|
|
334
|
+
// One thing this does give up, measured on 2.1.4 and 4.0.1 rather than assumed.
|
|
335
|
+
// Where the requirements happened to match, bundler used to keep *both*
|
|
336
|
+
// declarations — the project's and ours — so a gem the project had confined to
|
|
337
|
+
// `group :test` also arrived ungrouped through us, and no `BUNDLE_WITHOUT` could
|
|
338
|
+
// drop it. Skipping our line leaves only the project's, groups and all. That is
|
|
339
|
+
// the honest arrangement, and it is not silent: the World probe of spec 35-1
|
|
340
|
+
// asserts against a live `WebMock::NetConnectNotAllowedError`, so a webmock that
|
|
341
|
+
// did not come along stops `suite-prepare` with a fixable probe failure instead
|
|
342
|
+
// of letting a suite run with the block it advertises quietly missing.
|
|
343
|
+
function gemLineUnlessTheProjectHasIt(name, requirement) {
|
|
344
|
+
const pin = requirement ? `, "${requirement}"` : '';
|
|
345
|
+
return `gem "${name}"${pin}, require: false unless dependencies.any? { |d| d.name == "${name}" }\n`;
|
|
346
|
+
}
|
|
347
|
+
const BUNDLER_OUTPUT_LINES = 20;
|
|
348
|
+
const BUNDLER_OUTPUT_CHARS = 2000;
|
|
349
|
+
// What bundler said, kept instead of thrown away. Reads as the sentence after
|
|
350
|
+
// "Bundler failed to ...", whichever of its three shapes it takes.
|
|
351
|
+
//
|
|
352
|
+
// Both Ruby sidecars used to capture `result` and then return a fixed line, so a
|
|
353
|
+
// provisioning failure reached the vibecoder as "Bundler failed to provision ..."
|
|
354
|
+
// and nothing else. On A2.Time that hid the `GemfileError` above completely: the
|
|
355
|
+
// run reported a blocked behavioral branch, the reason was already in this
|
|
356
|
+
// process's memory, and it still took a round trip through the user — run bundler
|
|
357
|
+
// by hand, paste the output — to find out what it was. An error we have been told
|
|
358
|
+
// is not one to make somebody fetch again.
|
|
359
|
+
//
|
|
360
|
+
// The tail, because bundler puts the reason last on failures long enough to
|
|
361
|
+
// scroll (a resolution conflict prints its whole search first), and a Gemfile
|
|
362
|
+
// that will not parse is short enough that the tail is all of it. Not
|
|
363
|
+
// `installerComplaint`, which is next door and does the opposite on purpose: it
|
|
364
|
+
// picks the single line pip labelled an error out of hundreds of lines of
|
|
365
|
+
// compiler noise. Bundler's verdict carries no such label — the one that matters
|
|
366
|
+
// here opens with `[!]` and runs over three lines — so filtering by line would
|
|
367
|
+
// drop exactly the sentence worth keeping.
|
|
368
|
+
function whatBundlerSaid(result) {
|
|
369
|
+
const text = [result.stdout, result.stderr].map((part) => part.trim()).filter(Boolean).join('\n');
|
|
370
|
+
// A null code is a process this connector killed, not one that decided
|
|
371
|
+
// anything. No number is named with it: the two callers run under different
|
|
372
|
+
// budgets — `provisionRspec` asks for `DEPENDENCY_INSTALL_TIMEOUT_MS`,
|
|
373
|
+
// `provisionRuby` takes the `PROVISION_TIMEOUT_MS` default — and a message
|
|
374
|
+
// that states the wrong one is worse than a message that states none.
|
|
375
|
+
if (!text) {
|
|
376
|
+
return result.code === null
|
|
377
|
+
? 'It said nothing: it was stopped before it could, having run past its timeout or lost the place it was running in.'
|
|
378
|
+
: `It said nothing, and exited ${result.code}.`;
|
|
379
|
+
}
|
|
380
|
+
const lines = text.split('\n');
|
|
381
|
+
const tail = lines.slice(-BUNDLER_OUTPUT_LINES).join('\n').slice(-BUNDLER_OUTPUT_CHARS);
|
|
382
|
+
return `It said:\n${tail.length < text.length ? `...\n${tail}` : tail}`;
|
|
383
|
+
}
|
|
298
384
|
// A sidecar Gemfile that inherits the project's own, plus rspec-rails. Bundler
|
|
299
385
|
// resolves the two together, so the application's gems come with it — the same
|
|
300
386
|
// arrangement the Cucumber sidecar has used since spec 32-1, and the reason the
|
|
@@ -303,6 +389,20 @@ async function provisionRspec(projectRoot, deps) {
|
|
|
303
389
|
const sidecarGemfile = sidecarPath(projectRoot, 'Gemfile');
|
|
304
390
|
writeIfChanged(sidecarGemfile, '# Sidecar Gemfile written by the unitbob connector — do not edit.\n' +
|
|
305
391
|
'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
|
|
392
|
+
// Deliberately *not* guarded the way the Cucumber sidecar below is.
|
|
393
|
+
// `ensureStructuralRunner` only reaches here when the project does not
|
|
394
|
+
// supply rspec itself, so the duplicate that stopped A2.Time has almost no
|
|
395
|
+
// way in — and the guard would cost something real where it does. Measured
|
|
396
|
+
// 2026-08-20 on a gem project that carries rspec-rails as a gemspec
|
|
397
|
+
// development dependency: bundler replaces a `:development` dependency with
|
|
398
|
+
// ours rather than refusing it, so today the runner lands in `:default` and
|
|
399
|
+
// is always installed. Guarded, we would skip our line and leave it in
|
|
400
|
+
// `:development`, where a `BUNDLE_WITHOUT=development` would take the
|
|
401
|
+
// structural runner away from a project that had it working.
|
|
402
|
+
//
|
|
403
|
+
// That leaves one narrow hole: rspec-rails reached through an eval'd
|
|
404
|
+
// sub-Gemfile does raise the duplicate error here. It is now a legible one
|
|
405
|
+
// — see the message below, which no longer swallows what bundler said.
|
|
306
406
|
'gem "rspec-rails", require: false\n');
|
|
307
407
|
// Start from the project's own resolution for the reason spelled out on the
|
|
308
408
|
// Cucumber sidecar below: without it bundler re-resolves the whole graph and
|
|
@@ -320,7 +420,7 @@ async function provisionRspec(projectRoot, deps) {
|
|
|
320
420
|
return { status: 'provisioned' };
|
|
321
421
|
return {
|
|
322
422
|
status: 'fixable',
|
|
323
|
-
message: `Bundler failed to provision rspec-rails under ${SIDECAR_DIR}
|
|
423
|
+
message: `Bundler failed to provision rspec-rails under ${SIDECAR_DIR}. ${whatBundlerSaid(result)}`,
|
|
324
424
|
checklist: [
|
|
325
425
|
'Ensure bundler is installed (`gem install bundler`), then run ' +
|
|
326
426
|
`\`BUNDLE_GEMFILE=${SIDECAR_DIR}/Gemfile bundle install\` from the project root.`,
|
|
@@ -352,13 +452,14 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
352
452
|
const sidecarGemfile = join(behavioralDir, 'Gemfile');
|
|
353
453
|
const sidecarContent = '# Sidecar Gemfile generated by Unitbob (Spec 32-1)\n' +
|
|
354
454
|
'eval_gemfile File.expand_path("../../../Gemfile", __FILE__)\n' +
|
|
355
|
-
'
|
|
455
|
+
gemLineUnlessTheProjectHasIt('cucumber', '~> 9.0') +
|
|
356
456
|
// The connector-owned World blocks outgoing HTTP (spec 35-1), and it can only
|
|
357
|
-
// do that if webmock resolves here. A project that
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
457
|
+
// do that if webmock resolves here. A project that does not carry the gem
|
|
458
|
+
// would otherwise get a World promising a block it silently never performs —
|
|
459
|
+
// the exact shape of failure 35-1 closes. A project that does carry it keeps
|
|
460
|
+
// its own version, and now actually gets to: see the comment on the helper
|
|
461
|
+
// for what asking twice cost A2.Time.
|
|
462
|
+
gemLineUnlessTheProjectHasIt('webmock');
|
|
362
463
|
if (!existsSync(sidecarGemfile) || readFileSync(sidecarGemfile, 'utf8') !== sidecarContent) {
|
|
363
464
|
writeFileSync(sidecarGemfile, sidecarContent);
|
|
364
465
|
}
|
|
@@ -399,7 +500,7 @@ async function provisionRuby(projectRoot, behavioralDir, deps) {
|
|
|
399
500
|
}
|
|
400
501
|
return {
|
|
401
502
|
status: 'fixable',
|
|
402
|
-
message:
|
|
503
|
+
message: `Bundler failed to provision Cucumber sidecar gem. ${whatBundlerSaid(result)}`,
|
|
403
504
|
checklist: ['Ensure bundler is installed (`gem install bundler`) and run `bundle install` manually inside `.unitbob/behavioral/`.'],
|
|
404
505
|
};
|
|
405
506
|
}
|
package/dist/wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { proxyHint } from "./proxyHint.js";
|
|
1
2
|
// Raised when the server cannot be reached or answers with an error status.
|
|
2
3
|
// Verbs surface its message and exit non-zero; they never fabricate a result.
|
|
3
4
|
//
|
|
@@ -32,8 +33,8 @@ export async function registerRepo(server, name) {
|
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
catch (err) {
|
|
35
|
-
throw new WireError(`Cannot reach the Unitbob server at ${server} (${err.message}). ` +
|
|
36
|
-
'Check that the server is running.');
|
|
36
|
+
throw new WireError(withProxyHint(`Cannot reach the Unitbob server at ${server} (${err.message}). ` +
|
|
37
|
+
'Check that the server is running.', server));
|
|
37
38
|
}
|
|
38
39
|
if (!res.ok) {
|
|
39
40
|
let detail = '';
|
|
@@ -43,7 +44,7 @@ export async function registerRepo(server, name) {
|
|
|
43
44
|
catch {
|
|
44
45
|
// ignore — the status alone is actionable enough
|
|
45
46
|
}
|
|
46
|
-
throw new WireError(`POST ${url}
|
|
47
|
+
throw new WireError(statusRefusal(`POST ${url}`, res, detail, server));
|
|
47
48
|
}
|
|
48
49
|
const payload = (await res.json());
|
|
49
50
|
if (typeof payload.id !== 'number' || !Number.isInteger(payload.id)) {
|
|
@@ -229,9 +230,9 @@ export class Wire {
|
|
|
229
230
|
});
|
|
230
231
|
}
|
|
231
232
|
catch (err) {
|
|
232
|
-
throw new WireError(`Cannot reach the Unitbob server at ${this.config.server} ` +
|
|
233
|
+
throw new WireError(withProxyHint(`Cannot reach the Unitbob server at ${this.config.server} ` +
|
|
233
234
|
`(${err.message}). Check that the server is running and that ` +
|
|
234
|
-
`"server" in .unitbob.json is correct.`, { unreachable: true });
|
|
235
|
+
`"server" in .unitbob.json is correct.`, this.config.server), { unreachable: true });
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
async ensureOk(res, what) {
|
|
@@ -252,6 +253,44 @@ export class Wire {
|
|
|
252
253
|
catch {
|
|
253
254
|
// ignore — the status alone is actionable enough
|
|
254
255
|
}
|
|
255
|
-
throw new WireError(
|
|
256
|
+
throw new WireError(statusRefusal(what, res, detail, this.config.server));
|
|
256
257
|
}
|
|
257
258
|
}
|
|
259
|
+
// The two statuses that prove somebody else answered.
|
|
260
|
+
//
|
|
261
|
+
// a2time, 2026-08-17: a sandbox refused the host with `403 Forbidden — Host not
|
|
262
|
+
// in allowlist`, and the run read the 403 as Unitbob's own verdict. It cannot be
|
|
263
|
+
// one. The brain answers 404 even to a caller holding the wrong token — a 403
|
|
264
|
+
// would confirm the project exists — so 403 never comes from it, and 407 is a
|
|
265
|
+
// proxy demanding credentials, which the brain has never heard of.
|
|
266
|
+
const NOT_FROM_THE_BRAIN = {
|
|
267
|
+
403: 'The Unitbob server never answers 403 — it answers 404 even to a caller holding the wrong token — so ' +
|
|
268
|
+
'this was written by something between this machine and it: a proxy, a gateway, or a sandbox that has ' +
|
|
269
|
+
'not been told this host is allowed.',
|
|
270
|
+
407: 'A 407 is a proxy asking this machine to authenticate. It did not come from the Unitbob server.',
|
|
271
|
+
};
|
|
272
|
+
// One failing status, written the same way wherever it was collected.
|
|
273
|
+
//
|
|
274
|
+
// Registration and every tokened call compose this identically on purpose: they
|
|
275
|
+
// used to disagree, and the disagreement was invisible — a 422 from `register`
|
|
276
|
+
// carried network advice while the same 422 from a repo call did not, so which
|
|
277
|
+
// sentence a person got depended on which endpoint they happened to hit.
|
|
278
|
+
function statusRefusal(what, res, detail, server) {
|
|
279
|
+
const line = `${what} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`;
|
|
280
|
+
// A status the brain cannot have written. Relaying it as it stands reads as
|
|
281
|
+
// "Unitbob refused you" and sends the reader to their token; the refusal came
|
|
282
|
+
// from the network in between, and only they can clear it. Every other status
|
|
283
|
+
// is the server's own verdict and keeps the message it has always had — a
|
|
284
|
+
// proxy sentence on a 422 sends somebody to their network settings over a
|
|
285
|
+
// validation error.
|
|
286
|
+
const notOurs = NOT_FROM_THE_BRAIN[res.status];
|
|
287
|
+
return notOurs ? withProxyHint(`${line}\n${notOurs}`, server) : line;
|
|
288
|
+
}
|
|
289
|
+
// Every wire failure that the network could explain ends with the same sentence
|
|
290
|
+
// about it — see `proxyHint`, which decides whether there is one to say. Here
|
|
291
|
+
// rather than inside `WireError` so that a message composed for some other
|
|
292
|
+
// reason never picks it up by accident.
|
|
293
|
+
function withProxyHint(message, server) {
|
|
294
|
+
const hint = proxyHint(server);
|
|
295
|
+
return hint ? `${message}\n${hint}` : message;
|
|
296
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.6.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.6.3 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|