@catheadowl/ukp 0.2.6 → 0.3.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.
- package/README.md +83 -16
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +86 -8
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -0
- package/src/capabilities/nav.ts +209 -444
- package/src/capabilities/qmd.ts +19 -0
- package/src/capabilities/read.ts +18 -2
- package/src/capabilities/remote-client.ts +4 -2
- package/src/capabilities/search.ts +23 -7
- package/src/capabilities/session.ts +455 -0
- package/src/capabilities/tls-identity.ts +54 -3
- package/src/cli.ts +32 -64
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +21 -0
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +224 -9
- package/src/commands/inventory.ts +277 -17
- package/src/commands/nav.ts +17 -12
- package/src/commands/read.ts +1 -1
- package/src/commands/rg.ts +2 -2
- package/src/commands/search.ts +19 -3
- package/src/commands/serve-task.ts +5 -5
- package/src/commands/serve.ts +313 -29
- package/src/commands/session.ts +541 -0
- package/src/commands/use.ts +178 -0
- package/src/config/agent-backed.ts +136 -0
- package/src/config/manifest.ts +39 -3
- package/src/manifest.yml +15 -0
- package/src/path-safety.ts +9 -4
- package/src/plumbing/token-file.ts +368 -0
- package/src/registry.ts +78 -1
- package/src/server.ts +228 -45
- package/src/spawn-relay.ts +21 -3
package/src/capabilities/qmd.ts
CHANGED
|
@@ -88,6 +88,25 @@ export function providerTimeoutMs(): number {
|
|
|
88
88
|
return envTimeoutMs("UKP_PROVIDER_TIMEOUT_MS", 60_000);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Resolved spawn timeout for `ukp search` (owner ruling 2026-10-28, TODO
|
|
93
|
+
* 20261028-1113 option 3): the explicit `--timeout <seconds>` flag — `0`
|
|
94
|
+
* DISABLES the guard entirely (the 2026-08-16 interactive semantics revived
|
|
95
|
+
* on demand: user interrupt is then the only cancel mechanism — the
|
|
96
|
+
* curl/wget/ssh_config "zero disables" grammar), a positive value wins over
|
|
97
|
+
* the env override. Absent flag falls back to `UKP_PROVIDER_TIMEOUT_MS` /
|
|
98
|
+
* default 60s — the default-guard-ON posture maps to the unattended
|
|
99
|
+
* orchestration family (GitHub Actions / GitLab CI / Kubernetes): search's
|
|
100
|
+
* primary consumers are agent sessions nobody watches. Returns `undefined`
|
|
101
|
+
* = no timeout (wait indefinitely). `providerTimeoutMs` itself stays
|
|
102
|
+
* always-bounded for internal probes (rename recovery L2).
|
|
103
|
+
*/
|
|
104
|
+
export function searchSpawnTimeoutMs(flagSeconds?: number): number | undefined {
|
|
105
|
+
if (flagSeconds === 0) return undefined;
|
|
106
|
+
if (flagSeconds !== undefined) return flagSeconds * 1000;
|
|
107
|
+
return providerTimeoutMs();
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
/**
|
|
92
111
|
* Update runs `qmd update`, which may legitimately index a large corpus —
|
|
93
112
|
* its ceiling is correspondingly higher (default 600s, overridable via
|
package/src/capabilities/read.ts
CHANGED
|
@@ -164,9 +164,13 @@ function validateEndpointRelativePath(reference: string): string[] {
|
|
|
164
164
|
throw new ReadUsageError("reference must be endpoint-scoped, not absolute");
|
|
165
165
|
}
|
|
166
166
|
if (result.reason === "empty-segment") {
|
|
167
|
-
throw new ReadUsageError(
|
|
167
|
+
throw new ReadUsageError(
|
|
168
|
+
"reference must not contain empty path segments (use --from <route> to resolve a document-relative reference)",
|
|
169
|
+
);
|
|
168
170
|
}
|
|
169
|
-
throw new ReadUsageError(
|
|
171
|
+
throw new ReadUsageError(
|
|
172
|
+
"reference must not contain '.' or '..' path segments (use --from <route> to resolve a document-relative reference)",
|
|
173
|
+
);
|
|
170
174
|
}
|
|
171
175
|
return result.segments;
|
|
172
176
|
}
|
|
@@ -721,6 +725,18 @@ export function runRead(request: ReadRequest, context: ReadContext): ReadOutcome
|
|
|
721
725
|
request = { ...request, path: resolved, sourceReference: request.path };
|
|
722
726
|
}
|
|
723
727
|
|
|
728
|
+
// Trailing separators are address-encoding noise (ADR 0023 / D-099): strip
|
|
729
|
+
// once here so every downstream consumer — grammar validation, the
|
|
730
|
+
// miss-path did-you-mean scan, rename recovery, the envelope echo — sees
|
|
731
|
+
// the canonical route. The tolerant pre-pass above has already run: its
|
|
732
|
+
// rewritten routes never carry trailing separators, and absolute shapes
|
|
733
|
+
// were routed before this point (an input that is only separators is
|
|
734
|
+
// absolute-shaped and never reaches here).
|
|
735
|
+
const canonicalPath = request.path.replace(/[\\/]+$/, "");
|
|
736
|
+
if (canonicalPath !== request.path) {
|
|
737
|
+
request = { ...request, path: canonicalPath };
|
|
738
|
+
}
|
|
739
|
+
|
|
724
740
|
const outcome = executeResolvedRead(request, context, registry);
|
|
725
741
|
if (resolution === undefined) return outcome;
|
|
726
742
|
return outcome.ok
|
|
@@ -65,8 +65,10 @@ function remoteTimeoutMs(): number {
|
|
|
65
65
|
* chain. Default 10s (the Ansible baseline); `UKP_SSH_CONNECT_TIMEOUT_MS`
|
|
66
66
|
* overrides, mirroring UKP_REMOTE_TIMEOUT_MS. ssh takes whole seconds, so
|
|
67
67
|
* an integer ms value ≥1000 floors (1500 → 1s); anything else — non-numeric,
|
|
68
|
-
* fractional, below 1000 — keeps the default.
|
|
69
|
-
|
|
68
|
+
* fractional, below 1000 — keeps the default. Exported for the session
|
|
69
|
+
* remote transport (ukp_session_remote W2): one knob, one value, every
|
|
70
|
+
* ssh process UKP spawns. */
|
|
71
|
+
export function sshConnectTimeoutSeconds(): number {
|
|
70
72
|
const raw = process.env.UKP_SSH_CONNECT_TIMEOUT_MS;
|
|
71
73
|
if (raw === undefined || raw.trim() === "") return 10;
|
|
72
74
|
const value = Number(raw);
|
|
@@ -22,11 +22,17 @@ import {
|
|
|
22
22
|
import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
|
|
23
23
|
import { resolveScope } from "../scope.ts";
|
|
24
24
|
import { isAbsoluteShapedPath, isInsideRealRoot } from "../path-safety.ts";
|
|
25
|
-
import { buildQmdInvocation, defaultQmdCommand, isDocidBody,
|
|
25
|
+
import { buildQmdInvocation, defaultQmdCommand, isDocidBody, searchSpawnTimeoutMs, stripDocidHash } from "./qmd.ts";
|
|
26
26
|
|
|
27
27
|
export interface SearchRequest {
|
|
28
28
|
query: string;
|
|
29
29
|
limit: number;
|
|
30
|
+
/** Provider spawn timeout for this invocation, in whole seconds
|
|
31
|
+
* (owner ruling 2026-10-28, TODO 20261028-1113 option 3): 0 disables
|
|
32
|
+
* the hang guard (interrupt-only cancellation), a positive value wins
|
|
33
|
+
* over `UKP_PROVIDER_TIMEOUT_MS`. Absent = env/default (60s). Execution
|
|
34
|
+
* semantics like `limit`, not a surface-shaping option. */
|
|
35
|
+
timeoutSeconds?: number;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export interface SearchOptions {
|
|
@@ -435,13 +441,14 @@ function runHumanMode(
|
|
|
435
441
|
}
|
|
436
442
|
|
|
437
443
|
const command = commandFor(endpoint, parsed, true);
|
|
444
|
+
const timeoutMs = searchSpawnTimeoutMs(parsed.request.timeoutSeconds);
|
|
438
445
|
const result = spawnSync(command.file, command.args, {
|
|
439
446
|
cwd: endpoint.folder!,
|
|
440
447
|
encoding: "utf8",
|
|
441
448
|
windowsHide: true,
|
|
442
449
|
windowsVerbatimArguments: command.verbatim,
|
|
443
450
|
maxBuffer: 64 * 1024 * 1024,
|
|
444
|
-
timeout:
|
|
451
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
445
452
|
});
|
|
446
453
|
const providerOutput = (result.stdout ?? "").trimEnd();
|
|
447
454
|
const outcome: SearchEndpointOutcome = {
|
|
@@ -455,9 +462,13 @@ function runHumanMode(
|
|
|
455
462
|
endpoints.push(outcome);
|
|
456
463
|
if (result.signal === "SIGTERM") {
|
|
457
464
|
// Zero-output hang guard: a timed-out provider is a classified failure,
|
|
458
|
-
// never silence (see qmd.ts
|
|
465
|
+
// never silence (see qmd.ts searchSpawnTimeoutMs). The message names
|
|
466
|
+
// the flag (owner ruling form 4: diagnosable, never disguised as a
|
|
467
|
+
// provider crash); with the guard disabled a SIGTERM is external.
|
|
459
468
|
failed = true;
|
|
460
|
-
const message =
|
|
469
|
+
const message = timeoutMs === undefined
|
|
470
|
+
? `endpoint '${endpoint.name}' provider terminated by SIGTERM (timeout guard disabled by --timeout 0)`
|
|
471
|
+
: `endpoint '${endpoint.name}' provider timed out after ${timeoutMs / 1000}s - raise or disable via --timeout`;
|
|
461
472
|
warnings.push(message);
|
|
462
473
|
outcome.status = "failed";
|
|
463
474
|
outcome.providerExitStatus = null;
|
|
@@ -1085,12 +1096,13 @@ function runJsonMode(
|
|
|
1085
1096
|
stdoutFd = openSync(artifact, "w", 0o600);
|
|
1086
1097
|
stderrFd = openSync(errorArtifact, "w", 0o600);
|
|
1087
1098
|
const command = commandFor(endpoint, parsed, true);
|
|
1099
|
+
const timeoutMs = searchSpawnTimeoutMs(parsed.request.timeoutSeconds);
|
|
1088
1100
|
const result = spawnSync(command.file, command.args, {
|
|
1089
1101
|
cwd: endpoint.folder!,
|
|
1090
1102
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
1091
1103
|
windowsHide: true,
|
|
1092
1104
|
windowsVerbatimArguments: command.verbatim,
|
|
1093
|
-
timeout:
|
|
1105
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
1094
1106
|
});
|
|
1095
1107
|
closeSync(stdoutFd);
|
|
1096
1108
|
stdoutFd = undefined;
|
|
@@ -1102,9 +1114,13 @@ function runJsonMode(
|
|
|
1102
1114
|
|
|
1103
1115
|
if (result.signal === "SIGTERM") {
|
|
1104
1116
|
// Zero-output hang guard: classified failure + error artifact, never
|
|
1105
|
-
// silence (see qmd.ts
|
|
1117
|
+
// silence (see qmd.ts searchSpawnTimeoutMs). The artifact message
|
|
1118
|
+
// names the flag (owner ruling form 4); with the guard disabled a
|
|
1119
|
+
// SIGTERM is external, not a timeout.
|
|
1106
1120
|
failed = true;
|
|
1107
|
-
appendErrorArtifact(errorArtifact,
|
|
1121
|
+
appendErrorArtifact(errorArtifact, timeoutMs === undefined
|
|
1122
|
+
? "provider terminated by SIGTERM (timeout guard disabled by --timeout 0)"
|
|
1123
|
+
: `provider timed out after ${timeoutMs / 1000}s - raise or disable via --timeout`);
|
|
1108
1124
|
const message = `endpoint '${endpoint.name}' provider timed out`;
|
|
1109
1125
|
warnings.push(message);
|
|
1110
1126
|
outcome.status = "failed";
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// ukp_session W1→W2/G3 (line spec: session-command; ADR-SESSION-001/002/003;
|
|
2
|
+
// W2 extraction ruling X4):
|
|
3
|
+
// the session capability core in WRAPPER form. UKP owns naming and relay
|
|
4
|
+
// only (core-idea D-1): conversation history lives in the agent's own
|
|
5
|
+
// global space, so this module persists the host-scoped address book and
|
|
6
|
+
// nothing else — the no-transcript invariant is a spec §6 test anchor.
|
|
7
|
+
//
|
|
8
|
+
// The substrate mechanism (argv mapping, AG-UI synthesis, resume, sandbox
|
|
9
|
+
// pinning) retired to the amux binary in W2/G3 — ukp spawns it per
|
|
10
|
+
// invocation and consumes its structured face (the rg precedent: the
|
|
11
|
+
// wrapped tool holds the structured surface; the wrapper holds policy).
|
|
12
|
+
// amux's README is the tool contract authority; ukp never imports its
|
|
13
|
+
// source. The migration is provenance, not implementation.
|
|
14
|
+
//
|
|
15
|
+
// Layers (ADR 0021): this module owns the amux consumption mechanism and
|
|
16
|
+
// classified errors; the CLI adapter (commands/session.ts) owns argument
|
|
17
|
+
// surfaces, exit codes and channel text.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { dirname, join } from "node:path";
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import type { SessionWriteTier } from "../config/agent-backed.ts";
|
|
30
|
+
import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
|
|
31
|
+
|
|
32
|
+
// ---- Classified errors (ADR 0021: capability classifies, CLI maps codes)
|
|
33
|
+
|
|
34
|
+
export type SessionErrorClass =
|
|
35
|
+
| "usage"
|
|
36
|
+
| "not-authorized"
|
|
37
|
+
| "run-failed"
|
|
38
|
+
| "substrate-unavailable";
|
|
39
|
+
|
|
40
|
+
export class SessionError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
readonly errorClass: SessionErrorClass,
|
|
43
|
+
message: string,
|
|
44
|
+
options?: ErrorOptions,
|
|
45
|
+
) {
|
|
46
|
+
super(message, options);
|
|
47
|
+
this.name = "SessionError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Address book (spec §6): host-scoped, id = the agent's native id
|
|
52
|
+
|
|
53
|
+
export interface SessionRecord {
|
|
54
|
+
id: string;
|
|
55
|
+
endpoint: string;
|
|
56
|
+
agent: string;
|
|
57
|
+
tier: "sdk";
|
|
58
|
+
owner: string;
|
|
59
|
+
created_at: string;
|
|
60
|
+
last_run_at: string;
|
|
61
|
+
last_outcome: "finished" | "failed" | "unknown";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sessionStorePath(registryPath: string): string {
|
|
65
|
+
return join(dirname(registryPath), "sessions.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadSessionRecords(storePath: string): SessionRecord[] {
|
|
69
|
+
if (!existsSync(storePath)) return [];
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(readFileSync(storePath, "utf8"));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new SessionError("usage", `session address book is not readable: ${storePath}`, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(parsed)) {
|
|
77
|
+
throw new SessionError("usage", `session address book is malformed: ${storePath}`);
|
|
78
|
+
}
|
|
79
|
+
return parsed as SessionRecord[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeSessionRecords(storePath: string, records: readonly SessionRecord[]): void {
|
|
83
|
+
// Atomic write (temp + rename), the registry's own durability discipline.
|
|
84
|
+
const directory = dirname(storePath);
|
|
85
|
+
mkdirSync(directory, { recursive: true });
|
|
86
|
+
const tempPath = `${storePath}.tmp.${randomUUID()}`;
|
|
87
|
+
writeFileSync(tempPath, `${JSON.stringify(records, null, 2)}\n`, "utf8");
|
|
88
|
+
try {
|
|
89
|
+
renameSync(tempPath, storePath);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
unlinkSync(tempPath);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function appendSessionRecord(storePath: string, record: SessionRecord): void {
|
|
97
|
+
const records = loadSessionRecords(storePath);
|
|
98
|
+
records.push(record);
|
|
99
|
+
writeSessionRecords(storePath, records);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function updateSessionRecord(
|
|
103
|
+
storePath: string,
|
|
104
|
+
id: string,
|
|
105
|
+
lastRunAt: string,
|
|
106
|
+
lastOutcome: "finished" | "failed",
|
|
107
|
+
): void {
|
|
108
|
+
const records = loadSessionRecords(storePath);
|
|
109
|
+
const record = records.find((candidate) => candidate.id === id);
|
|
110
|
+
if (record === undefined) return;
|
|
111
|
+
record.last_run_at = lastRunAt;
|
|
112
|
+
record.last_outcome = lastOutcome;
|
|
113
|
+
writeSessionRecords(storePath, records);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Single-id resolution; the agent-native id namespace spans agents, so a
|
|
117
|
+
* collision is possible by construction — it degrades to a usage error
|
|
118
|
+
* listing the candidates (spec §3). */
|
|
119
|
+
export function resolveSessionRecord(records: readonly SessionRecord[], id: string): SessionRecord {
|
|
120
|
+
const matches = records.filter((record) => record.id === id);
|
|
121
|
+
if (matches.length === 0) {
|
|
122
|
+
throw new SessionError("usage", `unknown session '${id}' (see 'ukp session list')`);
|
|
123
|
+
}
|
|
124
|
+
if (matches.length > 1) {
|
|
125
|
+
const candidates = matches
|
|
126
|
+
.map((record) => `${record.id} (endpoint ${record.endpoint}, agent ${record.agent})`)
|
|
127
|
+
.join("; ");
|
|
128
|
+
throw new SessionError(
|
|
129
|
+
"usage",
|
|
130
|
+
`session id '${id}' is ambiguous across agents: ${candidates} — address it after re-spawning, or prune the address book`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return matches[0]!;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- amux consumption (spec §4, wrapper form). The mechanism contract:
|
|
137
|
+
// ukp invokes `amux` with `--events`, consumes the AG-UI JSONL, and derives
|
|
138
|
+
// everything it needs from the event stream. amux's human stderr is never
|
|
139
|
+
// forwarded on the success path (the command layer writes its own
|
|
140
|
+
// endpoint-flavored notes); amux's classified stderr lines are consulted
|
|
141
|
+
// only when the process exits non-zero without a derivable outcome.
|
|
142
|
+
|
|
143
|
+
/** amux's sandbox vocabulary (README trust model): pinned in both
|
|
144
|
+
* directions so the substrate's own default is never ridden. */
|
|
145
|
+
export type AmuxSandboxTier = "read-only" | "workspace-write";
|
|
146
|
+
|
|
147
|
+
/** The endpoint-declared write tier mapped onto amux's invocation flag —
|
|
148
|
+
* the policy vocabulary (manifest `write`) never leaks into the mechanism
|
|
149
|
+
* argv (ADR-SESSION-004: the grant is resolved before the spawn). */
|
|
150
|
+
export function amuxSandboxTier(write: SessionWriteTier): AmuxSandboxTier {
|
|
151
|
+
return write === "workspace" ? "workspace-write" : "read-only";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type AmuxInvocation =
|
|
155
|
+
| {
|
|
156
|
+
kind: "spawn";
|
|
157
|
+
/** Endpoint folder — amux anchors the session to this path. */
|
|
158
|
+
folder: string;
|
|
159
|
+
/** The registry-declared agent command, passed through verbatim. */
|
|
160
|
+
agentCommand: readonly string[];
|
|
161
|
+
prompt: string;
|
|
162
|
+
sandbox: AmuxSandboxTier;
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
kind: "send";
|
|
166
|
+
/** Native session id to resume. */
|
|
167
|
+
sessionId: string;
|
|
168
|
+
prompt: string;
|
|
169
|
+
sandbox: AmuxSandboxTier;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** What ukp derives from one amux invocation (spec §4 derivation rules). */
|
|
173
|
+
export interface AmuxRunOutcome {
|
|
174
|
+
/** First event's threadId (spawn: the native session id); undefined when
|
|
175
|
+
* amux emitted no events at all. */
|
|
176
|
+
threadId?: string;
|
|
177
|
+
outcome: "finished" | "failed";
|
|
178
|
+
/** The turn's final agent message — the default report face. */
|
|
179
|
+
finalMessage?: string;
|
|
180
|
+
/** Failure detail for the command layer's stderr note. */
|
|
181
|
+
detail?: string;
|
|
182
|
+
/** The AG-UI JSONL lines verbatim (amux's face, ukp never rewrites) —
|
|
183
|
+
* the `--events` passthrough payload. */
|
|
184
|
+
eventLines: readonly string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface AmuxRunOptions {
|
|
188
|
+
/** Explicit amux command (test injection, the rg.ts rgCommand precedent);
|
|
189
|
+
* never wrapped in the win32 relay. */
|
|
190
|
+
amuxCommand?: readonly string[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Default amux location: the PATH binary (rg precedent — no bundled copy,
|
|
194
|
+
* no version negotiation; the wrapped tool owns its own distribution). */
|
|
195
|
+
export function defaultAmuxCommand(): readonly string[] {
|
|
196
|
+
return ["amux"];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** UKP_AMUX env override: the amux binary to spawn instead of the PATH
|
|
200
|
+
* lookup (explicit override — spawned as given, never relay-wrapped). */
|
|
201
|
+
export function amuxCommandFromEnvironment(): readonly string[] | undefined {
|
|
202
|
+
const value = process.env.UKP_AMUX;
|
|
203
|
+
return value === undefined || value.trim() === "" ? undefined : [value];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function resolveAmuxCommand(options?: AmuxRunOptions): readonly string[] {
|
|
207
|
+
return options?.amuxCommand ?? amuxCommandFromEnvironment() ?? defaultAmuxCommand();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Same env knob and defaults as the provider timeout (qmd) — spec §5
|
|
211
|
+
* deliberately reuses UKP_PROVIDER_TIMEOUT_MS; minimum 1s. The value is
|
|
212
|
+
* forwarded to amux as AMUX_TIMEOUT_MS so both layers run on one deadline
|
|
213
|
+
* instead of racing two different defaults. */
|
|
214
|
+
export function sessionSubstrateTimeoutMs(): number {
|
|
215
|
+
const raw = process.env.UKP_PROVIDER_TIMEOUT_MS;
|
|
216
|
+
if (raw === undefined || raw.trim() === "") return 60_000;
|
|
217
|
+
const parsed = Number(raw);
|
|
218
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1_000) return 60_000;
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The amux invocation argv (without the binary): `amux <folder>
|
|
223
|
+
* <command...> --events --sandbox <tier> --text <prompt>` or the send
|
|
224
|
+
* form. `--events` is always on — the event stream is how ukp derives
|
|
225
|
+
* id/outcome/report; the caller's `--events` only selects what ukp
|
|
226
|
+
* re-emits on stdout. */
|
|
227
|
+
export function amuxInvocationArgv(request: AmuxInvocation): string[] {
|
|
228
|
+
const tail = ["--events", "--sandbox", request.sandbox, "--text", request.prompt];
|
|
229
|
+
return request.kind === "spawn"
|
|
230
|
+
? [request.folder, ...request.agentCommand, ...tail]
|
|
231
|
+
: ["send", request.sessionId, ...tail];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** amux's classified stderr line: `amux: <label>: <message>` (exit codes
|
|
235
|
+
* 2 = usage, 1 = runtime, the label carries the classification). Mapped
|
|
236
|
+
* onto ukp's classes; labels inherit amux's vocabulary so the wrapper's
|
|
237
|
+
* classification stays honest about which layer failed. */
|
|
238
|
+
const AMUX_ERROR_CLASS_BY_LABEL: Readonly<Record<string, SessionErrorClass>> = {
|
|
239
|
+
"usage error": "usage",
|
|
240
|
+
"run failed": "run-failed",
|
|
241
|
+
"agent substrate unavailable": "substrate-unavailable",
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
function parseAmuxClassifiedLine(stderrText: string): { errorClass: SessionErrorClass; message: string } | undefined {
|
|
245
|
+
for (const line of stderrText.split(/\r?\n/)) {
|
|
246
|
+
const match = /^amux: ([^:]+): (.*)$/.exec(line);
|
|
247
|
+
if (match === null) continue;
|
|
248
|
+
const errorClass = AMUX_ERROR_CLASS_BY_LABEL[match[1]!];
|
|
249
|
+
if (errorClass !== undefined) return { errorClass, message: match[2]! };
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** ukp owns the timeout knob; amux's remedy text is re-pointed so the
|
|
255
|
+
* user-facing knob stays UKP_PROVIDER_TIMEOUT_MS (spec §4). */
|
|
256
|
+
function inheritAmuxMessage(message: string): string {
|
|
257
|
+
return message.replaceAll("AMUX_TIMEOUT_MS", "UKP_PROVIDER_TIMEOUT_MS");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
261
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function amuxUnavailableMessage(command: readonly string[], reason: string): string {
|
|
265
|
+
return `amux is not available (${reason}: '${command.join(" ")}') — install amux on PATH or set UKP_AMUX to its binary`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Runs one amux invocation and derives the outcome from its AG-UI event
|
|
269
|
+
* stream (spec §4): id = first event threadId, finalMessage = last
|
|
270
|
+
* TEXT_MESSAGE_CONTENT, outcome = last RUN_FINISHED/RUN_ERROR with the
|
|
271
|
+
* amux exit code as the final authority. When the stream ends without a
|
|
272
|
+
* terminal event, the exit-code synthesis rule closes the pair — a
|
|
273
|
+
* defensive backstop one layer up from amux's own guarantee. */
|
|
274
|
+
export async function runAmuxSession(
|
|
275
|
+
request: AmuxInvocation,
|
|
276
|
+
options: AmuxRunOptions = {},
|
|
277
|
+
): Promise<AmuxRunOutcome> {
|
|
278
|
+
const command = resolveAmuxCommand(options);
|
|
279
|
+
const argv = [...command, ...amuxInvocationArgv(request)];
|
|
280
|
+
// Win32 default-PATH spawns ride the powershell relay (the rg precedent:
|
|
281
|
+
// the direct bun→tool edge is Defender-taxed, and npm .cmd shims do not
|
|
282
|
+
// resolve without it). Explicit overrides (context or UKP_AMUX) are
|
|
283
|
+
// spawned as given — the rg rule that test injections stay unwrapped.
|
|
284
|
+
const explicitCommand = options.amuxCommand !== undefined || amuxCommandFromEnvironment() !== undefined;
|
|
285
|
+
const spawnArgv = process.platform === "win32" && !explicitCommand
|
|
286
|
+
? toolRelayArgv(argv, { notFoundGuard: true })
|
|
287
|
+
: argv;
|
|
288
|
+
|
|
289
|
+
// Spec §7.2 (local single-user tier): the process environment equals
|
|
290
|
+
// the caller's, plus the shared timeout knob — amux runs its own
|
|
291
|
+
// substrate timer on the same deadline instead of a second default.
|
|
292
|
+
return runDerivedAmuxProcess({
|
|
293
|
+
spawnArgv,
|
|
294
|
+
env: { ...process.env, AMUX_TIMEOUT_MS: String(sessionSubstrateTimeoutMs()) },
|
|
295
|
+
timeoutMs: sessionSubstrateTimeoutMs(),
|
|
296
|
+
backstopKind: request.kind,
|
|
297
|
+
backstopSessionId: request.kind === "send" ? request.sessionId : undefined,
|
|
298
|
+
unavailable: { command, explicit: explicitCommand },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- The derive kernel: spawn one subprocess carrying an AG-UI JSONL
|
|
303
|
+
// amux event stream on stdout, run the wall timer, and derive id / outcome
|
|
304
|
+
// / report / eventLines.
|
|
305
|
+
async function runDerivedAmuxProcess(run: {
|
|
306
|
+
spawnArgv: readonly string[];
|
|
307
|
+
env: Record<string, string | undefined>;
|
|
308
|
+
timeoutMs: number;
|
|
309
|
+
backstopKind: AmuxInvocation["kind"];
|
|
310
|
+
backstopSessionId: string | undefined;
|
|
311
|
+
unavailable: { command: readonly string[]; explicit: boolean };
|
|
312
|
+
}): Promise<AmuxRunOutcome> {
|
|
313
|
+
let child: import("bun").Subprocess<"ignore", "pipe", "pipe">;
|
|
314
|
+
try {
|
|
315
|
+
child = Bun.spawn([...run.spawnArgv], {
|
|
316
|
+
env: run.env,
|
|
317
|
+
stdin: "ignore",
|
|
318
|
+
stdout: "pipe",
|
|
319
|
+
stderr: "pipe",
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
throw new SessionError("substrate-unavailable", amuxUnavailableMessage(run.unavailable.command, "failed to start"), {
|
|
323
|
+
cause: error,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const timedOut = { value: false };
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
timedOut.value = true;
|
|
330
|
+
child.kill("SIGTERM");
|
|
331
|
+
}, run.timeoutMs);
|
|
332
|
+
|
|
333
|
+
const eventLines: string[] = [];
|
|
334
|
+
let threadId: string | undefined;
|
|
335
|
+
let finalMessage: string | undefined;
|
|
336
|
+
let terminal: { kind: "finished" | "failed"; runId?: string } | undefined;
|
|
337
|
+
let lastRunId: string | undefined;
|
|
338
|
+
|
|
339
|
+
const drainStderr = async (): Promise<string> => {
|
|
340
|
+
const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
|
|
341
|
+
const decoder = new TextDecoder();
|
|
342
|
+
let text = "";
|
|
343
|
+
for (;;) {
|
|
344
|
+
const { done, value } = await reader.read();
|
|
345
|
+
if (done) break;
|
|
346
|
+
text += decoder.decode(value, { stream: true });
|
|
347
|
+
}
|
|
348
|
+
return text;
|
|
349
|
+
};
|
|
350
|
+
const drainStdout = async (): Promise<void> => {
|
|
351
|
+
const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
|
|
352
|
+
const decoder = new TextDecoder();
|
|
353
|
+
let buffer = "";
|
|
354
|
+
for (;;) {
|
|
355
|
+
const { done, value } = await reader.read();
|
|
356
|
+
if (done) break;
|
|
357
|
+
buffer += decoder.decode(value, { stream: true });
|
|
358
|
+
let newline = buffer.indexOf("\n");
|
|
359
|
+
while (newline !== -1) {
|
|
360
|
+
const line = buffer.slice(0, newline).trim();
|
|
361
|
+
buffer = buffer.slice(newline + 1);
|
|
362
|
+
if (line === "") continue;
|
|
363
|
+
eventLines.push(line);
|
|
364
|
+
let event: unknown;
|
|
365
|
+
try {
|
|
366
|
+
event = JSON.parse(line);
|
|
367
|
+
} catch {
|
|
368
|
+
continue; // Non-JSON noise on the events channel: passed through, never derived from.
|
|
369
|
+
}
|
|
370
|
+
if (!isRecord(event)) continue;
|
|
371
|
+
if (threadId === undefined && typeof event.threadId === "string") threadId = event.threadId;
|
|
372
|
+
if (typeof event.runId === "string") lastRunId = event.runId;
|
|
373
|
+
if (event.type === "TEXT_MESSAGE_CONTENT" && typeof event.content === "string") {
|
|
374
|
+
finalMessage = event.content;
|
|
375
|
+
}
|
|
376
|
+
if (event.type === "RUN_FINISHED") terminal = { kind: "finished", runId: lastRunId };
|
|
377
|
+
if (event.type === "RUN_ERROR") terminal = { kind: "failed", runId: lastRunId };
|
|
378
|
+
newline = buffer.indexOf("\n");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
let stderrText = "";
|
|
384
|
+
let amuxExitCode: number | null = null;
|
|
385
|
+
try {
|
|
386
|
+
await Promise.all([
|
|
387
|
+
drainStdout(),
|
|
388
|
+
drainStderr().then((text) => {
|
|
389
|
+
stderrText = text;
|
|
390
|
+
}),
|
|
391
|
+
]);
|
|
392
|
+
amuxExitCode = await child.exited;
|
|
393
|
+
} finally {
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const stderrTail = stderrText.trim().split(/\r?\n/).filter((line) => line !== "").slice(-3).join(" | ");
|
|
398
|
+
const tailSuffix = stderrTail === "" ? "" : `: ${stderrTail}`;
|
|
399
|
+
const classified = parseAmuxClassifiedLine(stderrText);
|
|
400
|
+
|
|
401
|
+
if (timedOut.value) {
|
|
402
|
+
throw new SessionError(
|
|
403
|
+
"substrate-unavailable",
|
|
404
|
+
`amux timed out after ${run.timeoutMs / 1000}s (set UKP_PROVIDER_TIMEOUT_MS to adjust)`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (amuxExitCode === TOOL_NOT_FOUND_EXIT && process.platform === "win32" && !run.unavailable.explicit) {
|
|
408
|
+
throw new SessionError("substrate-unavailable", amuxUnavailableMessage(run.unavailable.command, "not found on PATH"));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (eventLines.length === 0) {
|
|
412
|
+
// amux produced no event stream: a mechanism/substrate failure before
|
|
413
|
+
// the first event (amux classifies its own; the classes inherit).
|
|
414
|
+
if (classified !== undefined) {
|
|
415
|
+
throw new SessionError(classified.errorClass, inheritAmuxMessage(classified.message));
|
|
416
|
+
}
|
|
417
|
+
if (amuxExitCode === 2) {
|
|
418
|
+
throw new SessionError("usage", `amux exited with a usage error (code 2)${tailSuffix}`);
|
|
419
|
+
}
|
|
420
|
+
throw new SessionError("substrate-unavailable", `amux exited without events (code ${amuxExitCode ?? "signal"})${tailSuffix}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (terminal === undefined) {
|
|
424
|
+
// Backstop synthesis (amux already closes every pair; this covers an
|
|
425
|
+
// amux crash mid-stream): the exit code decides the boundary.
|
|
426
|
+
const failed = amuxExitCode !== 0;
|
|
427
|
+
const event = {
|
|
428
|
+
type: failed ? "RUN_ERROR" : "RUN_FINISHED",
|
|
429
|
+
threadId: threadId ?? (run.backstopKind === "send" && run.backstopSessionId !== undefined
|
|
430
|
+
? run.backstopSessionId
|
|
431
|
+
: randomUUID()),
|
|
432
|
+
runId: lastRunId ?? randomUUID(),
|
|
433
|
+
...(failed ? { message: `amux exited with code ${amuxExitCode ?? "signal"}` } : {}),
|
|
434
|
+
};
|
|
435
|
+
eventLines.push(JSON.stringify(event));
|
|
436
|
+
terminal = { kind: failed ? "failed" : "finished", runId: event.runId };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The process is the final authority on its own outcome: a non-zero
|
|
440
|
+
// amux exit fails the run even when the stream claimed RUN_FINISHED.
|
|
441
|
+
const failed = amuxExitCode !== 0 || terminal.kind === "failed";
|
|
442
|
+
return {
|
|
443
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
444
|
+
outcome: failed ? "failed" : "finished",
|
|
445
|
+
...(finalMessage === undefined ? {} : { finalMessage }),
|
|
446
|
+
...(failed
|
|
447
|
+
? {
|
|
448
|
+
detail: terminal.kind === "failed" && terminal.runId !== undefined
|
|
449
|
+
? `see RUN_ERROR on run ${terminal.runId}`
|
|
450
|
+
: `amux exited with code ${amuxExitCode ?? "signal"}`,
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
453
|
+
eventLines,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { hostname, networkInterfaces } from "node:os";
|
|
4
4
|
import { X509Certificate, createHash } from "node:crypto";
|
|
@@ -124,8 +124,12 @@ export interface SelfSignedIdentity {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Idempotent self-signed identity for `ukp serve --tls`: persisted under
|
|
127
|
-
*
|
|
128
|
-
*
|
|
127
|
+
* the machine home the caller resolves (`~/.ukp/tls/<endpoint-name>/` in
|
|
128
|
+
* endpoint mode, `~/.ukp/tls/` flat in door mode — the registry's
|
|
129
|
+
* directory, machine-scoped by adjudication 2026-11-01, inbox
|
|
130
|
+
* service-dir-layering item 2; the pre-2026-11-01 home was
|
|
131
|
+
* `<serviceFolder>/.ukp/tls/`, relocated by migrateLegacyServiceTlsDir).
|
|
132
|
+
* openssl signs
|
|
129
133
|
* it because node:crypto cannot issue X.509; the
|
|
130
134
|
* identity is the keypair, so losing the key loses the identity (clients
|
|
131
135
|
* re-register). `extraSanEntries` (the `--tls-san` flag, already normalized
|
|
@@ -228,3 +232,50 @@ function runOpenssl(args: readonly string[], outputs: readonly string[]): void {
|
|
|
228
232
|
);
|
|
229
233
|
}
|
|
230
234
|
}
|
|
235
|
+
|
|
236
|
+
/** One-time relocation of a Service folder's TLS identity into its machine
|
|
237
|
+
* home (adjudication 2026-11-01, inbox service-dir-layering item 2): the
|
|
238
|
+
* identity is machine-scoped material — its SAN coverage names THIS host's
|
|
239
|
+
* interfaces — so the keypair moves from the pre-2026-11-01
|
|
240
|
+
* `<serviceFolder>/.ukp/tls/` to the caller-resolved machine home,
|
|
241
|
+
* untouched (the SPKI pin every client holds survives; nothing re-signs —
|
|
242
|
+
* INV-04 renewal semantics by construction). The caller swaps the retired
|
|
243
|
+
* `/tls/` ignore rule in the same gesture.
|
|
244
|
+
*
|
|
245
|
+
* `occupied`: the machine home already holds a complete identity while the
|
|
246
|
+
* legacy dir also exists (a folder copied between machines rather than
|
|
247
|
+
* cloned through git). The stale duplicate stays put — silently deleting
|
|
248
|
+
* key material is not this step's call, and the legacy ignore rule keeps
|
|
249
|
+
* guarding it. */
|
|
250
|
+
export function migrateLegacyServiceTlsDir(
|
|
251
|
+
serviceFolder: string,
|
|
252
|
+
identityDir: string,
|
|
253
|
+
): "moved" | "absent" | "occupied" {
|
|
254
|
+
const legacyDir = join(serviceFolder, ".ukp", "tls");
|
|
255
|
+
if (!existsSync(legacyDir)) return "absent";
|
|
256
|
+
if (existsSync(join(identityDir, "cert.pem")) && existsSync(join(identityDir, "key.pem"))) {
|
|
257
|
+
return "occupied";
|
|
258
|
+
}
|
|
259
|
+
mkdirSync(identityDir, { recursive: true, mode: 0o700 });
|
|
260
|
+
for (const name of ["cert.pem", "key.pem"] as const) {
|
|
261
|
+
const source = join(legacyDir, name);
|
|
262
|
+
if (existsSync(source)) moveFileStrict(source, join(identityDir, name));
|
|
263
|
+
}
|
|
264
|
+
// With the material out, the whole legacy dir goes: anything left behind
|
|
265
|
+
// (.lock residue from a crashed serve) is transient state, not identity.
|
|
266
|
+
rmSync(legacyDir, { recursive: true, force: true });
|
|
267
|
+
return "moved";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** rename with a cross-device fallback (a service folder and the user home
|
|
271
|
+
* can live on different drives); the key keeps its 0600 either way. */
|
|
272
|
+
function moveFileStrict(source: string, target: string): void {
|
|
273
|
+
try {
|
|
274
|
+
renameSync(source, target);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
|
|
277
|
+
copyFileSync(source, target);
|
|
278
|
+
chmodSync(target, 0o600);
|
|
279
|
+
unlinkSync(source);
|
|
280
|
+
}
|
|
281
|
+
}
|