@labelbox/recursion-cli 0.0.0 → 0.0.42
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 +113 -1
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +38 -0
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +122 -0
- package/dist/embed.d.ts +41 -0
- package/dist/embed.js +153 -0
- package/dist/git-host.d.ts +16 -0
- package/dist/git-host.js +190 -0
- package/dist/manifest.d.ts +410 -0
- package/dist/manifest.js +415 -0
- package/dist/permissions.d.ts +31 -0
- package/dist/permissions.js +73 -0
- package/dist/program.d.ts +113 -0
- package/dist/program.js +879 -0
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +43 -0
- package/dist/run.d.ts +39 -0
- package/dist/run.js +80 -0
- package/dist/skills.d.ts +69 -0
- package/dist/skills.js +256 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +22 -0
- package/package.json +50 -4
package/README.md
CHANGED
|
@@ -1 +1,113 @@
|
|
|
1
|
-
|
|
1
|
+
# @labelbox/recursion-cli
|
|
2
|
+
|
|
3
|
+
`rl` — the rl-gym command-line interface. It mirrors the TypeScript SDK
|
|
4
|
+
(`@labelbox/recursion-sdk`) exactly: where the SDK is `rl.synthesizers.create(...)`, the
|
|
5
|
+
CLI is `rl synthesizers create …`. Dots become spaces; you get `--help` at every
|
|
6
|
+
level.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
rl --help # list nouns (synthesizers, synthesizer-runs)
|
|
10
|
+
rl synthesizers --help # list verbs (create, get, list, …)
|
|
11
|
+
rl synthesizers create --help # list flags
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## How it works (fully live, zero per-operation code)
|
|
15
|
+
|
|
16
|
+
The CLI ships **no** baked API reference and has **no** `@labelbox/recursion-sdk` dependency.
|
|
17
|
+
On each run it **fetches a manifest** from `GET /cli/manifest` on whatever server
|
|
18
|
+
`--base-url` points at (production by default, or staging, or `localhost`), validates
|
|
19
|
+
it (`src/manifest.ts`), and builds its entire command tree, `--help`, request/response
|
|
20
|
+
shapes, and docs browse surfaces from it. Dispatch is generic (`src/dispatch.ts`):
|
|
21
|
+
each request is built straight from the manifest operation's HTTP method + path
|
|
22
|
+
template + params + body — there is no hand-written command per operation and no
|
|
23
|
+
baked client.
|
|
24
|
+
|
|
25
|
+
The result: **adding or changing a backend endpoint needs zero CLI release** — the
|
|
26
|
+
live CLI reflects it as soon as the backend deploys. The CLI is re-released only when
|
|
27
|
+
its own engine code changes. The manifest is revalidated on every run via a
|
|
28
|
+
conditional fetch (ETag / `If-None-Match`), cached per base-url under
|
|
29
|
+
`~/.cache/rl-gym/`, so it can never serve stale data and never needs manual busting.
|
|
30
|
+
|
|
31
|
+
### Docs browse surfaces
|
|
32
|
+
|
|
33
|
+
Beyond the executable `rl <noun> <verb>` operations, the manifest carries the docs,
|
|
34
|
+
exposed as one consistent positional shape — `rl <group> [<id>]` (bare lists, an id
|
|
35
|
+
shows that one):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
rl resources [<id>] # Reference — resource hubs: object shape, operations, recipes
|
|
39
|
+
rl recipes [<id>] # How-to — multi-step user-goal guides (--format cli|ts|curl)
|
|
40
|
+
rl explain [<concept>] # Explanation — concept pages
|
|
41
|
+
rl tutorials [<id>] # Tutorials — getting-started docs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Install / run
|
|
45
|
+
|
|
46
|
+
Published to **public npm** as `@labelbox/recursion-cli` — no token, no repo access, no
|
|
47
|
+
setup beyond Node itself.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
npm uninstall -g @recursion/cli @labelbox/rl-cli # no-op unless a pre-rename package is installed
|
|
51
|
+
npm install -g @labelbox/recursion-cli # they all own the global `rl` bin — EEXIST otherwise
|
|
52
|
+
# or, zero-install:
|
|
53
|
+
npx @labelbox/recursion-cli --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The CLI is standalone, so this pulls in no `@labelbox/recursion-sdk`.
|
|
57
|
+
**Full setup steps and install-failure triage live in
|
|
58
|
+
`apps/recursion/web/public/docs/cli-getting-started.md`** — that page owns them; don't
|
|
59
|
+
restate them here.
|
|
60
|
+
|
|
61
|
+
The CLI is released independently of the SDK (it no longer depends on it): a
|
|
62
|
+
`packages/cli/**` change cuts a CLI release via the **Release / CLI** GitHub
|
|
63
|
+
Action — see `yarn dx help release`.
|
|
64
|
+
|
|
65
|
+
From a monorepo checkout, run the bin directly without installing:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
yarn workspace @labelbox/recursion-cli build
|
|
69
|
+
node ./packages/cli/dist/bin.js --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Auth
|
|
73
|
+
|
|
74
|
+
Set `LABELBOX_API_KEY` in the environment, or pass `--api-key <key>`. Override the
|
|
75
|
+
host with `--base-url <url>` (defaults to the public Recursion API gateway, see
|
|
76
|
+
`DEFAULT_BASE_URL` in `src/manifest.ts`).
|
|
77
|
+
|
|
78
|
+
## Flags
|
|
79
|
+
|
|
80
|
+
- **Path / query params** are individual flags: `--environment-id`, `--limit`, …
|
|
81
|
+
Required path and query params must be passed as flags. A query param whose type
|
|
82
|
+
is an array or object (e.g. `--models`, `--enrichment-filters`) takes a JSON value
|
|
83
|
+
(`--models '["a","b"]'`); its help text is tagged `[pass as JSON]`.
|
|
84
|
+
- **Request body**: scalar top-level fields are individual flags (`--name`,
|
|
85
|
+
`--system-prompt`, …); pass the full body — including complex fields like
|
|
86
|
+
`contextInputs` / `targetFields` — with `--from-json <file>` or `--data <json>`
|
|
87
|
+
(mutually exclusive — pass only one). Scalar flags override values from
|
|
88
|
+
`--from-json`/`--data`. A required scalar body field can be supplied by either
|
|
89
|
+
its flag or the JSON body.
|
|
90
|
+
- **Output**: prints the result as JSON by default; `--quiet` prints only the
|
|
91
|
+
resulting resource's `id` (a blank line for results without one, e.g. list or
|
|
92
|
+
no-content operations).
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
rl synthesizers get --synthesizer-job-id sj_01HX...
|
|
96
|
+
rl synthesizers create --environment-id env_01HX... --from-json ./body.json
|
|
97
|
+
rl synthesizer-runs trigger --problem-version-id pv_01HX... --from-json ./run.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## The command surface is the live server
|
|
101
|
+
|
|
102
|
+
There is **nothing to regenerate or commit** for the CLI — the command surface is
|
|
103
|
+
fetched fresh from the target server's `GET /cli/manifest` on every run, so a backend
|
|
104
|
+
change flows through automatically after deploy with no CLI step. The manifest itself
|
|
105
|
+
is assembled by `yarn dx manifest:generate` (wired into `dx codegen`) from the
|
|
106
|
+
spec-derived reference files and embedded into the backend; to add or change a
|
|
107
|
+
command, change the backend `@SdkRoute` — not this package.
|
|
108
|
+
|
|
109
|
+
The *engine* (manifest fetch + cache + validation, generic dispatch, flag mapping,
|
|
110
|
+
help formatting, the request-body / returns shape trees, and the docs browse
|
|
111
|
+
renderers) is verified by `src/*.test.ts`, which build the program from a fixture
|
|
112
|
+
manifest in-memory and assert the realized flags + `--help` + browse output — no
|
|
113
|
+
committed golden snapshot to maintain.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { fetchManifest } from './manifest.js';
|
|
4
|
+
import { fetchPermissions } from './permissions.js';
|
|
5
|
+
import { run } from './run.js';
|
|
6
|
+
import { readCliVersion } from './version.js';
|
|
7
|
+
// The orchestration lives in run.ts (testable); this entrypoint only binds the real
|
|
8
|
+
// dependencies and turns the returned code into a process exit. Runs on import —
|
|
9
|
+
// kept logic-free so it never needs its own test.
|
|
10
|
+
//
|
|
11
|
+
// `run()` never throws and never exits: it renders every failure (including a
|
|
12
|
+
// missing key, a manifest fetch/validation error, and synchronous throws from
|
|
13
|
+
// buildProgram() such as a reserved-flag collision) to the `stderr` sink as
|
|
14
|
+
// `error: <message>`, then returns the exit code. The `.catch()` below is a
|
|
15
|
+
// backstop for a defect in that contract, not a routine path.
|
|
16
|
+
run({
|
|
17
|
+
argv: process.argv,
|
|
18
|
+
// Pass the reader, not its result — run() invokes it so a throw (corrupt install)
|
|
19
|
+
// is rendered as `error: <message>` rather than a raw stack trace.
|
|
20
|
+
version: readCliVersion,
|
|
21
|
+
fetchManifest,
|
|
22
|
+
fetchPermissions,
|
|
23
|
+
// The terminal entrypoint: `scaffold`, `submit`, and `skills` act on the
|
|
24
|
+
// developer's own checkout, so this is the one caller that registers them.
|
|
25
|
+
localCheckout: true,
|
|
26
|
+
stdout: (text) => process.stdout.write(text),
|
|
27
|
+
stderr: (text) => process.stderr.write(text),
|
|
28
|
+
})
|
|
29
|
+
.then((code) => {
|
|
30
|
+
// Only exit non-zero. Calling process.exit(0) here could truncate a large
|
|
31
|
+
// pending stdout write; letting node drain and exit naturally cannot.
|
|
32
|
+
if (code !== 0)
|
|
33
|
+
process.exit(code);
|
|
34
|
+
})
|
|
35
|
+
.catch((err) => {
|
|
36
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ManifestOperation } from './manifest.js';
|
|
2
|
+
export interface DispatchContext {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
}
|
|
6
|
+
/** Build the full request URL: base + path (params substituted) + query string. */
|
|
7
|
+
export declare function buildUrl(op: ManifestOperation, params: Record<string, unknown>, baseUrl: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Build and send the request for one operation, returning the parsed success body
|
|
10
|
+
* (or `undefined` for a 204 / empty response). On a non-2xx response the parsed
|
|
11
|
+
* error body is thrown (so `formatError` prints the server's JSON detail);
|
|
12
|
+
* `fetch` rejecting for an unreachable server propagates and is mapped to the
|
|
13
|
+
* standard "could not be reached" message by `formatError`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function dispatchOperation(op: ManifestOperation, params: Record<string, unknown>, ctx: DispatchContext): Promise<unknown>;
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Bound every request so a server that accepts the connection but never responds
|
|
2
|
+
// can't hang the CLI indefinitely. Generous — recursion operations are quick REST
|
|
3
|
+
// calls (long work is async + polled), so this only trips on a genuine hang.
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
5
|
+
/** Serialize one query param value the way the generated client did. */
|
|
6
|
+
function serializeQueryParam(name, value) {
|
|
7
|
+
if (value === undefined || value === null)
|
|
8
|
+
return [];
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
// form + explode: repeat the key per element. A non-scalar element (object/array)
|
|
11
|
+
// is JSON-encoded so it survives rather than becoming `[object Object]` —
|
|
12
|
+
// consistent with the object branch below. No shipped query param is `object[]`
|
|
13
|
+
// today; this keeps the two branches from diverging.
|
|
14
|
+
return value
|
|
15
|
+
.filter((v) => v !== undefined && v !== null)
|
|
16
|
+
.map((v) => {
|
|
17
|
+
const encoded = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
18
|
+
return `${name}=${encodeURIComponent(encoded)}`;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'object') {
|
|
22
|
+
// Object-valued query params (the `deepObject` `enrichmentFilters`, a
|
|
23
|
+
// record(string, string[])) go on the wire as a single JSON-encoded string. The
|
|
24
|
+
// backend does NOT bracket-parse (`name[key]=v` lands as a stray key and the
|
|
25
|
+
// param reads as absent — verified against the live API); its schema preprocess
|
|
26
|
+
// JSON.parses the value, and the frontend serializes it identically
|
|
27
|
+
// (apps/recursion/web/src/api/problems.ts). JSON also preserves the nested arrays a
|
|
28
|
+
// bracket + `String()` encoding silently corrupted.
|
|
29
|
+
return [`${name}=${encodeURIComponent(JSON.stringify(value))}`];
|
|
30
|
+
}
|
|
31
|
+
return [`${name}=${encodeURIComponent(String(value))}`];
|
|
32
|
+
}
|
|
33
|
+
/** Build the full request URL: base + path (params substituted) + query string. */
|
|
34
|
+
export function buildUrl(op, params, baseUrl) {
|
|
35
|
+
const path = op.path.replace(/\{([^}]+)\}/gu, (_match, name) => {
|
|
36
|
+
const value = params[name];
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
throw new Error(`missing required path parameter "${name}"`);
|
|
39
|
+
}
|
|
40
|
+
return encodeURIComponent(String(value));
|
|
41
|
+
});
|
|
42
|
+
const search = [];
|
|
43
|
+
for (const param of op.params) {
|
|
44
|
+
if (param.in === 'query')
|
|
45
|
+
search.push(...serializeQueryParam(param.name, params[param.name]));
|
|
46
|
+
}
|
|
47
|
+
const base = baseUrl.replace(/\/$/u, '');
|
|
48
|
+
const withPath = `${base}${path.startsWith('/') ? path : `/${path}`}`;
|
|
49
|
+
return search.length > 0 ? `${withPath}?${search.join('&')}` : withPath;
|
|
50
|
+
}
|
|
51
|
+
/** Parse a response body, dispatching on content type (JSON vs. raw text). */
|
|
52
|
+
async function parseBody(res) {
|
|
53
|
+
if (res.status === 204)
|
|
54
|
+
return undefined;
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
if (text === '')
|
|
57
|
+
return undefined;
|
|
58
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
59
|
+
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build and send the request for one operation, returning the parsed success body
|
|
71
|
+
* (or `undefined` for a 204 / empty response). On a non-2xx response the parsed
|
|
72
|
+
* error body is thrown (so `formatError` prints the server's JSON detail);
|
|
73
|
+
* `fetch` rejecting for an unreachable server propagates and is mapped to the
|
|
74
|
+
* standard "could not be reached" message by `formatError`.
|
|
75
|
+
*/
|
|
76
|
+
export async function dispatchOperation(op, params, ctx) {
|
|
77
|
+
const url = buildUrl(op, params, ctx.baseUrl);
|
|
78
|
+
const headers = {
|
|
79
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
80
|
+
Authorization: `Bearer ${ctx.apiKey}`,
|
|
81
|
+
// biome-ignore lint/style/useNamingConvention: HTTP header names are not camelCase.
|
|
82
|
+
Accept: 'application/json',
|
|
83
|
+
};
|
|
84
|
+
let body;
|
|
85
|
+
if (op.bodyKey && params[op.bodyKey] !== undefined) {
|
|
86
|
+
body = JSON.stringify(params[op.bodyKey]);
|
|
87
|
+
headers['Content-Type'] = 'application/json';
|
|
88
|
+
}
|
|
89
|
+
let res;
|
|
90
|
+
try {
|
|
91
|
+
res = await fetch(url, {
|
|
92
|
+
method: op.httpMethod.toUpperCase(),
|
|
93
|
+
headers,
|
|
94
|
+
// Spread rather than `body` — `RequestInit.body` is not optional-undefined,
|
|
95
|
+
// so passing `undefined` explicitly is a type error under
|
|
96
|
+
// exactOptionalPropertyTypes (and a bodyless GET is the common case).
|
|
97
|
+
...(body === undefined ? {} : { body }),
|
|
98
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// AbortSignal.timeout rejects with a TimeoutError — turn the hang into a clear,
|
|
103
|
+
// actionable message; other fetch failures (refused/reset) propagate unchanged.
|
|
104
|
+
if (err instanceof Error && err.name === 'TimeoutError') {
|
|
105
|
+
throw new Error(`request to ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
const parsed = await parseBody(res);
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
// Prefer the server's structured error body (formatError renders it as JSON);
|
|
112
|
+
// fall back to a status-coded Error when there's no usable body. `!== null` (not
|
|
113
|
+
// `!== undefined`) because `typeof null === 'object'` — a literal `null` body
|
|
114
|
+
// would otherwise be thrown and surface as an unactionable `error: null`.
|
|
115
|
+
if (parsed !== null && typeof parsed === 'object')
|
|
116
|
+
throw parsed;
|
|
117
|
+
throw new Error(typeof parsed === 'string' && parsed !== ''
|
|
118
|
+
? `request failed (HTTP ${res.status}): ${parsed}`
|
|
119
|
+
: `request failed — HTTP ${res.status}`);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Manifest } from './manifest.js';
|
|
2
|
+
import type { GrantedPermissions } from './permissions.js';
|
|
3
|
+
export type { Manifest, GrantedPermissions };
|
|
4
|
+
export interface ExecuteCliOptions {
|
|
5
|
+
/** CLI arguments as the caller would type them, e.g. `['environments', 'list']`. */
|
|
6
|
+
args: readonly string[];
|
|
7
|
+
/** The caller's own API key, from the request. Never the server's environment. */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** The API the CLI dispatches against. Server configuration, not caller input. */
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
/** Reported by `rl --version`. */
|
|
12
|
+
version: string;
|
|
13
|
+
/** The pre-assembled manifest — served from memory, never re-fetched over HTTP. */
|
|
14
|
+
manifest: Manifest;
|
|
15
|
+
/** The caller's granted permissions, so `--help` and gating match who is asking. */
|
|
16
|
+
granted: GrantedPermissions;
|
|
17
|
+
}
|
|
18
|
+
export interface CliResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
/** True when the invocation failed — becomes the tool result's `isError`. */
|
|
23
|
+
failed: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reject argv that would exfiltrate the caller's key, reach the filesystem, or
|
|
27
|
+
* spawn a process. Messages are written for the model, naming the alternative so
|
|
28
|
+
* it can correct itself rather than retrying the same call.
|
|
29
|
+
*
|
|
30
|
+
* @throws Error when the invocation is not permitted here.
|
|
31
|
+
*/
|
|
32
|
+
export declare function assertAllowedArgv(args: readonly string[]): void;
|
|
33
|
+
/**
|
|
34
|
+
* Run one `rl` invocation in-process and capture its output.
|
|
35
|
+
*
|
|
36
|
+
* Resolves for both success and failure — a non-zero exit is a normal outcome the
|
|
37
|
+
* model should see and correct from, not an exception. It rejects only on a
|
|
38
|
+
* refused argv (see `assertAllowedArgv`), or if `run()` breaks its no-throw
|
|
39
|
+
* contract.
|
|
40
|
+
*/
|
|
41
|
+
export declare function executeCli(options: ExecuteCliOptions): Promise<CliResult>;
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { run } from './run.js';
|
|
2
|
+
// Running `rl` inside a server process, on behalf of an agent, without a shell.
|
|
3
|
+
//
|
|
4
|
+
// The MCP endpoint exposes one tool whose argument is an argv array. This module
|
|
5
|
+
// is the whole surface it needs: `executeCli` builds the argv, runs the same
|
|
6
|
+
// `run()` the terminal entrypoint uses, and returns the captured output instead of
|
|
7
|
+
// writing to the process streams.
|
|
8
|
+
//
|
|
9
|
+
// Three things make this safe rather than merely convenient:
|
|
10
|
+
//
|
|
11
|
+
// 1. **No process is spawned.** There is no shell, so there is no quoting,
|
|
12
|
+
// escaping, or metacharacter surface — argv is passed as an array and reaches
|
|
13
|
+
// commander verbatim. An agent cannot reach the container's shell through it.
|
|
14
|
+
// 2. **Credentials and target are server-controlled**, never caller-supplied.
|
|
15
|
+
// See RESERVED_FLAGS — this is the difference between running the CLI for the
|
|
16
|
+
// caller and letting the caller point the CLI wherever it likes.
|
|
17
|
+
// 3. **Laptop-shaped commands are refused.** BLOCKED_COMMANDS covers the CLI's
|
|
18
|
+
// only routes to the host filesystem and to `spawnSync`.
|
|
19
|
+
//
|
|
20
|
+
// Everything else — the command tree, `--help`, permission gating, dispatch — is
|
|
21
|
+
// the real CLI, unchanged.
|
|
22
|
+
/**
|
|
23
|
+
* Command groups that never make sense server-side, and that are the CLI's only
|
|
24
|
+
* routes to the host filesystem or a child process.
|
|
25
|
+
*
|
|
26
|
+
* - `scaffold` / `submit` (`git-host.ts`) run `spawnSync('git', …)` and write an
|
|
27
|
+
* executable credential helper, handing it the parent's full environment.
|
|
28
|
+
* - `skills` (`skills.ts`) writes into `~/.claude/skills/` — on a server that is
|
|
29
|
+
* the container's home directory, not the caller's machine.
|
|
30
|
+
*
|
|
31
|
+
* **This set is not what makes them unreachable.** `run({ localCheckout: false })`
|
|
32
|
+
* below never registers them, so the capability is absent from the command tree
|
|
33
|
+
* rather than filtered out of argv. This check exists only so a model that asks for
|
|
34
|
+
* one gets an explanation instead of commander's bare `unknown command`.
|
|
35
|
+
*/
|
|
36
|
+
const BLOCKED_COMMANDS = new Set(['scaffold', 'submit', 'skills']);
|
|
37
|
+
/**
|
|
38
|
+
* Flags the caller may not set, each because the server must decide it.
|
|
39
|
+
*
|
|
40
|
+
* `--base-url` is the security-critical one. `dispatchOperation` sends
|
|
41
|
+
* `Authorization: Bearer <apiKey>` to whatever base URL resolves, so a caller-set
|
|
42
|
+
* `--base-url` would send the *caller's own API key* to a host of the caller's
|
|
43
|
+
* choosing. Refusing it is what keeps this from being a credential-exfiltration
|
|
44
|
+
* primitive wrapped in a tool call.
|
|
45
|
+
*
|
|
46
|
+
* `--api-key` is refused for the same reason in reverse: the identity a request
|
|
47
|
+
* runs under is established by the request, not by an argument a model can write.
|
|
48
|
+
*
|
|
49
|
+
* Rejecting is not merely belt-and-braces over appending our own values.
|
|
50
|
+
* `resolveApiKey`/`resolveBaseUrl` read argv via `flagValue`, which prefers the
|
|
51
|
+
* `--name=value` form found *anywhere* in argv over the space form — so a
|
|
52
|
+
* caller-supplied `--base-url=…` would beat a server value appended afterwards.
|
|
53
|
+
* The only correct handling is to refuse the argv outright.
|
|
54
|
+
*/
|
|
55
|
+
const RESERVED_FLAGS = ['--api-key', '--base-url'];
|
|
56
|
+
/**
|
|
57
|
+
* `--from-json <path>` reads an arbitrary file and folds it into the request body
|
|
58
|
+
* (`program.ts` → `parseBodyBase`). In a server process that is a file-read
|
|
59
|
+
* primitive handed to a model: the contents reach the API, and a parse failure
|
|
60
|
+
* echoes the path back in the error. `--data` accepts the same JSON inline and is
|
|
61
|
+
* always available, so nothing is lost by refusing the file form.
|
|
62
|
+
*/
|
|
63
|
+
const BLOCKED_FLAG = '--from-json';
|
|
64
|
+
/** Does argv set this flag, in either the space form or the `=` form? */
|
|
65
|
+
function hasFlag(args, flag) {
|
|
66
|
+
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reject argv that would exfiltrate the caller's key, reach the filesystem, or
|
|
70
|
+
* spawn a process. Messages are written for the model, naming the alternative so
|
|
71
|
+
* it can correct itself rather than retrying the same call.
|
|
72
|
+
*
|
|
73
|
+
* @throws Error when the invocation is not permitted here.
|
|
74
|
+
*/
|
|
75
|
+
export function assertAllowedArgv(args) {
|
|
76
|
+
for (const flag of RESERVED_FLAGS) {
|
|
77
|
+
if (hasFlag(args, flag)) {
|
|
78
|
+
throw new Error(`${flag} cannot be set here — it is determined by your authenticated session. ` +
|
|
79
|
+
'Remove it and retry.');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (hasFlag(args, BLOCKED_FLAG)) {
|
|
83
|
+
throw new Error(`${BLOCKED_FLAG} is not available here because it reads a file from the server. ` +
|
|
84
|
+
'Pass the same JSON inline with --data instead.');
|
|
85
|
+
}
|
|
86
|
+
// Only the command position, which is the first non-flag token — where commander
|
|
87
|
+
// resolves it from. Scanning every token instead would permanently refuse valid
|
|
88
|
+
// data that happens to spell one of these names (`--name submit`), and because it
|
|
89
|
+
// is the caller's own data the model could not retry its way out of it. The
|
|
90
|
+
// security property does not rest on this check anyway; see BLOCKED_COMMANDS.
|
|
91
|
+
const command = args.find((arg) => !arg.startsWith('-'));
|
|
92
|
+
if (command !== undefined && BLOCKED_COMMANDS.has(command)) {
|
|
93
|
+
throw new Error(`"${command}" is not available here: it acts on a local developer checkout, ` +
|
|
94
|
+
'which this server does not have. Run it from your own terminal with the rl CLI.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Run one `rl` invocation in-process and capture its output.
|
|
99
|
+
*
|
|
100
|
+
* Resolves for both success and failure — a non-zero exit is a normal outcome the
|
|
101
|
+
* model should see and correct from, not an exception. It rejects only on a
|
|
102
|
+
* refused argv (see `assertAllowedArgv`), or if `run()` breaks its no-throw
|
|
103
|
+
* contract.
|
|
104
|
+
*/
|
|
105
|
+
export async function executeCli(options) {
|
|
106
|
+
if (options.apiKey === '') {
|
|
107
|
+
throw new Error('cannot run the CLI without an API key for the calling user');
|
|
108
|
+
}
|
|
109
|
+
assertAllowedArgv(options.args);
|
|
110
|
+
const stdout = [];
|
|
111
|
+
const stderr = [];
|
|
112
|
+
const exitCode = await run({
|
|
113
|
+
// Server-controlled flags go first, before anything the caller sent — global
|
|
114
|
+
// flags precede the command, which is the form `resolveApiKey`/`resolveBaseUrl`
|
|
115
|
+
// scan for. `assertAllowedArgv` has already guaranteed the caller set neither.
|
|
116
|
+
// `run()` slices off the leading two entries as it would a real `process.argv`,
|
|
117
|
+
// so the placeholders have to be present.
|
|
118
|
+
//
|
|
119
|
+
// The `=` form, not the space form: `flagValue` refuses to read a space-form
|
|
120
|
+
// value that itself starts with `-`, so a key or URL beginning with a dash would
|
|
121
|
+
// read as unset and `resolveApiKey` would fall through to
|
|
122
|
+
// `process.env.LABELBOX_API_KEY` — turning a key in the container into the acting
|
|
123
|
+
// identity, the exact substitution this path exists to prevent. The `=` form
|
|
124
|
+
// binds any value, and is also the form `flagValue` prefers, so nothing later in
|
|
125
|
+
// argv can outrank it.
|
|
126
|
+
argv: [
|
|
127
|
+
'node',
|
|
128
|
+
'rl',
|
|
129
|
+
`--api-key=${options.apiKey}`,
|
|
130
|
+
`--base-url=${options.baseUrl}`,
|
|
131
|
+
...options.args,
|
|
132
|
+
],
|
|
133
|
+
version: () => options.version,
|
|
134
|
+
// Both fetches are satisfied from what the caller already resolved: the
|
|
135
|
+
// manifest is embedded in the server, and the permissions came in on the
|
|
136
|
+
// request. No HTTP, no ETag cache directory, and no way for a slow or
|
|
137
|
+
// unreachable network to stall a tool call.
|
|
138
|
+
fetchManifest: () => Promise.resolve(options.manifest),
|
|
139
|
+
fetchPermissions: () => Promise.resolve(options.granted),
|
|
140
|
+
// No developer checkout here, so `scaffold`, `submit`, and `skills` are never
|
|
141
|
+
// registered — the CLI's only `spawnSync`, `~/.claude/` writes, and
|
|
142
|
+
// `process.exit` calls are absent from this program, not merely refused.
|
|
143
|
+
localCheckout: false,
|
|
144
|
+
stdout: (text) => stdout.push(text),
|
|
145
|
+
stderr: (text) => stderr.push(text),
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
stdout: stdout.join(''),
|
|
149
|
+
stderr: stderr.join(''),
|
|
150
|
+
exitCode,
|
|
151
|
+
failed: exitCode !== 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
/** `POST /v1/organizations/:organizationId/git-repo-claims` — mints a per-Aligner
|
|
3
|
+
* repo + push token for `problemId`. The claiming Aligner is the
|
|
4
|
+
* authenticated caller (the API key), never a client-supplied field. */
|
|
5
|
+
export declare function claimGitRepo(args: {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
organizationId: string;
|
|
9
|
+
problemId: string;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
cloneUrl: string;
|
|
12
|
+
defaultBranch: string;
|
|
13
|
+
pushToken: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** Register the `scaffold` / `submit` commands on `program`. */
|
|
16
|
+
export declare function addGitHostCommands(program: Command, helpGroup: string): void;
|