@vecteur/cli 0.2.4 → 0.3.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/vecteur-mcp.js +29 -0
- package/bin/vecteur.js +110 -0
- package/package.json +25 -47
- package/src/client.js +399 -0
- package/src/contract.js +12 -0
- package/src/credentials.js +75 -0
- package/src/errors.js +14 -0
- package/src/format.js +91 -0
- package/src/mcp.js +98 -0
- package/LICENSE +0 -21
- package/README.md +0 -70
- package/dist/api.js +0 -89
- package/dist/commands/ask.js +0 -44
- package/dist/commands/auth.js +0 -76
- package/dist/commands/chat.js +0 -126
- package/dist/commands/projects.js +0 -20
- package/dist/config.js +0 -92
- package/dist/index.js +0 -82
- package/dist/runner.js +0 -175
- package/dist/session.js +0 -138
- package/dist/ui/App.js +0 -175
- package/dist/ui/Header.js +0 -7
- package/dist/ui/Prompt.js +0 -7
- package/dist/ui/RunStatus.js +0 -12
- package/dist/ui/logo.js +0 -24
- package/dist/ui/markdown.js +0 -38
- package/dist/update.js +0 -75
- package/dist/version.js +0 -6
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
|
|
4
|
+
import { redact } from "../src/client.js";
|
|
5
|
+
import { createVecteurMcpServer } from "../src/mcp.js";
|
|
6
|
+
|
|
7
|
+
const shutdown = new AbortController();
|
|
8
|
+
const handle = serveStdio(() => createVecteurMcpServer(undefined, shutdown.signal), {
|
|
9
|
+
onerror(error) {
|
|
10
|
+
process.stderr.write(`mcp_transport_error: ${redact(error?.message ?? "MCP transport failed")}\n`);
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
let closing;
|
|
15
|
+
function close() {
|
|
16
|
+
if (!closing) {
|
|
17
|
+
shutdown.abort();
|
|
18
|
+
closing = handle.close().catch((error) => {
|
|
19
|
+
process.stderr.write(`mcp_close_error: ${redact(error?.message ?? "MCP close failed")}\n`);
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return closing;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.stdin.once("end", () => { void close(); });
|
|
27
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
28
|
+
process.once(signal, () => { void close(); });
|
|
29
|
+
}
|
package/bin/vecteur.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { CliError, VecteurClient, configuration, redact } from "../src/client.js";
|
|
3
|
+
import { clearCredentials, credentialsPath, readCredentials, writeCredentials } from "../src/credentials.js";
|
|
4
|
+
import { canonicalJson, human, progress } from "../src/format.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read one line from stdin, with no echo concerns: the token is PIPED, never typed into argv.
|
|
8
|
+
*
|
|
9
|
+
* `--token vct_…` would put a live credential in `ps`, in `/proc/<pid>/cmdline` for every local
|
|
10
|
+
* user, and in the shell history file afterwards. That exposure is not hypothetical here —
|
|
11
|
+
* `SHIP-007` on this project's own backlog exists to rotate production credentials that reached
|
|
12
|
+
* a command line. A door must not ship a convenient way to repeat it.
|
|
13
|
+
*/
|
|
14
|
+
async function readTokenFromStdin() {
|
|
15
|
+
if (process.stdin.isTTY) {
|
|
16
|
+
throw new CliError("token_required",
|
|
17
|
+
"pipe the personal access token in: `vecteur login --url <origin> < token.txt`");
|
|
18
|
+
}
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
21
|
+
const token = Buffer.concat(chunks).toString("utf8").trim();
|
|
22
|
+
if (!token) throw new CliError("token_required", "stdin held no personal access token");
|
|
23
|
+
return token;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseCommand(argv) {
|
|
27
|
+
const json = argv.at(-1) === "--json";
|
|
28
|
+
const args = json ? argv.slice(0, -1) : [...argv];
|
|
29
|
+
const value = (candidate) => typeof candidate === "string" && candidate.length > 0 &&
|
|
30
|
+
!candidate.startsWith("-");
|
|
31
|
+
if (args.length === 3 && args[0] === "login" && args[1] === "--url" && value(args[2])) {
|
|
32
|
+
return { kind: "login", origin: args[2], json };
|
|
33
|
+
}
|
|
34
|
+
if (args.length === 1 && args[0] === "logout") return { kind: "logout", json };
|
|
35
|
+
if (args.length === 1 && args[0] === "whoami") return { kind: "whoami", json };
|
|
36
|
+
if (args.length === 3 && args[0] === "project" && args[1] === "get" && value(args[2])) {
|
|
37
|
+
return { kind: "project_get", id: args[2], json };
|
|
38
|
+
}
|
|
39
|
+
if (args.length === 6 && args[0] === "run" && args[1] === "submit" &&
|
|
40
|
+
args[2] === "--project" && value(args[3]) && args[4] === "--ask" && value(args[5])) {
|
|
41
|
+
return { kind: "run_submit", projectId: args[3], ask: args[5], json };
|
|
42
|
+
}
|
|
43
|
+
if (args.length === 3 && args[0] === "run" && args[1] === "get" && value(args[2])) {
|
|
44
|
+
return { kind: "run_get", id: args[2], json };
|
|
45
|
+
}
|
|
46
|
+
if (args.length === 3 && args[0] === "run" && args[1] === "events" && value(args[2])) {
|
|
47
|
+
return { kind: "run_events", id: args[2], json };
|
|
48
|
+
}
|
|
49
|
+
throw new CliError("usage_invalid", "Invalid command grammar");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
const command = parseCommand(process.argv.slice(2));
|
|
54
|
+
|
|
55
|
+
// The login commands run BEFORE a client is constructed. Constructing one resolves a
|
|
56
|
+
// configuration, and requiring a valid login in order to log in is the shape of bug that
|
|
57
|
+
// makes a first run impossible.
|
|
58
|
+
if (command.kind === "login") {
|
|
59
|
+
const token = await readTokenFromStdin();
|
|
60
|
+
// Shape-check HERE so the refusal names what is wrong with what was just piped in. Falling
|
|
61
|
+
// straight through to `configuration` reported "no login found: run `vecteur login`" to a
|
|
62
|
+
// user in the middle of running exactly that.
|
|
63
|
+
if (!/^vct_[A-Za-z0-9_-]{43}$/.test(token)) {
|
|
64
|
+
throw new CliError("token_invalid",
|
|
65
|
+
`stdin held ${token.length} characters; a personal access token is \`vct_\` and 43 more`);
|
|
66
|
+
}
|
|
67
|
+
// Then through the SAME function every command resolves through, so an origin this CLI
|
|
68
|
+
// would later refuse is refused now, while the user is still holding the token — not on
|
|
69
|
+
// their next command with the credential already on disk.
|
|
70
|
+
const checked = configuration({ VECTEUR_TOKEN: token, VECTEUR_BASE_URL: command.origin }, null);
|
|
71
|
+
const path = writeCredentials({ origin: checked.origin, token }, process.env);
|
|
72
|
+
process.stdout.write(command.json
|
|
73
|
+
? canonicalJson({ logged_in: true, origin: checked.origin, credentials: path })
|
|
74
|
+
: `logged in to ${checked.origin}\ncredentials: ${path}\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (command.kind === "logout") {
|
|
78
|
+
const path = clearCredentials(process.env);
|
|
79
|
+
process.stdout.write(command.json
|
|
80
|
+
? canonicalJson({ logged_in: false, credentials: path })
|
|
81
|
+
: `logged out\ncredentials removed: ${path}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (command.kind === "whoami") {
|
|
85
|
+
const saved = readCredentials(process.env);
|
|
86
|
+
// NEVER the token, not even a prefix. A door that prints part of a credential teaches the
|
|
87
|
+
// habit of pasting it somewhere, and a prefix is enough to confirm a guess.
|
|
88
|
+
process.stdout.write(command.json
|
|
89
|
+
? canonicalJson({ logged_in: Boolean(saved), origin: saved?.origin ?? null })
|
|
90
|
+
: saved ? `logged in to ${saved.origin}\n` : "not logged in\n");
|
|
91
|
+
process.exitCode = saved ? 0 : 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const client = new VecteurClient();
|
|
96
|
+
const onEvent = command.json ? null : (event) => process.stderr.write(progress(event));
|
|
97
|
+
let value;
|
|
98
|
+
if (command.kind === "project_get") value = await client.projectGet(command.id);
|
|
99
|
+
else if (command.kind === "run_submit") {
|
|
100
|
+
value = await client.runSubmit(command.projectId, command.ask, onEvent);
|
|
101
|
+
} else if (command.kind === "run_get") value = await client.runGet(command.id);
|
|
102
|
+
else value = await client.runEvents(command.id, onEvent);
|
|
103
|
+
process.stdout.write(command.json ? canonicalJson(value) : human(value));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main().catch((error) => {
|
|
107
|
+
const code = error instanceof CliError ? error.code : "internal_error";
|
|
108
|
+
process.stderr.write(`${code}: ${redact(error?.message ?? "CLI failed")}\n`);
|
|
109
|
+
process.exitCode = code === "usage_invalid" ? 2 : 1;
|
|
110
|
+
});
|
package/package.json
CHANGED
|
@@ -1,63 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vecteur/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Vecteur CLI — a thin client for the Vecteur space-engineering platform (login, ask, projects, files). Hosted brain; no IP ships.",
|
|
3
|
+
"version": "0.3.1",
|
|
5
4
|
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Vecteur <hello@vecteur.space>",
|
|
8
|
-
"homepage": "https://vecteur.space",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/vecteurspace/vecteur-cli.git"
|
|
12
|
-
},
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/vecteurspace/vecteur-cli/issues"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"vecteur",
|
|
18
|
-
"space",
|
|
19
|
-
"space-systems",
|
|
20
|
-
"engineering",
|
|
21
|
-
"cli",
|
|
22
|
-
"agent",
|
|
23
|
-
"mcp"
|
|
24
|
-
],
|
|
25
5
|
"bin": {
|
|
26
|
-
"
|
|
6
|
+
"cli": "bin/vecteur.js",
|
|
7
|
+
"vecteur": "bin/vecteur.js",
|
|
8
|
+
"vecteur-mcp": "bin/vecteur-mcp.js"
|
|
27
9
|
},
|
|
28
10
|
"files": [
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
11
|
+
"bin/vecteur-mcp.js",
|
|
12
|
+
"bin/vecteur.js",
|
|
13
|
+
"src/client.js",
|
|
14
|
+
"src/contract.js",
|
|
15
|
+
"src/credentials.js",
|
|
16
|
+
"src/errors.js",
|
|
17
|
+
"src/format.js",
|
|
18
|
+
"src/mcp.js"
|
|
32
19
|
],
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test tests/*.test.js",
|
|
22
|
+
"contract:check": "node scripts/gen-contract.mjs --check",
|
|
23
|
+
"verify-package": "node scripts/verify-package.mjs",
|
|
24
|
+
"prepublishOnly": "npm test && npm run contract:check && npm run verify-package"
|
|
36
25
|
},
|
|
37
26
|
"engines": {
|
|
38
27
|
"node": ">=20"
|
|
39
28
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
42
|
-
"build": "npm run clean && tsc -p tsconfig.json",
|
|
43
|
-
"dev": "tsc -w -p tsconfig.json",
|
|
44
|
-
"start": "node dist/index.js",
|
|
45
|
-
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
46
|
-
},
|
|
47
29
|
"dependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"ink-spinner": "^5.0.0",
|
|
51
|
-
"ink-text-input": "^6.0.0",
|
|
52
|
-
"react": "^18.3.1",
|
|
53
|
-
"ws": "^8.18.0"
|
|
30
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
31
|
+
"zod": "4.4.3"
|
|
54
32
|
},
|
|
55
33
|
"devDependencies": {
|
|
56
|
-
"@
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
34
|
+
"@modelcontextprotocol/client": "2.0.0"
|
|
35
|
+
},
|
|
36
|
+
"description": "The Vecteur command line \u2014 one login, the same product as the web app, from any directory.",
|
|
37
|
+
"license": "UNLICENSED",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
62
40
|
}
|
|
63
41
|
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { EVENT_KINDS, validateAdmitted, validateProject, validateRunAck } from "./contract.js";
|
|
4
|
+
import { readCredentials } from "./credentials.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { canonicalJson } from "./format.js";
|
|
7
|
+
|
|
8
|
+
// Re-exported so every existing importer keeps its one import site; `errors.js` owns the type.
|
|
9
|
+
export { CliError };
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
12
|
+
const TOKEN_PATTERN = /vct_[A-Za-z0-9_-]{20,}/g;
|
|
13
|
+
const RELEASE_TIMEOUT = Symbol("release-timeout");
|
|
14
|
+
const RESPONSE_ACTIVITY = Symbol("response-activity");
|
|
15
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const MAX_FRAME_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_EVENTS = 10_000;
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export function redact(value) {
|
|
21
|
+
return String(value).replace(TOKEN_PATTERN, "[REDACTED]");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function refuseSecretValue(value) {
|
|
25
|
+
if (/vct_[A-Za-z0-9_-]{20,}/.test(JSON.stringify(value))) {
|
|
26
|
+
throw new CliError("secret_response_refused", "Public API response contains credential-shaped data");
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where a run gets its origin and its token, in one place, with the environment winning.
|
|
33
|
+
*
|
|
34
|
+
* The environment first, because that is how CI and a container pass a credential and neither
|
|
35
|
+
* should have to write a file to do it. The stored login second, so a human logs in once —
|
|
36
|
+
* SRD 06's release bar for this door is "one persistent login, the same product from any
|
|
37
|
+
* working directory", and reading only the environment made "logged in" last one shell.
|
|
38
|
+
*
|
|
39
|
+
* Both are all-or-nothing per field: a stored login does not lend its token to an environment
|
|
40
|
+
* that names a different origin, because a token minted for one deployment silently pointed at
|
|
41
|
+
* another is a credential leak with a good explanation.
|
|
42
|
+
*/
|
|
43
|
+
export function configuration(env = process.env, stored = undefined) {
|
|
44
|
+
const saved = stored === undefined ? readCredentials(env) : stored;
|
|
45
|
+
const token = env.VECTEUR_TOKEN ?? saved?.token ?? "";
|
|
46
|
+
|
|
47
|
+
// A STORED TOKEN GOES TO THE ORIGIN IT WAS STORED FOR, AND NOWHERE ELSE.
|
|
48
|
+
//
|
|
49
|
+
// The two sources resolve field by field, which on its own lets `VECTEUR_BASE_URL=<other>`
|
|
50
|
+
// borrow the stored token and send it somewhere it was never minted for — a credential leak
|
|
51
|
+
// with a plausible explanation, and one a user would never see happen. If the environment
|
|
52
|
+
// names an origin, it must bring its own token.
|
|
53
|
+
if (!env.VECTEUR_TOKEN && saved && env.VECTEUR_BASE_URL &&
|
|
54
|
+
env.VECTEUR_BASE_URL.replace(/\/+$/, "") !== saved.origin) {
|
|
55
|
+
throw new CliError("origin_mismatch",
|
|
56
|
+
`the stored login is for ${saved.origin} and VECTEUR_BASE_URL names `
|
|
57
|
+
+ `${env.VECTEUR_BASE_URL} — set VECTEUR_TOKEN for that origin, or log in to it`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!/^vct_[A-Za-z0-9_-]{43}$/.test(token)) {
|
|
61
|
+
throw new CliError("token_invalid", saved
|
|
62
|
+
? "the stored login does not hold one personal access token — run `vecteur login` again"
|
|
63
|
+
: "no login found: run `vecteur login --url <origin>`, or set VECTEUR_TOKEN");
|
|
64
|
+
}
|
|
65
|
+
const raw = env.VECTEUR_BASE_URL ?? saved?.origin ?? "";
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new CliError("origin_invalid", "VECTEUR_BASE_URL must be an absolute public origin");
|
|
71
|
+
}
|
|
72
|
+
const lexicalLoopback = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d{1,5})?$/.test(raw);
|
|
73
|
+
if ((url.protocol !== "https:" && !lexicalLoopback) ||
|
|
74
|
+
url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
75
|
+
throw new CliError("origin_invalid", "VECTEUR_BASE_URL must be HTTPS or an exact loopback HTTP origin");
|
|
76
|
+
}
|
|
77
|
+
const timeout = Number(env.VECTEUR_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS);
|
|
78
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 120_000) {
|
|
79
|
+
throw new CliError("timeout_invalid", "VECTEUR_TIMEOUT_MS must be 100 to 120000");
|
|
80
|
+
}
|
|
81
|
+
return { origin: url.origin, token, timeout };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exactObject(value, fields, code) {
|
|
85
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
86
|
+
Object.keys(value).sort().join("\n") !== [...fields].sort().join("\n")) {
|
|
87
|
+
throw new CliError(code, "Server returned malformed owner data");
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateEvent(value, runId, expectedSeq) {
|
|
93
|
+
exactObject(value, ["run_id", "seq", "kind", "payload"], "event_invalid");
|
|
94
|
+
if (value.run_id !== runId || !Number.isInteger(value.seq) || value.seq !== expectedSeq ||
|
|
95
|
+
!EVENT_KINDS.includes(value.kind) || !value.payload || typeof value.payload !== "object" ||
|
|
96
|
+
Array.isArray(value.payload)) {
|
|
97
|
+
throw new CliError("event_sequence_invalid", `Expected event sequence ${expectedSeq}`);
|
|
98
|
+
}
|
|
99
|
+
if (value.payload.evidence_dag !== undefined) {
|
|
100
|
+
validateEvidenceDag(value.payload.evidence_dag, runId);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function evidenceReplayInvalid(message) {
|
|
106
|
+
throw new CliError("evidence_replay_invalid", `EvidenceDAG ${message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exactKeys(value, keys) {
|
|
110
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
111
|
+
&& Object.keys(value).sort().join("\n") === [...keys].sort().join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function validateEvidenceDag(value, runId) {
|
|
115
|
+
if (!exactKeys(value, ["id", "schema_version", "run_id", "nodes", "edges"])) {
|
|
116
|
+
evidenceReplayInvalid("shape is invalid");
|
|
117
|
+
}
|
|
118
|
+
if (value.schema_version !== "evidence-dag.v1" || value.run_id !== runId ||
|
|
119
|
+
typeof value.id !== "string" || !value.id || !Array.isArray(value.nodes) ||
|
|
120
|
+
!Array.isArray(value.edges)) {
|
|
121
|
+
evidenceReplayInvalid("identity or arrays are invalid");
|
|
122
|
+
}
|
|
123
|
+
const digest = createHash("sha256").update(canonicalJson({
|
|
124
|
+
schema_version: value.schema_version, run_id: value.run_id,
|
|
125
|
+
nodes: value.nodes, edges: value.edges,
|
|
126
|
+
}).trimEnd(), "utf8").digest("hex");
|
|
127
|
+
if (value.id !== `evidence-dag:sha256:${digest}`) evidenceReplayInvalid("id seal is invalid");
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function parseSse(response, runId, onEvent = null) {
|
|
132
|
+
if (!response.body) throw new CliError("sse_invalid", "Event response has no body");
|
|
133
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
134
|
+
let buffer = "";
|
|
135
|
+
let total = 0;
|
|
136
|
+
const events = [];
|
|
137
|
+
let terminalSeen = false;
|
|
138
|
+
const reader = response.body.getReader();
|
|
139
|
+
const signal = response["vecteurAbortSignal"];
|
|
140
|
+
while (true) {
|
|
141
|
+
let result;
|
|
142
|
+
if (signal) {
|
|
143
|
+
let rejectAbort;
|
|
144
|
+
const aborted = new Promise((_, reject) => {
|
|
145
|
+
rejectAbort = () => reject(new DOMException("Aborted", "AbortError"));
|
|
146
|
+
signal.addEventListener("abort", rejectAbort, { once: true });
|
|
147
|
+
});
|
|
148
|
+
try { result = await Promise.race([reader.read(), aborted]); }
|
|
149
|
+
finally { signal.removeEventListener("abort", rejectAbort); }
|
|
150
|
+
} else result = await reader.read();
|
|
151
|
+
if (result.done) break;
|
|
152
|
+
const chunk = result.value;
|
|
153
|
+
response[RESPONSE_ACTIVITY]?.();
|
|
154
|
+
total += chunk.byteLength;
|
|
155
|
+
if (total > MAX_RESPONSE_BYTES) throw new CliError("response_too_large", "Event response exceeds limit");
|
|
156
|
+
try { buffer += decoder.decode(chunk, { stream: true }); }
|
|
157
|
+
catch { throw new CliError("sse_invalid", "Event stream is not UTF-8"); }
|
|
158
|
+
buffer = buffer.replace(/\r\n/g, "\n");
|
|
159
|
+
let boundary;
|
|
160
|
+
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
161
|
+
const frame = buffer.slice(0, boundary).replace(/\r/g, "");
|
|
162
|
+
if (Buffer.byteLength(frame, "utf8") > MAX_FRAME_BYTES) {
|
|
163
|
+
throw new CliError("sse_invalid", "SSE frame exceeds limit");
|
|
164
|
+
}
|
|
165
|
+
buffer = buffer.slice(boundary + 2);
|
|
166
|
+
if (!frame || frame.startsWith(":")) continue;
|
|
167
|
+
if (terminalSeen) throw new CliError("terminal_invalid", "Event stream contains data after terminal");
|
|
168
|
+
const lines = frame.split("\n");
|
|
169
|
+
if (lines.length !== 3 || !lines[0].startsWith("id:") ||
|
|
170
|
+
!lines[1].startsWith("event:") || !lines[2].startsWith("data:")) {
|
|
171
|
+
throw new CliError("sse_invalid", "Event stream contains malformed framing");
|
|
172
|
+
}
|
|
173
|
+
const id = lines[0].slice(3).trim();
|
|
174
|
+
const kind = lines[1].slice(6).trim();
|
|
175
|
+
let value;
|
|
176
|
+
try { value = JSON.parse(lines[2].slice(5).trimStart()); }
|
|
177
|
+
catch { throw new CliError("event_invalid", "Event stream contains malformed JSON"); }
|
|
178
|
+
if (id !== String(events.length) || kind !== value.kind) {
|
|
179
|
+
throw new CliError("sse_invalid", "SSE metadata disagrees with event data");
|
|
180
|
+
}
|
|
181
|
+
events.push(validateEvent(refuseSecretValue(value), runId, events.length));
|
|
182
|
+
await onEvent?.(events.at(-1));
|
|
183
|
+
if (events.length > MAX_EVENTS) throw new CliError("response_too_large", "Event count exceeds limit");
|
|
184
|
+
terminalSeen = events.at(-1).kind === "terminal";
|
|
185
|
+
}
|
|
186
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
187
|
+
throw new CliError("sse_invalid", "SSE partial frame exceeds limit");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
buffer += decoder.decode();
|
|
191
|
+
if (buffer.trim()) throw new CliError("sse_invalid", "Event stream ended with a partial frame");
|
|
192
|
+
return events;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class VecteurClient {
|
|
196
|
+
constructor(config = configuration(), fetchImpl = fetch) {
|
|
197
|
+
this.config = config;
|
|
198
|
+
this.fetchImpl = fetchImpl;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async request(method, path, { body, idempotencyKey, accept, signal } = {}) {
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
const cancel = () => controller.abort();
|
|
204
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
205
|
+
if (signal?.aborted) controller.abort();
|
|
206
|
+
const overallTimer = setTimeout(() => controller.abort(), this.config.timeout);
|
|
207
|
+
// THE IDLE WINDOW CATCHES A DEAD CONNECTION, NOT A THINKING AGENT.
|
|
208
|
+
//
|
|
209
|
+
// Five seconds is right for a request/response call: nothing legitimate is silent that long.
|
|
210
|
+
// It is wrong for the RUN EVENT STREAM, where silence between events is the agent working —
|
|
211
|
+
// `run submit` waits for a whole turn, and every turn that thought for more than five
|
|
212
|
+
// seconds died as `request_timeout: Public API request timed out`, which reads as a dead
|
|
213
|
+
// endpoint. That is every engineering ask, on a released door, and no environment variable
|
|
214
|
+
// could widen it because `VECTEUR_TIMEOUT_MS` was capped by this same `min`.
|
|
215
|
+
//
|
|
216
|
+
// A stream is still BOUNDED — by `overallTimer`, which no branch here changes. What goes is
|
|
217
|
+
// the assumption that a quiet socket is a broken one, which only ever held for the calls
|
|
218
|
+
// that answer immediately.
|
|
219
|
+
const idleMs = accept === "text/event-stream"
|
|
220
|
+
? this.config.timeout
|
|
221
|
+
: Math.min(this.config.timeout, 5_000);
|
|
222
|
+
let idleTimer;
|
|
223
|
+
const activity = () => {
|
|
224
|
+
clearTimeout(idleTimer);
|
|
225
|
+
idleTimer = setTimeout(() => controller.abort(), idleMs);
|
|
226
|
+
};
|
|
227
|
+
activity();
|
|
228
|
+
try {
|
|
229
|
+
const response = await this.fetchImpl(`${this.config.origin}${path}`, {
|
|
230
|
+
method,
|
|
231
|
+
redirect: "manual",
|
|
232
|
+
signal: controller.signal,
|
|
233
|
+
headers: {
|
|
234
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
235
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
236
|
+
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
|
|
237
|
+
...(accept ? { Accept: accept } : {}),
|
|
238
|
+
},
|
|
239
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
240
|
+
});
|
|
241
|
+
const abortBody = () => response.body?.cancel().catch(() => {});
|
|
242
|
+
controller.signal.addEventListener("abort", abortBody, { once: true });
|
|
243
|
+
response[RESPONSE_ACTIVITY] = activity;
|
|
244
|
+
response["vecteurAbortSignal"] = controller.signal;
|
|
245
|
+
response[RELEASE_TIMEOUT] = () => {
|
|
246
|
+
controller.signal.removeEventListener("abort", abortBody);
|
|
247
|
+
signal?.removeEventListener("abort", cancel);
|
|
248
|
+
clearTimeout(overallTimer); clearTimeout(idleTimer);
|
|
249
|
+
};
|
|
250
|
+
if (response.status >= 300 && response.status < 400) {
|
|
251
|
+
throw new CliError("redirect_refused", "Public API redirects are refused");
|
|
252
|
+
}
|
|
253
|
+
if (!response.ok) {
|
|
254
|
+
let code = `http_${response.status}`;
|
|
255
|
+
try {
|
|
256
|
+
const candidate = parseOwnerJson(await boundedText(response)).error?.code;
|
|
257
|
+
if (typeof candidate === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(candidate)) {
|
|
258
|
+
code = candidate;
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error?.name === "AbortError") throw error;
|
|
262
|
+
}
|
|
263
|
+
throw new CliError(code, `Public API refused request with HTTP ${response.status}`);
|
|
264
|
+
}
|
|
265
|
+
return response;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
signal?.removeEventListener("abort", cancel);
|
|
268
|
+
clearTimeout(overallTimer); clearTimeout(idleTimer);
|
|
269
|
+
if (error?.name === "AbortError") {
|
|
270
|
+
if (signal?.aborted) throw new CliError("request_cancelled", "Public API request was cancelled");
|
|
271
|
+
throw new CliError("request_timeout", "Public API request timed out");
|
|
272
|
+
}
|
|
273
|
+
if (error instanceof CliError) throw error;
|
|
274
|
+
throw new CliError("request_failed", "Public API request failed");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async projectGet(projectId, signal = undefined) {
|
|
279
|
+
const value = await this.#json("GET", `/api/projects/${encodeURIComponent(projectId)}`, { signal });
|
|
280
|
+
if (!validateProject(value) || value.id !== projectId) {
|
|
281
|
+
throw new CliError("project_invalid", "Server returned malformed project data");
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async runSubmit(projectId, ask, onEvent = null, signal = undefined) {
|
|
287
|
+
const value = await this.#json("POST", `/api/projects/${encodeURIComponent(projectId)}/runs`, {
|
|
288
|
+
body: { ask, attachments: [], parent_state_hash: null, ui_context: null },
|
|
289
|
+
idempotencyKey: randomUUID(),
|
|
290
|
+
signal,
|
|
291
|
+
});
|
|
292
|
+
if (!validateRunAck(value)) {
|
|
293
|
+
throw new CliError("run_ack_invalid", "Server returned malformed run acknowledgement");
|
|
294
|
+
}
|
|
295
|
+
return this.runEvents(value.run_id, onEvent, signal);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async runGet(runId, signal = undefined) {
|
|
299
|
+
const value = await this.#json("GET", `/api/runs/${encodeURIComponent(runId)}`, { signal });
|
|
300
|
+
if (!validateAdmitted(value) || value.run_id !== runId) {
|
|
301
|
+
throw new CliError("run_invalid", "Server returned malformed run record");
|
|
302
|
+
}
|
|
303
|
+
value.events.forEach((event, seq) => validateEvent(event, runId, seq));
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async runEvents(runId, onEvent = null, signal = undefined) {
|
|
308
|
+
const response = await this.request("GET", `/api/runs/${encodeURIComponent(runId)}/events`, {
|
|
309
|
+
accept: "text/event-stream",
|
|
310
|
+
signal,
|
|
311
|
+
});
|
|
312
|
+
let events;
|
|
313
|
+
try {
|
|
314
|
+
const type = response.headers.get("content-type")?.split(";", 1)[0];
|
|
315
|
+
if (type !== "text/event-stream") {
|
|
316
|
+
throw new CliError("sse_invalid", "Event route is not an SSE stream");
|
|
317
|
+
}
|
|
318
|
+
events = await parseSse(response, runId, onEvent);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error?.name === "AbortError") {
|
|
321
|
+
throw new CliError("request_timeout", "Public API request timed out");
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
} finally {
|
|
325
|
+
response[RELEASE_TIMEOUT]?.();
|
|
326
|
+
}
|
|
327
|
+
if (events.length === 0 || events.at(-1).kind !== "terminal") {
|
|
328
|
+
throw new CliError("terminal_invalid", "Event stream has no final terminal event");
|
|
329
|
+
}
|
|
330
|
+
const direct = await this.runGet(runId, signal);
|
|
331
|
+
if (JSON.stringify(events) !== JSON.stringify(direct.events)) {
|
|
332
|
+
throw new CliError("record_mismatch", "Event stream disagrees with direct replay");
|
|
333
|
+
}
|
|
334
|
+
const eventTerminal = events.at(-1).payload.state ?? events.at(-1).payload.terminal;
|
|
335
|
+
if (eventTerminal !== direct.terminal) {
|
|
336
|
+
throw new CliError("terminal_mismatch", "Terminal event disagrees with direct record");
|
|
337
|
+
}
|
|
338
|
+
return direct;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #json(method, path, options) {
|
|
342
|
+
const response = await this.request(method, path, options);
|
|
343
|
+
try {
|
|
344
|
+
if (response.headers.get("content-type")?.split(";", 1)[0] !== "application/json") {
|
|
345
|
+
throw new CliError("response_invalid", "Public API response is not JSON");
|
|
346
|
+
}
|
|
347
|
+
try { return refuseSecretValue(parseOwnerJson(await boundedText(response))); }
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error?.name === "AbortError") {
|
|
350
|
+
throw new CliError("request_timeout", "Public API request timed out");
|
|
351
|
+
}
|
|
352
|
+
if (error instanceof CliError) throw error;
|
|
353
|
+
throw new CliError("response_invalid", "Public API returned malformed JSON");
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
response[RELEASE_TIMEOUT]?.();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function boundedText(response) {
|
|
362
|
+
if (!response.body) throw new CliError("response_invalid", "Public API response has no body");
|
|
363
|
+
const chunks = [];
|
|
364
|
+
let size = 0;
|
|
365
|
+
for await (const chunk of response.body) {
|
|
366
|
+
response[RESPONSE_ACTIVITY]?.();
|
|
367
|
+
size += chunk.byteLength;
|
|
368
|
+
if (size > MAX_RESPONSE_BYTES) throw new CliError("response_too_large", "Public API response exceeds limit");
|
|
369
|
+
chunks.push(chunk);
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
373
|
+
} catch {
|
|
374
|
+
throw new CliError("response_invalid", "Public API response is not UTF-8");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function parseOwnerJson(text) {
|
|
379
|
+
let inString = false;
|
|
380
|
+
let escaped = false;
|
|
381
|
+
let outside = "";
|
|
382
|
+
for (const character of text) {
|
|
383
|
+
if (inString) {
|
|
384
|
+
if (escaped) escaped = false;
|
|
385
|
+
else if (character === "\\") escaped = true;
|
|
386
|
+
else if (character === '"') inString = false;
|
|
387
|
+
outside += " ";
|
|
388
|
+
} else if (character === '"') {
|
|
389
|
+
inString = true; outside += " ";
|
|
390
|
+
} else outside += character;
|
|
391
|
+
}
|
|
392
|
+
for (const match of outside.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
393
|
+
const number = Number(match[0]);
|
|
394
|
+
if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
|
|
395
|
+
throw new CliError("response_invalid", "Public API response contains an unsafe number");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return JSON.parse(text);
|
|
399
|
+
}
|
package/src/contract.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-contract.mjs from owner TypeScript projections.
|
|
2
|
+
export const OWNER_CONTRACT_SHA256 = "a8a1bfec596231c560f80e9f9dbbc38f4b9dff0eef7e87db5bc4687f98b89597";
|
|
3
|
+
export const EVENT_KINDS = Object.freeze(["source","step_started","step_finished","synthesis","artifact","cta","state_advanced","terminal","heartbeat","assistant_delta","step_label","context_frame","knowledge_binding","engineering_projection"]);
|
|
4
|
+
export const ORIGIN_KINDS = Object.freeze(["user","hypothesis","derived","file","catalog","default"]);
|
|
5
|
+
export const ORIGIN_WORD = Object.freeze({"user":"your brief","hypothesis":"assumed","derived":"derived","file":"your file","catalog":"catalogue","default":"default","unattributed":"unstated"});
|
|
6
|
+
const exact=(v,k)=>!!v&&typeof v==="object"&&!Array.isArray(v)&&Object.keys(v).sort().join("\n")===[...k].sort().join("\n");
|
|
7
|
+
const text=(v)=>typeof v==="string"&&v.length>0; const integer=(v)=>Number.isSafeInteger(v);
|
|
8
|
+
const member=(v)=>exact(v,["user_id","email","display_name","role"])&&[v.user_id,v.email,v.display_name].every(text)&&["OWNER","ADMIN","EDITOR","VIEWER"].includes(v.role);
|
|
9
|
+
export const validateProject=(v)=>exact(v,["id","name","org_id","workspace_id","role","role_source","members","created_at","updated_at"])&&[v.id,v.name,v.org_id,v.workspace_id].every(text)&&["OWNER","ADMIN","EDITOR","VIEWER"].includes(v.role)&&["workspace","project"].includes(v.role_source)&&Array.isArray(v.members)&&v.members.every(member)&&integer(v.created_at)&&integer(v.updated_at);
|
|
10
|
+
export const validateRunAck=(v)=>exact(v,["run_id","state_hash","accepted_at","profile"])&&[v.run_id,v.state_hash,v.profile].every(text)&&integer(v.accepted_at);
|
|
11
|
+
const usage=(v)=>exact(v,["state","provider","model_id","input_tokens","output_tokens","cost_usd"])&&["recorded","usage_unavailable"].includes(v.state)&&[v.provider,v.model_id].every(text)&&[v.input_tokens,v.output_tokens].every(x=>x===null||(integer(x)&&x>=0))&&(v.cost_usd===null||(typeof v.cost_usd==="number"&&Number.isFinite(v.cost_usd)&&v.cost_usd>=0));
|
|
12
|
+
export const validateAdmitted=(v)=>exact(v,["run_id","state_hash","result_hash","profile","terminal","usage","events"])&&[v.run_id,v.state_hash,v.result_hash,v.profile].every(text)&&["ok","partial","blocked","refused","infeasible_physics","capability_gap"].includes(v.terminal)&&usage(v.usage)&&Array.isArray(v.events);
|