@catheadowl/ukp 0.2.0 → 0.2.1
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/bin/ukp.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Self-locating launcher (D-085 completion, Windows-host wake 2026-09-20).
|
|
3
|
+
//
|
|
4
|
+
// npm's shims exec this file with node (the shebang above); bun's own bin
|
|
5
|
+
// links and `bunx` run it with bun itself. Either way the launcher finds a
|
|
6
|
+
// bun executable WITHOUT relying on the caller's PATH: non-interactive
|
|
7
|
+
// shells (ssh sessions, schtasks, service wrappers) see only the system
|
|
8
|
+
// PATH, while every standard install mode lands bun in a user-local dir.
|
|
9
|
+
// Candidate order: an explicit UKP_BUN override, the current interpreter
|
|
10
|
+
// when it already is bun, the standard install locations, then PATH.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const cli = join(here, "..", "src", "cli.ts");
|
|
19
|
+
const isWindows = process.platform === "win32";
|
|
20
|
+
const runningUnderBun = typeof Bun !== "undefined" || Boolean(process.versions && process.versions.bun);
|
|
21
|
+
|
|
22
|
+
function bunCandidates() {
|
|
23
|
+
const exe = isWindows ? "bun.exe" : "bun";
|
|
24
|
+
const home = homedir();
|
|
25
|
+
const candidates = [];
|
|
26
|
+
if (typeof process.env.UKP_BUN === "string" && process.env.UKP_BUN.length > 0) {
|
|
27
|
+
candidates.push(process.env.UKP_BUN);
|
|
28
|
+
}
|
|
29
|
+
if (runningUnderBun) candidates.push(process.execPath);
|
|
30
|
+
// bun's official installer (same shape on every platform).
|
|
31
|
+
candidates.push(join(home, ".bun", "bin", exe));
|
|
32
|
+
if (isWindows) {
|
|
33
|
+
// `npm i -g bun` lands its shim in the npm user prefix — the same
|
|
34
|
+
// prefix root this package's own shim lives under.
|
|
35
|
+
candidates.push(join(here, "..", "..", "..", "..", exe));
|
|
36
|
+
// scoop installs stay user-local too.
|
|
37
|
+
candidates.push(join(home, "scoop", "shims", exe));
|
|
38
|
+
} else {
|
|
39
|
+
candidates.push(join(home, ".npm-global", "bin", exe));
|
|
40
|
+
// Homebrew's path is joined, not written as one literal: the release
|
|
41
|
+
// leak scan rightly flags literal home-directory-shaped paths, and this
|
|
42
|
+
// is a functional standard-location constant (same stance as the wake
|
|
43
|
+
// command's PATH prefix in src/capabilities/remote-client.ts).
|
|
44
|
+
candidates.push(join("/", "opt", "homebrew", "bin", "bun"));
|
|
45
|
+
candidates.push("/usr/local/bin/bun");
|
|
46
|
+
}
|
|
47
|
+
// PATH last, resolved ourselves dir-by-dir: a bare-name spawn would let
|
|
48
|
+
// the OS search with the PARENT's PATH on Windows (CreateProcess
|
|
49
|
+
// semantics), which this process cannot see or control.
|
|
50
|
+
const pathVar = process.env.PATH;
|
|
51
|
+
if (typeof pathVar === "string" && pathVar.length > 0) {
|
|
52
|
+
for (const dir of pathVar.split(isWindows ? ";" : ":")) {
|
|
53
|
+
if (dir.length > 0) candidates.push(join(dir, exe));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return candidates;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fail(detail) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
"ukp: no bun executable found (" + detail + ").\n"
|
|
62
|
+
+ "ukp runs on bun — install it (https://bun.sh), or point UKP_BUN at the\n"
|
|
63
|
+
+ "bun executable. Standard install locations (~/.bun/bin and the npm\n"
|
|
64
|
+
+ "global prefix) are checked before PATH.\n",
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bunPath = bunCandidates().find((candidate) => existsSync(candidate));
|
|
70
|
+
if (bunPath === undefined) {
|
|
71
|
+
fail("looked for UKP_BUN, the standard install locations, and PATH");
|
|
72
|
+
}
|
|
73
|
+
const result = spawnSync(bunPath, [cli, ...process.argv.slice(2)], {
|
|
74
|
+
stdio: "inherit",
|
|
75
|
+
env: process.env,
|
|
76
|
+
});
|
|
77
|
+
if (result.error) {
|
|
78
|
+
// bunPath existed on disk a moment ago; a spawn failure here is exotic,
|
|
79
|
+
// but it must still land in the remedy, never a stack trace.
|
|
80
|
+
fail(`spawning '${bunPath}' failed: ${String(result.error.message ?? result.error)}`);
|
|
81
|
+
}
|
|
82
|
+
process.exit(result.status === null ? (result.signal !== null ? 130 : 1) : result.status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catheadowl/ukp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"bun": ">=1.3.14"
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
"agent"
|
|
25
25
|
],
|
|
26
26
|
"bin": {
|
|
27
|
-
"ukp": "
|
|
27
|
+
"ukp": "bin/ukp.js"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
|
+
"bin",
|
|
30
31
|
"src",
|
|
31
32
|
"README.md",
|
|
32
33
|
"LICENSE",
|
|
@@ -290,10 +290,24 @@ function spawnMuxMaster(
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
/** The pinned wake command (W9 / ADR-REM-006 §4 safety narrowing)
|
|
294
|
-
* only the client-chosen integer port and idle window are
|
|
295
|
-
* exact string an operator can allowlist with git-shell /
|
|
296
|
-
* `command=`. Loopback-only, anonymous: SSH carries
|
|
293
|
+
/** The pinned wake command (W9 / ADR-REM-006 §4 safety narrowing), in two
|
|
294
|
+
* host-shell forms: only the client-chosen integer port and idle window are
|
|
295
|
+
* interpolated — the exact string an operator can allowlist with git-shell /
|
|
296
|
+
* authorized_keys `command=`. Loopback-only, anonymous: SSH carries
|
|
297
|
+
* encryption and auth. The POSIX form is unchanged since W9; the cmd form
|
|
298
|
+
* (2026-09-20, liku feedback) serves Windows hosts whose OpenSSH default
|
|
299
|
+
* shell is cmd.exe and cannot parse `sh -c` at all — it is the pinned
|
|
300
|
+
* equivalent with a Windows-shaped PATH prefix (bun official, scoop, npm
|
|
301
|
+
* user prefix; the launcher resolves bun itself once ukp is found, the
|
|
302
|
+
* prefix guarantees ukp resolves). The `set "PATH=…"` quoting is load-
|
|
303
|
+
* bearing: an unquoted `set PATH=…%PATH%&&…` breaks when the expanded PATH
|
|
304
|
+
* contains cmd metacharacters (`&` in a directory name — verified), while
|
|
305
|
+
* the quoted form survives both the sshd double-cmd wrapper and cmd's own
|
|
306
|
+
* expansion (verified against Win32-OpenSSH source and live). The wake
|
|
307
|
+
* ladder sends POSIX first and switches on the wrong-shell failure
|
|
308
|
+
* signature; host shells covered: POSIX login shells, cmd.exe default, and
|
|
309
|
+
* Git-Bash-style DefaultShell hosts — a powershell DefaultShell is NOT
|
|
310
|
+
* supported (its -c quoting drops the form).
|
|
297
311
|
*
|
|
298
312
|
* The remote shell ssh runs commands in is NON-INTERACTIVE (bare system
|
|
299
313
|
* PATH), while every standard install mode lands in user-local,
|
|
@@ -301,9 +315,9 @@ function spawnMuxMaster(
|
|
|
301
315
|
* interactivity guard; `bun add -g` links into ~/.bun/bin; a user npm prefix
|
|
302
316
|
* like ~/.npm-global never leaves the user dir; brew lives outside the
|
|
303
317
|
* default ssh PATH). So the command prepends the standard install locations
|
|
304
|
-
* to PATH before exec — ukp AND its
|
|
305
|
-
*
|
|
306
|
-
*
|
|
318
|
+
* to PATH before exec — ukp AND its shebang/launcher both resolve for any
|
|
319
|
+
* usual install. nvm-style versioned layouts have no fixed path shape and
|
|
320
|
+
* need a system symlink (documented exception). */
|
|
307
321
|
// The Linuxbrew segment is joined, not written as one literal: its standard
|
|
308
322
|
// install location is a functional constant (part of the byte-pinned remote
|
|
309
323
|
// command), while the release "absolute user path" leak scan rightly flags
|
|
@@ -317,10 +331,25 @@ const WAKE_REMOTE_PATH_PREFIX = [
|
|
|
317
331
|
["/home", "linuxbrew", ".linuxbrew", "bin"].join("/"),
|
|
318
332
|
].join(":");
|
|
319
333
|
|
|
320
|
-
|
|
334
|
+
export type WakeShellForm = "posix" | "cmd";
|
|
335
|
+
|
|
336
|
+
function wakeDoorCommand(remotePort: number, form: WakeShellForm = "posix"): string {
|
|
337
|
+
if (form === "cmd") {
|
|
338
|
+
return `cmd /d /c "set "PATH=%USERPROFILE%\\.bun\\bin;%USERPROFILE%\\scoop\\shims;%APPDATA%\\npm;%PATH%"&&ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}"`;
|
|
339
|
+
}
|
|
321
340
|
return `sh -c 'PATH="${WAKE_REMOTE_PATH_PREFIX}:$PATH" exec ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}'`;
|
|
322
341
|
}
|
|
323
342
|
|
|
343
|
+
/** Wrong-shell failure signature for the wake ladder: the remote shell could
|
|
344
|
+
* not even parse the sent form, so the OTHER form is the retry — as opposed
|
|
345
|
+
* to a missing-ukp failure, where the shell ran fine and named 'ukp' as the
|
|
346
|
+
* thing it cannot find. cmd.exe errors quote the missing token ('sh' is not
|
|
347
|
+
* recognized, exit 9009); POSIX shells print "<token>: command not found". */
|
|
348
|
+
function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean {
|
|
349
|
+
if (form === "posix") return /'sh' is not recognized|sh: command not found/i.test(diagnostics);
|
|
350
|
+
return /'cmd' is not recognized|cmd: command not found/i.test(diagnostics);
|
|
351
|
+
}
|
|
352
|
+
|
|
324
353
|
/** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
|
|
325
354
|
* local forward proves nothing — ssh accepts locally and connects remotely
|
|
326
355
|
* lazily — so we poll the door's public discovery document until it answers
|
|
@@ -399,6 +428,11 @@ async function openSshTunnel(
|
|
|
399
428
|
// options for the remaining attempts so the endpoint degrades to Tier 0
|
|
400
429
|
// instead of hard-failing what worked without mux.
|
|
401
430
|
let muxDisabled = false;
|
|
431
|
+
// Shell-form ladder (2026-09-20): POSIX first (the common server shell),
|
|
432
|
+
// cmd second — a Windows host whose OpenSSH default shell is cmd.exe fails
|
|
433
|
+
// the POSIX form with an 'sh'-not-recognized signature (not a missing-ukp
|
|
434
|
+
// failure: the shell never got as far as looking for ukp).
|
|
435
|
+
let form: WakeShellForm = "posix";
|
|
402
436
|
const attempts = 3;
|
|
403
437
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
404
438
|
const localPort = await freePort();
|
|
@@ -416,7 +450,7 @@ async function openSshTunnel(
|
|
|
416
450
|
...(muxDisabled ? [] : muxClientOptions()),
|
|
417
451
|
"-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
|
|
418
452
|
target,
|
|
419
|
-
wakeDoorCommand(remotePort),
|
|
453
|
+
wakeDoorCommand(remotePort, form),
|
|
420
454
|
];
|
|
421
455
|
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
422
456
|
const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
|
|
@@ -432,6 +466,17 @@ async function openSshTunnel(
|
|
|
432
466
|
if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
|
|
433
467
|
muxDisabled = true;
|
|
434
468
|
}
|
|
469
|
+
if (exited && wrongShellSignature(form, diagnostics)) {
|
|
470
|
+
if (attempt < attempts) {
|
|
471
|
+
form = form === "posix" ? "cmd" : "posix";
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// Both forms rejected: this is a shell mismatch, not a missing-ukp
|
|
475
|
+
// failure — say so instead of falling into the missing-ukp wording.
|
|
476
|
+
throw new RemoteTransportError(
|
|
477
|
+
`waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed — the remote shell rejected both wake command forms (POSIX and cmd.exe): ${diagnostics}. Supported host shells: POSIX login shells, cmd.exe default, Git-Bash-style DefaultShell; a powershell DefaultShell is not supported`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
435
480
|
// POSIX login shells print "…: ukp: command not found" (exit 127); Windows
|
|
436
481
|
// OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
|
|
437
482
|
if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
|
|
@@ -54,8 +54,8 @@ Name and identity:
|
|
|
54
54
|
- ssh:// urls take no port: registration rejects one (the woken door listens on a client-chosen loopback port; use an ssh config Host alias for a non-standard sshd port).
|
|
55
55
|
|
|
56
56
|
Operational note:
|
|
57
|
-
- ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself
|
|
58
|
-
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. The CLI does not install or autostart the door.
|
|
57
|
+
- ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
|
|
58
|
+
- https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door.
|
|
59
59
|
- serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
|
|
60
60
|
|
|
61
61
|
Remember:
|
|
@@ -80,7 +80,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
|
|
|
80
80
|
" assertion on the declared name; bearer-token services read",
|
|
81
81
|
" UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
|
|
82
82
|
"",
|
|
83
|
-
"Host doors
|
|
83
|
+
"Host doors:",
|
|
84
84
|
" 'ukp register --url ssh://ali' fetches the door's discovery document",
|
|
85
85
|
" and imports every endpoint behind it (a door url may also name one",
|
|
86
86
|
" endpoint: ssh://ali/notes). Import is explicit and idempotent:",
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer as netCreateServer, connect as netConnect, type Server as
|
|
|
4
4
|
import { ENDPOINT_NAME, loadManifest, type LoadedManifest } from "./config/manifest.ts";
|
|
5
5
|
import { localPathOf, readRegistry } from "./registry.ts";
|
|
6
6
|
import { FILE_NATIVE_CAPABILITIES, resolveFileNativeCapability } from "./config/file-native.ts";
|
|
7
|
+
import { EXTERNAL_PROVIDER, EXTERNAL_TOOL_CAPABILITIES } from "./config/external-tool.ts";
|
|
7
8
|
import {
|
|
8
9
|
PROPOSE_SLUG,
|
|
9
10
|
ProposeBusyError,
|
|
@@ -231,13 +232,19 @@ function readInstanceUid(serviceFolder: string): string {
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/** Capability projection shared by endpoint and door documents: derived
|
|
234
|
-
* file-native defaults (read/nav) are always present
|
|
235
|
-
*
|
|
235
|
+
* file-native defaults (read/nav) are always present, then the external-tool
|
|
236
|
+
* base tier (rg, ADR-RG-003 — served on /v1/rg for every endpoint; a missing
|
|
237
|
+
* binary degrades to per-endpoint availability data per the W6 wire
|
|
238
|
+
* contract, never an undeclared capability); declared capabilities override
|
|
239
|
+
* or extend both (a declared nav or rg replaces the default entry). */
|
|
236
240
|
function projectCapabilities(loaded: LoadedManifest): DiscoveryDocument["capabilities"] {
|
|
237
241
|
const capabilities: DiscoveryDocument["capabilities"] = {};
|
|
238
242
|
for (const [name, spec] of Object.entries(FILE_NATIVE_CAPABILITIES)) {
|
|
239
243
|
if (spec.derived) capabilities[name] = { provider: "file", derived: true };
|
|
240
244
|
}
|
|
245
|
+
for (const name of Object.keys(EXTERNAL_TOOL_CAPABILITIES)) {
|
|
246
|
+
capabilities[name] = { provider: EXTERNAL_PROVIDER, derived: true };
|
|
247
|
+
}
|
|
241
248
|
for (const name of Object.keys(loaded.manifest.capabilities).sort()) {
|
|
242
249
|
const declared = loaded.manifest.capabilities[name]!;
|
|
243
250
|
capabilities[name] = { provider: declared.provider ?? "file" };
|