@f5-sales-demo/xcsh 19.63.7 → 19.64.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/package.json +16 -8
- package/src/browser/bridge-cert.ts +376 -0
- package/src/browser/capabilities.generated.ts +10 -4
- package/src/browser/capabilities.json +16 -3
- package/src/browser/chat-conformance.json +269 -1
- package/src/browser/chat-handler.ts +47 -0
- package/src/browser/chat-protocol.ts +67 -0
- package/src/browser/extension-bridge.ts +142 -25
- package/src/commands/manager.ts +3 -0
- package/src/commands/worker.ts +14 -2
- package/src/{modes/rpc → host-tools}/host-tools.ts +32 -3
- package/src/host-tools/index.ts +22 -0
- package/src/host-tools/types.ts +48 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +299 -299
- package/src/main.ts +9 -2
- package/src/modes/rpc/rpc-mode.ts +6 -26
- package/src/modes/rpc/rpc-types.ts +18 -39
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.64.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
58
58
|
"@mozilla/readability": "^0.6",
|
|
59
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
60
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
61
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
62
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
63
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
64
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
65
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
59
|
+
"@f5-sales-demo/xcsh-stats": "19.64.0",
|
|
60
|
+
"@f5-sales-demo/pi-agent-core": "19.64.0",
|
|
61
|
+
"@f5-sales-demo/pi-ai": "19.64.0",
|
|
62
|
+
"@f5-sales-demo/pi-natives": "19.64.0",
|
|
63
|
+
"@f5-sales-demo/pi-resource-management": "19.64.0",
|
|
64
|
+
"@f5-sales-demo/pi-tui": "19.64.0",
|
|
65
|
+
"@f5-sales-demo/pi-utils": "19.64.0",
|
|
66
66
|
"@sinclair/typebox": "^0.34",
|
|
67
67
|
"@xterm/headless": "^6.0",
|
|
68
68
|
"ajv": "^8.20",
|
|
@@ -536,6 +536,14 @@
|
|
|
536
536
|
"types": "./src/extensibility/hooks/*.ts",
|
|
537
537
|
"import": "./src/extensibility/hooks/*.ts"
|
|
538
538
|
},
|
|
539
|
+
"./host-tools": {
|
|
540
|
+
"types": "./src/host-tools/index.ts",
|
|
541
|
+
"import": "./src/host-tools/index.ts"
|
|
542
|
+
},
|
|
543
|
+
"./host-tools/*": {
|
|
544
|
+
"types": "./src/host-tools/*.ts",
|
|
545
|
+
"import": "./src/host-tools/*.ts"
|
|
546
|
+
},
|
|
539
547
|
"./*.js": "./src/*.ts"
|
|
540
548
|
}
|
|
541
549
|
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge TLS certificate provisioning for the `wss://` extension-bridge listener.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `claude-office/proxy.mjs` (`certDaysLeft` / `provisionPublicCert` /
|
|
5
|
+
* self-signed fallback / `loadCtx` / SNI selection) into idiomatic TypeScript.
|
|
6
|
+
*
|
|
7
|
+
* The bridge terminates TLS with a **publicly-trusted `*.local-ip.sh`** Let's
|
|
8
|
+
* Encrypt certificate served on the host `127-0-0-1.local-ip.sh` (which resolves
|
|
9
|
+
* to 127.0.0.1). Because the cert chains to a public CA and the hostname matches
|
|
10
|
+
* the `*.local-ip.sh` SAN, WebKit/Chromium open `wss://127-0-0-1.local-ip.sh:<port>`
|
|
11
|
+
* with TLS verification ON and **no local trust / MDM step**. A self-signed
|
|
12
|
+
* localhost cert is a dev-only fallback (it will NOT satisfy WebKit).
|
|
13
|
+
*
|
|
14
|
+
* Design: a clean PURE-vs-I/O split. The freshness gate ({@link certDaysLeft},
|
|
15
|
+
* {@link isCertStale}), default-cert selection ({@link selectServerCert}) and SNI
|
|
16
|
+
* routing ({@link isLocalIpServerName}, {@link selectSniContext}) are pure and unit
|
|
17
|
+
* tested without sockets, fs, or a keychain. The I/O entry points
|
|
18
|
+
* ({@link provisionPublicCert}, {@link provisionSelfSigned}, {@link loadCtx}) accept
|
|
19
|
+
* injectable `fetch`/`fs`/validation dependencies so the download + write path is
|
|
20
|
+
* tested with mocks.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { X509Certificate } from "node:crypto";
|
|
24
|
+
import {
|
|
25
|
+
chmodSync as nodeChmodSync,
|
|
26
|
+
existsSync as nodeExistsSync,
|
|
27
|
+
mkdirSync as nodeMkdirSync,
|
|
28
|
+
readFileSync as nodeReadFileSync,
|
|
29
|
+
writeFileSync as nodeWriteFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { dirname, join } from "node:path";
|
|
32
|
+
import { createSecureContext, type SecureContext } from "node:tls";
|
|
33
|
+
import { getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
34
|
+
|
|
35
|
+
// ---- public constants (safe to ship) ----------------------------------------
|
|
36
|
+
|
|
37
|
+
/** local-ip.sh publicly-trusted `*.local-ip.sh` certificate (PEM). */
|
|
38
|
+
export const LOCALIP_CERT_URL = "https://local-ip.sh/server.pem";
|
|
39
|
+
/** local-ip.sh matching private key (PEM). */
|
|
40
|
+
export const LOCALIP_KEY_URL = "https://local-ip.sh/server.key";
|
|
41
|
+
/** The only hostname that resolves to 127.0.0.1 AND matches the `*.local-ip.sh` SAN. */
|
|
42
|
+
export const LOCALIP_HOST = "127-0-0-1.local-ip.sh";
|
|
43
|
+
/** Weekly background refresh interval for the provisioned public cert. */
|
|
44
|
+
export const REFRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
45
|
+
/** Refresh the cert once it is within this many days of expiry. */
|
|
46
|
+
export const CERT_STALE_THRESHOLD_DAYS = 30;
|
|
47
|
+
|
|
48
|
+
// ---- storage paths (under getXCSHConfigDir()/bridge/) ------------------------
|
|
49
|
+
|
|
50
|
+
/** Directory holding the bridge's provisioned certs (`<cfg>/bridge`). */
|
|
51
|
+
export function bridgeCertDir(): string {
|
|
52
|
+
return join(getXCSHConfigDir(), "bridge");
|
|
53
|
+
}
|
|
54
|
+
/** Path to the public `*.local-ip.sh` certificate. */
|
|
55
|
+
export function publicCertPath(): string {
|
|
56
|
+
return join(bridgeCertDir(), "localip.pem");
|
|
57
|
+
}
|
|
58
|
+
/** Path to the public `*.local-ip.sh` private key. */
|
|
59
|
+
export function publicKeyPath(): string {
|
|
60
|
+
return join(bridgeCertDir(), "localip.key");
|
|
61
|
+
}
|
|
62
|
+
/** Path to the self-signed localhost certificate (dev fallback). */
|
|
63
|
+
export function selfSignedCertPath(): string {
|
|
64
|
+
return join(bridgeCertDir(), "localhost.pem");
|
|
65
|
+
}
|
|
66
|
+
/** Path to the self-signed localhost private key (dev fallback). */
|
|
67
|
+
export function selfSignedKeyPath(): string {
|
|
68
|
+
return join(bridgeCertDir(), "localhost.key");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- pure: freshness ---------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Days remaining until the certificate's `notAfter`, or `-1` if the input is not
|
|
75
|
+
* a parseable X.509 certificate. A parseable-but-expired cert returns a negative
|
|
76
|
+
* (fractional) number, distinct from the `-1` garbage sentinel.
|
|
77
|
+
*/
|
|
78
|
+
export function certDaysLeft(pem: string | Buffer): number {
|
|
79
|
+
try {
|
|
80
|
+
const validTo = new Date(new X509Certificate(pem).validTo).getTime();
|
|
81
|
+
return (validTo - Date.now()) / 86_400_000;
|
|
82
|
+
} catch {
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A cert is stale (should be re-provisioned) once it is within the threshold of expiry. */
|
|
88
|
+
export function isCertStale(pem: string | Buffer, thresholdDays = CERT_STALE_THRESHOLD_DAYS): boolean {
|
|
89
|
+
return certDaysLeft(pem) < thresholdDays;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- pure: content validation ------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Heuristic guard that a downloaded body is a PEM certificate (not an HTML error page). */
|
|
95
|
+
export function looksLikeCertPem(body: string): boolean {
|
|
96
|
+
return body.includes("BEGIN CERTIFICATE");
|
|
97
|
+
}
|
|
98
|
+
/** Heuristic guard that a downloaded body is a PEM private key. */
|
|
99
|
+
export function looksLikeKeyPem(body: string): boolean {
|
|
100
|
+
return body.includes("PRIVATE KEY");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- pure: default-cert selection --------------------------------------------
|
|
104
|
+
|
|
105
|
+
/** Which cert the listener should serve by default, given which pairs are available. */
|
|
106
|
+
export function selectServerCert(available: { public: boolean; selfSigned: boolean }): "public" | "self-signed" | null {
|
|
107
|
+
if (available.public) return "public";
|
|
108
|
+
if (available.selfSigned) return "self-signed";
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- pure: SNI routing -------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/** True only for a `*.local-ip.sh` subdomain (never the bare apex, never a lookalike). */
|
|
115
|
+
export function isLocalIpServerName(name: string | null | undefined): boolean {
|
|
116
|
+
return typeof name === "string" && name.endsWith(".local-ip.sh");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick the TLS context for an incoming SNI servername: the public `*.local-ip.sh`
|
|
121
|
+
* context for a matching name, otherwise the self-signed/localhost context. Falls
|
|
122
|
+
* back to whichever context is present when the preferred one is missing.
|
|
123
|
+
*/
|
|
124
|
+
export function selectSniContext(
|
|
125
|
+
servername: string | null | undefined,
|
|
126
|
+
ctx: { localIp: SecureContext | null; selfSigned: SecureContext | null },
|
|
127
|
+
): SecureContext | null {
|
|
128
|
+
if (isLocalIpServerName(servername) && ctx.localIp) return ctx.localIp;
|
|
129
|
+
return ctx.selfSigned ?? ctx.localIp;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- I/O: dependency seams ---------------------------------------------------
|
|
133
|
+
|
|
134
|
+
type FetchResponse = { ok: boolean; status: number; text(): Promise<string> };
|
|
135
|
+
|
|
136
|
+
/** Injectable I/O dependencies (real implementations used when omitted). */
|
|
137
|
+
export interface CertIoDeps {
|
|
138
|
+
certFile?: string;
|
|
139
|
+
keyFile?: string;
|
|
140
|
+
fetch?: (url: string) => Promise<FetchResponse>;
|
|
141
|
+
existsSync?: (path: string) => boolean;
|
|
142
|
+
readFileSync?: (path: string) => Buffer;
|
|
143
|
+
writeFileSync?: (path: string, data: string, opts?: { mode?: number }) => void;
|
|
144
|
+
mkdirSync?: (path: string, opts: { recursive: boolean }) => void;
|
|
145
|
+
/** Validate that the cert and key form a matching pair (throws on mismatch). */
|
|
146
|
+
validatePair?: (cert: string, key: string) => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function defaultValidatePair(cert: string, key: string): void {
|
|
150
|
+
// Throws on key/cert mismatch — the same guard claude-office relies on.
|
|
151
|
+
createSecureContext({ cert, key });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type ProvisionReason = "fresh" | "provisioned" | "invalid-content" | "provision-failed";
|
|
155
|
+
|
|
156
|
+
export interface ProvisionResult {
|
|
157
|
+
/** Whether a network fetch was attempted (false when the existing cert was fresh). */
|
|
158
|
+
fetched: boolean;
|
|
159
|
+
/** Whether new cert/key files were written to disk. */
|
|
160
|
+
written: boolean;
|
|
161
|
+
reason: ProvisionReason;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ensure a fresh public `*.local-ip.sh` cert+key exist on disk.
|
|
166
|
+
*
|
|
167
|
+
* Mirrors `claude-office`'s `provisionPublicCert`: keep the existing cert when it
|
|
168
|
+
* is present and not stale ({@link isCertStale}); otherwise fetch cert+key and
|
|
169
|
+
* write them ONLY when the download is well-formed PEM AND the pair validates.
|
|
170
|
+
* Never throws — provisioning is best-effort; on any failure the existing (or
|
|
171
|
+
* self-signed) cert is left in place.
|
|
172
|
+
*/
|
|
173
|
+
export async function provisionPublicCert(deps: CertIoDeps = {}): Promise<ProvisionResult> {
|
|
174
|
+
const certFile = deps.certFile ?? publicCertPath();
|
|
175
|
+
const keyFile = deps.keyFile ?? publicKeyPath();
|
|
176
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
177
|
+
const read = deps.readFileSync ?? nodeReadFileSync;
|
|
178
|
+
const write = deps.writeFileSync ?? ((p, d, o) => nodeWriteFileSync(p, d, o));
|
|
179
|
+
const mkdirp = deps.mkdirSync ?? ((p, o) => nodeMkdirSync(p, o));
|
|
180
|
+
const doFetch = deps.fetch ?? ((url: string) => fetch(url) as Promise<FetchResponse>);
|
|
181
|
+
const validate = deps.validatePair ?? defaultValidatePair;
|
|
182
|
+
|
|
183
|
+
// Freshness gate: an existing, non-stale cert is kept as-is (no network hit).
|
|
184
|
+
if (exists(certFile) && exists(keyFile) && !isCertStale(read(certFile))) {
|
|
185
|
+
return { fetched: false, written: false, reason: "fresh" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const [cert, key] = await Promise.all([
|
|
190
|
+
doFetch(LOCALIP_CERT_URL).then(r => {
|
|
191
|
+
if (!r.ok) throw new Error(`cert HTTP ${r.status}`);
|
|
192
|
+
return r.text();
|
|
193
|
+
}),
|
|
194
|
+
doFetch(LOCALIP_KEY_URL).then(r => {
|
|
195
|
+
if (!r.ok) throw new Error(`key HTTP ${r.status}`);
|
|
196
|
+
return r.text();
|
|
197
|
+
}),
|
|
198
|
+
]);
|
|
199
|
+
if (!looksLikeCertPem(cert) || !looksLikeKeyPem(key)) {
|
|
200
|
+
return { fetched: true, written: false, reason: "invalid-content" };
|
|
201
|
+
}
|
|
202
|
+
validate(cert, key); // throws on key/cert mismatch
|
|
203
|
+
mkdirp(dirname(certFile), { recursive: true });
|
|
204
|
+
write(certFile, cert);
|
|
205
|
+
write(keyFile, key, { mode: 0o600 });
|
|
206
|
+
return { fetched: true, written: true, reason: "provisioned" };
|
|
207
|
+
} catch {
|
|
208
|
+
// Keep the existing / self-signed cert; never let provisioning crash boot.
|
|
209
|
+
return { fetched: true, written: false, reason: "provision-failed" };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generate a self-signed localhost cert+key (dev-only fallback) if absent.
|
|
215
|
+
* Returns true when a usable pair exists afterwards. Requires `openssl` on PATH.
|
|
216
|
+
*/
|
|
217
|
+
export function provisionSelfSigned(deps: CertIoDeps = {}): boolean {
|
|
218
|
+
const certFile = deps.certFile ?? selfSignedCertPath();
|
|
219
|
+
const keyFile = deps.keyFile ?? selfSignedKeyPath();
|
|
220
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
221
|
+
const mkdirp = deps.mkdirSync ?? ((p, o) => nodeMkdirSync(p, o));
|
|
222
|
+
|
|
223
|
+
if (exists(certFile) && exists(keyFile)) return true;
|
|
224
|
+
try {
|
|
225
|
+
mkdirp(dirname(certFile), { recursive: true });
|
|
226
|
+
execFileSync(
|
|
227
|
+
"openssl",
|
|
228
|
+
[
|
|
229
|
+
"req",
|
|
230
|
+
"-x509",
|
|
231
|
+
"-newkey",
|
|
232
|
+
"rsa:2048",
|
|
233
|
+
"-sha256",
|
|
234
|
+
"-days",
|
|
235
|
+
"800",
|
|
236
|
+
"-nodes",
|
|
237
|
+
"-keyout",
|
|
238
|
+
keyFile,
|
|
239
|
+
"-out",
|
|
240
|
+
certFile,
|
|
241
|
+
"-subj",
|
|
242
|
+
"/CN=localhost",
|
|
243
|
+
"-addext",
|
|
244
|
+
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
|
245
|
+
"-addext",
|
|
246
|
+
"extendedKeyUsage=serverAuth",
|
|
247
|
+
"-addext",
|
|
248
|
+
"basicConstraints=critical,CA:FALSE",
|
|
249
|
+
],
|
|
250
|
+
{ stdio: "ignore" },
|
|
251
|
+
);
|
|
252
|
+
nodeChmodSync(keyFile, 0o600);
|
|
253
|
+
return true;
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Load a {@link SecureContext} from a cert/key file pair, or `null` if the files
|
|
261
|
+
* are missing or fail to load (e.g. a mismatched pair).
|
|
262
|
+
*/
|
|
263
|
+
export function loadCtx(
|
|
264
|
+
certFile: string,
|
|
265
|
+
keyFile: string,
|
|
266
|
+
deps: Pick<CertIoDeps, "existsSync" | "readFileSync"> = {},
|
|
267
|
+
): SecureContext | null {
|
|
268
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
269
|
+
const read = deps.readFileSync ?? nodeReadFileSync;
|
|
270
|
+
if (!exists(certFile) || !exists(keyFile)) return null;
|
|
271
|
+
try {
|
|
272
|
+
return createSecureContext({ cert: read(certFile), key: read(keyFile) });
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Load a cert/key file pair as PEM strings for the `wss` listener, or `null` when
|
|
280
|
+
* the files are missing/unreadable or the content is not well-formed PEM. Unlike
|
|
281
|
+
* {@link loadCtx} this returns the raw PEM (which is what `Bun.serve({ tls })` wants).
|
|
282
|
+
*/
|
|
283
|
+
export function loadPemPair(
|
|
284
|
+
certFile: string,
|
|
285
|
+
keyFile: string,
|
|
286
|
+
deps: Pick<CertIoDeps, "existsSync" | "readFileSync"> = {},
|
|
287
|
+
): ResolvedBridgeTls | null {
|
|
288
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
289
|
+
const read = deps.readFileSync ?? nodeReadFileSync;
|
|
290
|
+
if (!exists(certFile) || !exists(keyFile)) return null;
|
|
291
|
+
try {
|
|
292
|
+
const cert = read(certFile).toString("utf8");
|
|
293
|
+
const key = read(keyFile).toString("utf8");
|
|
294
|
+
if (!looksLikeCertPem(cert) || !looksLikeKeyPem(key)) return null;
|
|
295
|
+
return { cert, key };
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- boot seam: provision + refresh + resolve TLS material -------------------
|
|
302
|
+
|
|
303
|
+
/** PEM cert/key material for the additive `wss` listener (matches `BridgeTls`). */
|
|
304
|
+
export interface ResolvedBridgeTls {
|
|
305
|
+
cert: string;
|
|
306
|
+
key: string;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Process-wide latch so the weekly refresh timer is armed at most once. */
|
|
310
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Arm the weekly background refresh of the public cert (idempotent — latched to
|
|
314
|
+
* fire at most once per process). The timer is `.unref()`d so it never keeps the
|
|
315
|
+
* process alive, and each tick is best-effort ({@link provisionPublicCert} never
|
|
316
|
+
* throws). A no-op after the first call.
|
|
317
|
+
*/
|
|
318
|
+
export function startBridgeCertRefresh(io: CertIoDeps = {}): void {
|
|
319
|
+
if (refreshTimer) return;
|
|
320
|
+
refreshTimer = setInterval(() => {
|
|
321
|
+
void provisionPublicCert(io);
|
|
322
|
+
}, REFRESH_MS);
|
|
323
|
+
// Do not hold the event loop open just for the refresh cadence.
|
|
324
|
+
refreshTimer.unref?.();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Injectable seams for {@link resolveBridgeTls} (real implementations when omitted). */
|
|
328
|
+
export interface ResolveBridgeTlsDeps {
|
|
329
|
+
/** Provision the public `*.local-ip.sh` cert (the network path). */
|
|
330
|
+
provision?: (io?: CertIoDeps) => Promise<ProvisionResult>;
|
|
331
|
+
/** Provision the self-signed dev fallback. */
|
|
332
|
+
provisionSelfSigned?: (io?: CertIoDeps) => boolean;
|
|
333
|
+
/** Read a cert/key pair off disk as PEM strings, or null when unavailable. */
|
|
334
|
+
loadPair?: (certFile: string, keyFile: string) => ResolvedBridgeTls | null;
|
|
335
|
+
/** Arm the weekly refresh timer (idempotent). */
|
|
336
|
+
startRefresh?: (io?: CertIoDeps) => void;
|
|
337
|
+
/** Underlying fetch/fs seams forwarded to provisioning + loading. */
|
|
338
|
+
io?: CertIoDeps;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve the TLS material for the bridge's `wss` listener, provisioning it first.
|
|
343
|
+
*
|
|
344
|
+
* IMPORTANT — TTFT: this awaits {@link provisionPublicCert}, which may perform a
|
|
345
|
+
* network fetch on a cold/stale cache. Callers MUST run it OUTSIDE (before) the
|
|
346
|
+
* `session:bridgeListen` `logger.time(...)` span so a cold fetch never inflates
|
|
347
|
+
* the measured bridge-ready time. Because provisioning is provision-once-cached, a
|
|
348
|
+
* warm boot is a fast on-disk cache hit and adds no measurable latency.
|
|
349
|
+
*
|
|
350
|
+
* Preference order: the publicly-trusted `*.local-ip.sh` cert, then a self-signed
|
|
351
|
+
* dev cert. Returns `undefined` when neither can be provisioned or loaded (offline
|
|
352
|
+
* / `local-ip.sh` unreachable / no `openssl`) so the bridge starts **ws-only** —
|
|
353
|
+
* the caller passes `undefined` as the `tls` option and the bridge does not crash.
|
|
354
|
+
* Never throws.
|
|
355
|
+
*/
|
|
356
|
+
export async function resolveBridgeTls(deps: ResolveBridgeTlsDeps = {}): Promise<ResolvedBridgeTls | undefined> {
|
|
357
|
+
const io = deps.io ?? {};
|
|
358
|
+
const provision = deps.provision ?? provisionPublicCert;
|
|
359
|
+
const provisionFallback = deps.provisionSelfSigned ?? provisionSelfSigned;
|
|
360
|
+
const load = deps.loadPair ?? ((certFile: string, keyFile: string) => loadPemPair(certFile, keyFile, io));
|
|
361
|
+
const startRefresh = deps.startRefresh ?? startBridgeCertRefresh;
|
|
362
|
+
|
|
363
|
+
// Network path (best-effort, never throws) — run by the caller before the TTFT span.
|
|
364
|
+
await provision(io);
|
|
365
|
+
// Keep the cert fresh in the background; latched so this only arms one timer.
|
|
366
|
+
startRefresh(io);
|
|
367
|
+
|
|
368
|
+
// Prefer the publicly-trusted cert; the self-signed pair is a dev-only fallback.
|
|
369
|
+
const pub = load(io.certFile ?? publicCertPath(), io.keyFile ?? publicKeyPath());
|
|
370
|
+
if (pub) return pub;
|
|
371
|
+
if (provisionFallback(io)) {
|
|
372
|
+
const self = load(selfSignedCertPath(), selfSignedKeyPath());
|
|
373
|
+
if (self) return self;
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
@@ -26,7 +26,7 @@ export interface ExtensionCapabilities {
|
|
|
26
26
|
|
|
27
27
|
export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
28
28
|
"version": "0.1.0",
|
|
29
|
-
"contractVersion": "1.
|
|
29
|
+
"contractVersion": "1.8.0",
|
|
30
30
|
"multiPortDiscovery": true,
|
|
31
31
|
"protocol": "tool_request/result",
|
|
32
32
|
"tools": [
|
|
@@ -130,7 +130,7 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
"name": "login",
|
|
133
|
-
"summary": "Drive the F5 XC OIDC/Keycloak login end-to-end.",
|
|
133
|
+
"summary": "Drive the F5 XC OIDC/Keycloak login end-to-end (production + staging consoles).",
|
|
134
134
|
"category": "navigation",
|
|
135
135
|
"params": {
|
|
136
136
|
"type": "object",
|
|
@@ -825,7 +825,13 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
825
825
|
"chat_done",
|
|
826
826
|
"chat_error",
|
|
827
827
|
"chat_stop",
|
|
828
|
-
"chat_tool_notice"
|
|
828
|
+
"chat_tool_notice",
|
|
829
|
+
"set_host_tools",
|
|
830
|
+
"set_host_tools_ack",
|
|
831
|
+
"host_tool_call",
|
|
832
|
+
"host_tool_update",
|
|
833
|
+
"host_tool_result",
|
|
834
|
+
"host_tool_cancel"
|
|
829
835
|
],
|
|
830
836
|
"description": "User ↔ xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams chat_delta tokens then a terminal chat_done (with reference links) or chat_error. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn — it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel.",
|
|
831
837
|
"promptHints": {
|
|
@@ -845,6 +851,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
845
851
|
}
|
|
846
852
|
};
|
|
847
853
|
|
|
848
|
-
export const EXTENSION_CONTRACT_VERSION = "1.
|
|
854
|
+
export const EXTENSION_CONTRACT_VERSION = "1.8.0";
|
|
849
855
|
|
|
850
856
|
export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","diag_activation","diag_ttft","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "0.1.0",
|
|
3
|
-
"contractVersion": "1.
|
|
3
|
+
"contractVersion": "1.8.0",
|
|
4
4
|
"multiPortDiscovery": true,
|
|
5
5
|
"protocol": "tool_request/result",
|
|
6
6
|
"tools": [
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
"name": "login",
|
|
103
|
-
"summary": "Drive the F5 XC OIDC/Keycloak login end-to-end.",
|
|
103
|
+
"summary": "Drive the F5 XC OIDC/Keycloak login end-to-end (production + staging consoles).",
|
|
104
104
|
"category": "navigation",
|
|
105
105
|
"params": {
|
|
106
106
|
"type": "object",
|
|
@@ -727,7 +727,20 @@
|
|
|
727
727
|
"contextTool": "get_page_context",
|
|
728
728
|
"transport": "websocket-bridge",
|
|
729
729
|
"modes": ["educational", "presentation", "configuration", "screenshot", "annotation"],
|
|
730
|
-
"messages": [
|
|
730
|
+
"messages": [
|
|
731
|
+
"chat_request",
|
|
732
|
+
"chat_delta",
|
|
733
|
+
"chat_done",
|
|
734
|
+
"chat_error",
|
|
735
|
+
"chat_stop",
|
|
736
|
+
"chat_tool_notice",
|
|
737
|
+
"set_host_tools",
|
|
738
|
+
"set_host_tools_ack",
|
|
739
|
+
"host_tool_call",
|
|
740
|
+
"host_tool_update",
|
|
741
|
+
"host_tool_result",
|
|
742
|
+
"host_tool_cancel"
|
|
743
|
+
],
|
|
731
744
|
"description": "User ↔ xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams chat_delta tokens then a terminal chat_done (with reference links) or chat_error. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn — it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel.",
|
|
732
745
|
"promptHints": {
|
|
733
746
|
"role": "You are xcsh, the AI assistant embedded in the F5 Distributed Cloud (XC) console side panel. The user is viewing a live console page; help them drive automation and understand settings and their purpose.",
|