@sibylline-labs/eris 0.0.0-stage → 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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* login.ts — `eris login` and `eris logout`, loaded only when one dispatches
|
|
3
|
+
* (the manifest is ./index.ts).
|
|
4
|
+
*
|
|
5
|
+
* `eris login` is an RFC 8628 device-authorization client. It starts a grant at
|
|
6
|
+
* `<url>/v1/device/code`, shows the user code and the verification URI (and
|
|
7
|
+
* opens a browser when it can), then polls `<url>/v1/device/token` until the
|
|
8
|
+
* code is approved, denied or expired. The bearer it is handed goes into
|
|
9
|
+
* `~/.eris/yaks.json`, the one file `eris yak` reads (./yak-identity.ts), with
|
|
10
|
+
* the url beside it. The url comes from `resolveUrl`, the one resolver `eris
|
|
11
|
+
* yak` also reads: `--url`, else `ERIS_YAKS_URL`, else the file's own `url`,
|
|
12
|
+
* else `DEFAULT_YAKS_URL`, so there is always an endpoint (the parser refuses
|
|
13
|
+
* an empty `--url`).
|
|
14
|
+
*
|
|
15
|
+
* `eris logout` removes the token from that file and keeps the url. No route
|
|
16
|
+
* lets a bearer revoke itself, so the token stays valid server-side until it is
|
|
17
|
+
* revoked from the dashboard's API tokens settings; the command says so.
|
|
18
|
+
*
|
|
19
|
+
* Invariants:
|
|
20
|
+
* - Neither the device code nor the bearer is ever written to stdout or
|
|
21
|
+
* stderr. The user code is the one value shown, and it is not a secret on
|
|
22
|
+
* its own.
|
|
23
|
+
* - A grant that does not end approved writes nothing: the file is replaced
|
|
24
|
+
* once, after a token has been received, through `writeYaksFileConfig`
|
|
25
|
+
* (created 0600, renamed into place).
|
|
26
|
+
* - The poll interval only grows: `slow_down` raises it for every later poll
|
|
27
|
+
* (to the interval it carries, else by 5 s); a 429 waits out `retry-after`
|
|
28
|
+
* for one poll. The code's `expires_in` ends the loop locally even if the
|
|
29
|
+
* server never says `expired_token`.
|
|
30
|
+
* - Two error shapes are read: RFC 6749's `{error: "<code>"}` (every 400) and
|
|
31
|
+
* the gateway's `{error: {code, message}}` (429, 503). A 5xx or a dropped
|
|
32
|
+
* connection while polling is retried up to `MAX_TRANSIENT` times in a
|
|
33
|
+
* row, so a blip after the user approved does not throw the approval away.
|
|
34
|
+
* - Text from the server is shown only after control characters are
|
|
35
|
+
* stripped, so a response cannot drive the terminal.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { hostname as osHostname } from "node:os";
|
|
39
|
+
import { renderFailureBlock, renderWarnLines, readArgs, type VerbContext } from "../../src/cli/plugin";
|
|
40
|
+
import plugin from "./index";
|
|
41
|
+
import { DEFAULT_YAKS_URL, readYaksFileConfig, resolveUrl, runBounded, writeYaksFileConfig, yaksConfigPath } from "./yak-identity";
|
|
42
|
+
import { promises as fs } from "node:fs";
|
|
43
|
+
|
|
44
|
+
/** The `docs/ERRORS.md` slugs this module raises (`tests/errors-doc.test.ts`). */
|
|
45
|
+
export type LoginSlug =
|
|
46
|
+
| "login-unreachable"
|
|
47
|
+
| "login-refused"
|
|
48
|
+
| "login-denied"
|
|
49
|
+
| "login-expired"
|
|
50
|
+
| "login-env-token-overrides";
|
|
51
|
+
|
|
52
|
+
const DEVICE_AUTHORIZATION_PATH = "/v1/device/code";
|
|
53
|
+
const DEVICE_TOKEN_PATH = "/v1/device/token";
|
|
54
|
+
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
55
|
+
/** RFC 8628 §3.5: what `slow_down` adds when it carries no interval. */
|
|
56
|
+
const SLOW_DOWN_STEP_MS = 5_000;
|
|
57
|
+
/** Consecutive 5xx or dropped polls tolerated before giving up. */
|
|
58
|
+
const MAX_TRANSIENT = 3;
|
|
59
|
+
/** One request's whole exchange, headers and body. */
|
|
60
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
61
|
+
/** The gateway caps a device label at 128 characters. */
|
|
62
|
+
const MAX_LABEL = 128;
|
|
63
|
+
|
|
64
|
+
export const LOGIN_USAGE = `Usage: eris login [--url <url>] [--no-browser]
|
|
65
|
+
|
|
66
|
+
Sign this machine in with a device code: approve the code shown here in your
|
|
67
|
+
browser, and the token is written to ~/.eris/yaks.json (mode 0600), where
|
|
68
|
+
'eris yak' reads it. ERIS_YAKS_TOKEN, when set, still wins over the file.
|
|
69
|
+
|
|
70
|
+
--url <url> the endpoint (default: $ERIS_YAKS_URL, then the url
|
|
71
|
+
already in ~/.eris/yaks.json, then ${DEFAULT_YAKS_URL})
|
|
72
|
+
--no-browser print the link without trying to open a browser
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
export const LOGOUT_USAGE = `Usage: eris logout
|
|
76
|
+
|
|
77
|
+
Remove this machine's token from ~/.eris/yaks.json, keeping the url. The token
|
|
78
|
+
stays valid until it is revoked under Settings > API Tokens.
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
export interface LoginDeps {
|
|
82
|
+
home?: string;
|
|
83
|
+
hostname?: () => string;
|
|
84
|
+
now?: () => number;
|
|
85
|
+
sleep?: (ms: number) => Promise<void>;
|
|
86
|
+
/** Open `url` in a browser, best effort. The default does so only when
|
|
87
|
+
* stderr is a terminal. */
|
|
88
|
+
openBrowser?: (url: string) => Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type Grant = {
|
|
92
|
+
deviceCode: string;
|
|
93
|
+
userCode: string;
|
|
94
|
+
verificationUri: string;
|
|
95
|
+
expiresInMs: number;
|
|
96
|
+
intervalMs: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type Exchange = { kind: "response"; status: number; headers: Headers; body: unknown } | { kind: "unreachable"; reason: string };
|
|
100
|
+
|
|
101
|
+
/** Drop control characters, so text a server sent cannot drive the terminal. */
|
|
102
|
+
function printable(text: string): string {
|
|
103
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The error code from either shape: RFC `{error: "<code>"}` or the gateway's `{error: {code}}`. */
|
|
107
|
+
function errorCode(status: number, body: unknown): string {
|
|
108
|
+
if (typeof body === "object" && body !== null) {
|
|
109
|
+
const error = (body as Record<string, unknown>).error;
|
|
110
|
+
if (typeof error === "string") return printable(error);
|
|
111
|
+
if (typeof error === "object" && error !== null) {
|
|
112
|
+
const { code, message } = error as Record<string, unknown>;
|
|
113
|
+
if (typeof code === "string") return printable(typeof message === "string" ? `${code}: ${message}` : code);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return `HTTP ${status}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function post(url: string, form: Record<string, string>): Promise<Exchange> {
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
124
|
+
body: new URLSearchParams(form).toString(),
|
|
125
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
126
|
+
});
|
|
127
|
+
let body: unknown = null;
|
|
128
|
+
try {
|
|
129
|
+
body = await res.json();
|
|
130
|
+
} catch {
|
|
131
|
+
// A body that is not JSON reads as no body; the status still decides.
|
|
132
|
+
}
|
|
133
|
+
return { kind: "response", status: res.status, headers: res.headers, body };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const name = (err as { name?: string }).name;
|
|
136
|
+
return { kind: "unreachable", reason: name === "TimeoutError" ? `no answer within ${REQUEST_TIMEOUT_MS / 1000}s` : printable((err as Error).message) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readGrant(body: unknown): Grant | null {
|
|
141
|
+
if (typeof body !== "object" || body === null) return null;
|
|
142
|
+
const b = body as Record<string, unknown>;
|
|
143
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = b;
|
|
144
|
+
if (typeof device_code !== "string" || typeof user_code !== "string" || typeof verification_uri !== "string") return null;
|
|
145
|
+
if (typeof expires_in !== "number" || !(expires_in > 0)) return null;
|
|
146
|
+
return {
|
|
147
|
+
deviceCode: device_code,
|
|
148
|
+
userCode: printable(user_code),
|
|
149
|
+
verificationUri: printable(typeof verification_uri_complete === "string" ? verification_uri_complete : verification_uri),
|
|
150
|
+
expiresInMs: expires_in * 1000,
|
|
151
|
+
intervalMs: (typeof interval === "number" && interval > 0 ? interval : 5) * 1000,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `<host> (eris)`, or `<host> (eris, claude-code)` under Claude Code: the
|
|
156
|
+
* name the minted token carries in the dashboard's token list. */
|
|
157
|
+
function deviceLabel(host: string, env: Record<string, string | undefined>): string {
|
|
158
|
+
const short = host.split(".")[0] || "unknown-host";
|
|
159
|
+
const harness = env.CLAUDECODE === "1" ? ", claude-code" : "";
|
|
160
|
+
return `${short} (eris${harness})`.slice(0, MAX_LABEL);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function defaultOpenBrowser(url: string): Promise<void> {
|
|
164
|
+
if (!process.stderr.isTTY) return;
|
|
165
|
+
const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "linux" ? ["xdg-open", url] : null;
|
|
166
|
+
if (cmd !== null) await runBounded(cmd, process.cwd(), 5_000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fail(ctx: VerbContext, slug: LoginSlug | "yak-config-permissive" | "yak-config-malformed" | "yak-config-unwritable", what: string, fix: string): number {
|
|
170
|
+
ctx.stderr.write(renderFailureBlock({ slug, what, fix }, ctx.erisDir));
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function runLogin(args: string[], ctx: VerbContext, deps: LoginDeps = {}): Promise<number> {
|
|
175
|
+
const a = readArgs(plugin.rows, "login", args, ctx, { help: LOGIN_USAGE });
|
|
176
|
+
if (a === null) return 0;
|
|
177
|
+
const home = deps.home ?? ctx.env.HOME ?? "";
|
|
178
|
+
const now = deps.now ?? Date.now;
|
|
179
|
+
const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
180
|
+
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
|
181
|
+
|
|
182
|
+
const file = await readYaksFileConfig(home);
|
|
183
|
+
if ("reason" in file) return fail(ctx, file.slug, file.reason, `fix or remove ${yaksConfigPath(home)}, then run 'eris login' again`);
|
|
184
|
+
|
|
185
|
+
const url = resolveUrl(file.value, ctx.env, a.flags["--url"]).value;
|
|
186
|
+
const base = url.replace(/\/+$/, "");
|
|
187
|
+
|
|
188
|
+
// --- start -------------------------------------------------------------------
|
|
189
|
+
const started = await post(`${base}${DEVICE_AUTHORIZATION_PATH}`, { device: deviceLabel((deps.hostname ?? osHostname)(), ctx.env) });
|
|
190
|
+
if (started.kind === "unreachable") {
|
|
191
|
+
return fail(ctx, "login-unreachable", `cannot reach ${printable(base)}: ${started.reason}`, "check the url and your connection, then run 'eris login' again");
|
|
192
|
+
}
|
|
193
|
+
const grant = started.status === 200 ? readGrant(started.body) : null;
|
|
194
|
+
if (grant === null) {
|
|
195
|
+
const why = started.status === 200 ? "a malformed device authorization response" : errorCode(started.status, started.body);
|
|
196
|
+
return fail(ctx, "login-refused", `${printable(base)} refused to start a device login (${why})`, "check the url points at an endpoint with device login, then run 'eris login' again");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const opens = !a.flags["--no-browser"] && /^https?:\/\//i.test(grant.verificationUri);
|
|
200
|
+
ctx.stderr.write(
|
|
201
|
+
`To sign this machine in, open:\n\n ${grant.verificationUri}\n\nand check it shows the code: ${grant.userCode}\n` +
|
|
202
|
+
`Waiting for approval (the code expires in ${Math.round(grant.expiresInMs / 60_000)} min)...\n`,
|
|
203
|
+
);
|
|
204
|
+
if (opens) await openBrowser(grant.verificationUri).catch(() => undefined);
|
|
205
|
+
|
|
206
|
+
// --- poll --------------------------------------------------------------------
|
|
207
|
+
const deadline = now() + grant.expiresInMs;
|
|
208
|
+
let interval = grant.intervalMs;
|
|
209
|
+
let wait = interval;
|
|
210
|
+
let transient = 0;
|
|
211
|
+
for (;;) {
|
|
212
|
+
await sleep(wait);
|
|
213
|
+
wait = interval;
|
|
214
|
+
if (now() >= deadline) {
|
|
215
|
+
return fail(ctx, "login-expired", "the device code expired before it was approved", "run 'eris login' again and approve the new code within its lifetime");
|
|
216
|
+
}
|
|
217
|
+
const polled = await post(`${base}${DEVICE_TOKEN_PATH}`, { grant_type: DEVICE_CODE_GRANT_TYPE, device_code: grant.deviceCode });
|
|
218
|
+
|
|
219
|
+
if (polled.kind === "unreachable" || polled.status >= 500) {
|
|
220
|
+
if (++transient > MAX_TRANSIENT) {
|
|
221
|
+
const why = polled.kind === "unreachable" ? polled.reason : errorCode(polled.status, polled.body);
|
|
222
|
+
return fail(ctx, "login-unreachable", `lost ${printable(base)} while waiting for approval (${why})`, "run 'eris login' again");
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
transient = 0;
|
|
227
|
+
|
|
228
|
+
if (polled.status === 429) {
|
|
229
|
+
const retryAfter = Number(polled.headers.get("retry-after"));
|
|
230
|
+
if (Number.isFinite(retryAfter) && retryAfter * 1000 > wait) wait = retryAfter * 1000;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (polled.status === 200) {
|
|
235
|
+
const body = polled.body as Record<string, unknown> | null;
|
|
236
|
+
const token = body?.access_token;
|
|
237
|
+
if (typeof token !== "string" || token === "" || String(body?.token_type).toLowerCase() !== "bearer") {
|
|
238
|
+
return fail(ctx, "login-refused", "the endpoint approved the code but sent no bearer token", "run 'eris login' again");
|
|
239
|
+
}
|
|
240
|
+
const written = await writeYaksFileConfig({ url, token }, home);
|
|
241
|
+
if ("reason" in written) return fail(ctx, written.slug, written.reason, `make ${yaksConfigPath(home)} writable, then run 'eris login' again`);
|
|
242
|
+
if (file.value.token !== undefined && file.value.token !== token) {
|
|
243
|
+
ctx.stderr.write("replaced the token that was in the file; revoke it under Settings > API Tokens if nothing else uses it\n");
|
|
244
|
+
}
|
|
245
|
+
if (ctx.env.ERIS_YAKS_TOKEN) {
|
|
246
|
+
ctx.stderr.write(renderWarnLines("ERIS_YAKS_TOKEN is set and overrides the token just written; unset it to use this login", "login-env-token-overrides"));
|
|
247
|
+
}
|
|
248
|
+
const name = typeof body?.name === "string" ? printable(body.name) : "eris";
|
|
249
|
+
ctx.stdout.write(`logged in: token "${name}" written to ${written.value}\n`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const code = errorCode(polled.status, polled.body);
|
|
254
|
+
if (code === "authorization_pending") continue;
|
|
255
|
+
if (code === "slow_down") {
|
|
256
|
+
const raised = (polled.body as Record<string, unknown>).interval;
|
|
257
|
+
interval = typeof raised === "number" && raised * 1000 > interval ? raised * 1000 : interval + SLOW_DOWN_STEP_MS;
|
|
258
|
+
wait = interval;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (code === "access_denied") {
|
|
262
|
+
return fail(ctx, "login-denied", "the sign-in was denied in the browser", "run 'eris login' again if that was not intended");
|
|
263
|
+
}
|
|
264
|
+
if (code === "expired_token") {
|
|
265
|
+
return fail(ctx, "login-expired", "the device code expired before it was approved", "run 'eris login' again and approve the new code within its lifetime");
|
|
266
|
+
}
|
|
267
|
+
return fail(ctx, "login-refused", `the endpoint refused the device code (${code})`, "run 'eris login' again");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function runLogout(args: string[], ctx: VerbContext, deps: Pick<LoginDeps, "home"> = {}): Promise<number> {
|
|
272
|
+
const a = readArgs(plugin.rows, "logout", args, ctx, { help: LOGOUT_USAGE });
|
|
273
|
+
if (a === null) return 0;
|
|
274
|
+
const home = deps.home ?? ctx.env.HOME ?? "";
|
|
275
|
+
const path = yaksConfigPath(home);
|
|
276
|
+
|
|
277
|
+
const file = await readYaksFileConfig(home);
|
|
278
|
+
if ("reason" in file) return fail(ctx, file.slug, file.reason, `fix or remove ${path}`);
|
|
279
|
+
|
|
280
|
+
if (file.value.token === undefined) {
|
|
281
|
+
ctx.stdout.write(`not logged in: no token in ${path}\n`);
|
|
282
|
+
} else {
|
|
283
|
+
if (file.value.url === undefined) {
|
|
284
|
+
try {
|
|
285
|
+
await fs.rm(path, { force: true });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return fail(ctx, "yak-config-unwritable", `cannot remove ${path}: ${(err as NodeJS.ErrnoException).code ?? "error"}`, `remove ${path} by hand`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
const written = await writeYaksFileConfig({ url: file.value.url }, home);
|
|
291
|
+
if ("reason" in written) return fail(ctx, written.slug, written.reason, `make ${path} writable, then run 'eris logout' again`);
|
|
292
|
+
}
|
|
293
|
+
ctx.stdout.write(`logged out: removed the token from ${path}\n`);
|
|
294
|
+
ctx.stderr.write("the token stays valid until it is revoked under Settings > API Tokens\n");
|
|
295
|
+
}
|
|
296
|
+
if (ctx.env.ERIS_YAKS_TOKEN) {
|
|
297
|
+
ctx.stderr.write(renderWarnLines("ERIS_YAKS_TOKEN is still set, so 'eris yak' keeps authenticating with it", "login-env-token-overrides"));
|
|
298
|
+
}
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yak-context.ts — best-effort provenance gather for `eris yak`.
|
|
3
|
+
*
|
|
4
|
+
* Produces a `YakProvenance` record (`@sibylline-labs/yak-contract`) — six
|
|
5
|
+
* optional categories, each a permissive record:
|
|
6
|
+
* `execution`, `harness`, `device`, `process`, `repo`, `git`. NEVER a gate,
|
|
7
|
+
* NEVER a delay a caller should notice: every individual probe is wrapped so
|
|
8
|
+
* one failure drops exactly that key, and the WHOLE gather is time-capped so
|
|
9
|
+
* a slow filesystem or a hung `git` cannot make `eris yak` itself hang.
|
|
10
|
+
*
|
|
11
|
+
* Explicitly OUT of this module, because a provenance probe must never spawn
|
|
12
|
+
* work it cannot bound or reap, or prompt the user:
|
|
13
|
+
*
|
|
14
|
+
* - no cloud CLI, no keychain read
|
|
15
|
+
* - no config merge (no root/leaf deep-merge, excludes or overrides) —
|
|
16
|
+
* `repo.service` below is a raw single-file peek at the nearest
|
|
17
|
+
* `eris.yaml`
|
|
18
|
+
* - no unbounded fan-out — exactly five bounded, individually-reaped `git`
|
|
19
|
+
* subprocesses (`gatherGit`), run in parallel, each capped at
|
|
20
|
+
* `GIT_PROBE_TIMEOUT_MS`
|
|
21
|
+
*
|
|
22
|
+
* `provenance` is redacted too: an origin URL can carry a token. That
|
|
23
|
+
* redaction pass is `plugins/yak/yak-redact.ts`, applied by the submission
|
|
24
|
+
* core (`yak-submit.ts`), which is where the whole payload — `summary`, `body`,
|
|
25
|
+
* `evidence`, AND this provenance record — gets one redaction pass before
|
|
26
|
+
* key derivation. This module only gathers; it does not redact.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { hostname } from "node:os";
|
|
32
|
+
import type { YakProvenance } from "eris/yak-contract";
|
|
33
|
+
import { asText, runBounded, type Runner } from "./yak-identity";
|
|
34
|
+
|
|
35
|
+
const GIT_PROBE_TIMEOUT_MS = 500;
|
|
36
|
+
/** Overall wall-clock budget for the whole gather. Generous headroom over
|
|
37
|
+
* the five GIT_PROBE_TIMEOUT_MS probes (which run in PARALLEL, so their
|
|
38
|
+
* worst case is ~one timeout, not five) plus the synchronous/fs-only
|
|
39
|
+
* categories, while still being "an agent won't notice this" fast for a
|
|
40
|
+
* mid-task CLI call. If this fires, every category still in flight is
|
|
41
|
+
* simply omitted — never awaited to completion, never thrown. */
|
|
42
|
+
const OVERALL_GATHER_TIMEOUT_MS = 1_500;
|
|
43
|
+
/** Filesystem-root backstop for the `repo.service` walk-up. A real checkout
|
|
44
|
+
* never nests this deep; this exists so a symlink cycle or an unexpectedly
|
|
45
|
+
* deep tree can't turn a "cheap peek" into an unbounded walk. */
|
|
46
|
+
const REPO_WALK_MAX_DEPTH = 64;
|
|
47
|
+
|
|
48
|
+
export type ProvenanceOptions = {
|
|
49
|
+
env?: Record<string, string | undefined>;
|
|
50
|
+
cwd?: string;
|
|
51
|
+
pid?: number;
|
|
52
|
+
ppid?: number;
|
|
53
|
+
run?: Runner;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gather best-effort provenance. Never throws, never exceeds
|
|
58
|
+
* `OVERALL_GATHER_TIMEOUT_MS` in wall time regardless of how slow any single
|
|
59
|
+
* probe is, and every category it returns has at least one key (an empty
|
|
60
|
+
* category is omitted rather than emitted as `{}`: absent means nothing was
|
|
61
|
+
* measured, so an empty object would claim a measurement that never happened).
|
|
62
|
+
*/
|
|
63
|
+
export async function gatherProvenance(opts: ProvenanceOptions = {}): Promise<YakProvenance> {
|
|
64
|
+
const env = opts.env ?? process.env;
|
|
65
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
66
|
+
const pid = opts.pid ?? process.pid;
|
|
67
|
+
const ppid = opts.ppid ?? process.ppid;
|
|
68
|
+
const run = opts.run ?? runBounded;
|
|
69
|
+
|
|
70
|
+
const provenance: YakProvenance = {};
|
|
71
|
+
|
|
72
|
+
const categories: Array<[keyof YakProvenance, Promise<Record<string, unknown> | undefined>]> = [
|
|
73
|
+
["execution", Promise.resolve(safeRecord(() => gatherExecution(env)))],
|
|
74
|
+
["harness", Promise.resolve(safeRecord(() => gatherHarness(env)))],
|
|
75
|
+
["device", Promise.resolve(safeRecord(() => gatherDevice()))],
|
|
76
|
+
["process", Promise.resolve(safeRecord(() => gatherProcess(env, cwd, pid, ppid)))],
|
|
77
|
+
["repo", safeRecordAsync(() => gatherRepo(cwd))],
|
|
78
|
+
["git", safeRecordAsync(() => gatherGit(cwd, run))],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
const deadline = new Promise<void>((resolve) => {
|
|
83
|
+
timer = setTimeout(resolve, OVERALL_GATHER_TIMEOUT_MS);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await Promise.race([
|
|
88
|
+
Promise.allSettled(
|
|
89
|
+
categories.map(([key, p]) =>
|
|
90
|
+
p.then((rec) => {
|
|
91
|
+
if (rec !== undefined) provenance[key] = rec;
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
deadline,
|
|
96
|
+
]);
|
|
97
|
+
} finally {
|
|
98
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return provenance;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- per-field helpers --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** Set `rec[key]` only when `producer()` returns a defined value; a thrown
|
|
107
|
+
* error or an `undefined` return drops exactly this ONE field and nothing
|
|
108
|
+
* else in the record. */
|
|
109
|
+
function trySet<T>(rec: Record<string, unknown>, key: string, producer: () => T | undefined): void {
|
|
110
|
+
try {
|
|
111
|
+
const value = producer();
|
|
112
|
+
if (value !== undefined) rec[key] = value;
|
|
113
|
+
} catch {
|
|
114
|
+
// this field drops; every other field in the same record is unaffected
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
119
|
+
return value && value.length > 0 ? value : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wrap a whole category producer: catch anything it throws, and treat a
|
|
123
|
+
* zero-key result as ABSENT (omit the category) rather than emitting `{}` —
|
|
124
|
+
* a category nobody could speak to is different from a category that spoke
|
|
125
|
+
* and said nothing, and only the record's own fields (via `trySet`) can
|
|
126
|
+
* legitimately be empty of content. */
|
|
127
|
+
function safeRecord(producer: () => Record<string, unknown>): Record<string, unknown> | undefined {
|
|
128
|
+
try {
|
|
129
|
+
const rec = producer();
|
|
130
|
+
return Object.keys(rec).length > 0 ? rec : undefined;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function safeRecordAsync(
|
|
137
|
+
producer: () => Promise<Record<string, unknown>>,
|
|
138
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
139
|
+
try {
|
|
140
|
+
const rec = await producer();
|
|
141
|
+
return Object.keys(rec).length > 0 ? rec : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- execution / harness / device / process (sync, no I/O) ------------------
|
|
148
|
+
|
|
149
|
+
/** Session identity — what task this yak belongs to, mirroring the
|
|
150
|
+
* `resolveExecutionId` ladder's own rungs (`yak-identity.ts`) as raw facts
|
|
151
|
+
* rather than the one resolved value, so a reader can see which rungs were
|
|
152
|
+
* actually populated in this process's environment. */
|
|
153
|
+
function gatherExecution(env: Record<string, string | undefined>): Record<string, unknown> {
|
|
154
|
+
const rec: Record<string, unknown> = {};
|
|
155
|
+
trySet(rec, "claude_code_session_id", () => nonEmpty(env.CLAUDE_CODE_SESSION_ID));
|
|
156
|
+
trySet(rec, "claude_code_host_session_id", () => nonEmpty(env.CLAUDE_CODE_HOST_SESSION_ID));
|
|
157
|
+
trySet(rec, "entrypoint", () => nonEmpty(env.CLAUDE_CODE_ENTRYPOINT));
|
|
158
|
+
return rec;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Which harness is running, and what version of it — the three env vars
|
|
162
|
+
* actually OBSERVED set in a live Claude Code session (`CLAUDECODE=1`,
|
|
163
|
+
* `AI_AGENT=claude-code_2-1-222_agent`, `CLAUDE_AGENT_SDK_VERSION=0.3.222`),
|
|
164
|
+
* not a guessed name. `AI_AGENT`'s value already embeds a version-shaped
|
|
165
|
+
* string; `CLAUDE_AGENT_SDK_VERSION` is a second, more literal version
|
|
166
|
+
* fact when the harness sets it. */
|
|
167
|
+
function gatherHarness(env: Record<string, string | undefined>): Record<string, unknown> {
|
|
168
|
+
const rec: Record<string, unknown> = {};
|
|
169
|
+
trySet(rec, "claudecode", () => nonEmpty(env.CLAUDECODE));
|
|
170
|
+
trySet(rec, "ai_agent", () => nonEmpty(env.AI_AGENT));
|
|
171
|
+
trySet(rec, "claude_agent_sdk_version", () => nonEmpty(env.CLAUDE_AGENT_SDK_VERSION));
|
|
172
|
+
return rec;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function gatherDevice(): Record<string, unknown> {
|
|
176
|
+
const rec: Record<string, unknown> = {};
|
|
177
|
+
trySet(rec, "hostname", () => nonEmpty(hostname()));
|
|
178
|
+
trySet(rec, "platform", () => nonEmpty(process.platform));
|
|
179
|
+
trySet(rec, "arch", () => nonEmpty(process.arch));
|
|
180
|
+
return rec;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function gatherProcess(
|
|
184
|
+
env: Record<string, string | undefined>,
|
|
185
|
+
cwd: string,
|
|
186
|
+
pid: number,
|
|
187
|
+
ppid: number,
|
|
188
|
+
): Record<string, unknown> {
|
|
189
|
+
const rec: Record<string, unknown> = {};
|
|
190
|
+
trySet(rec, "cwd", () => nonEmpty(cwd));
|
|
191
|
+
trySet(rec, "pid", () => pid);
|
|
192
|
+
trySet(rec, "ppid", () => ppid);
|
|
193
|
+
trySet(rec, "shell", () => nonEmpty(env.SHELL));
|
|
194
|
+
return rec;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- repo (a raw single-file peek, never a config merge) -------------------
|
|
198
|
+
|
|
199
|
+
async function gatherRepo(cwd: string): Promise<Record<string, unknown>> {
|
|
200
|
+
const rec: Record<string, unknown> = {};
|
|
201
|
+
const service = await findNearestErisYamlService(cwd);
|
|
202
|
+
if (service !== undefined) rec.service = service;
|
|
203
|
+
return rec;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Walk up from `startDir` looking for the nearest `eris.yaml` and read its
|
|
208
|
+
* top-level `service:` field directly — no root/leaf merge, no
|
|
209
|
+
* excludes/overrides. Bounded two ways: a hard iteration ceiling
|
|
210
|
+
* (`REPO_WALK_MAX_DEPTH`) and stopping at the enclosing git checkout's root
|
|
211
|
+
* (a `.git` entry, file or directory), so this cheap peek never wanders into
|
|
212
|
+
* a sibling repo above the one it was invoked from.
|
|
213
|
+
*/
|
|
214
|
+
async function findNearestErisYamlService(startDir: string): Promise<string | undefined> {
|
|
215
|
+
let dir = startDir;
|
|
216
|
+
for (let i = 0; i < REPO_WALK_MAX_DEPTH; i++) {
|
|
217
|
+
const candidate = join(dir, "eris.yaml");
|
|
218
|
+
try {
|
|
219
|
+
const raw = await fs.readFile(candidate, "utf8");
|
|
220
|
+
const parsed = Bun.YAML.parse(raw);
|
|
221
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
222
|
+
const service = (parsed as Record<string, unknown>).service;
|
|
223
|
+
if (typeof service === "string" && service.length > 0) return service;
|
|
224
|
+
}
|
|
225
|
+
// A manifest exists here but has no usable `service:` — it is still
|
|
226
|
+
// the NEAREST one, so stop rather than walking past it.
|
|
227
|
+
return undefined;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") return undefined; // unreadable — drop, never throw
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (await isGitCheckoutRoot(dir)) return undefined; // reached the repo root with no manifest
|
|
233
|
+
|
|
234
|
+
const parent = dirname(dir);
|
|
235
|
+
if (parent === dir) return undefined; // filesystem root
|
|
236
|
+
dir = parent;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function isGitCheckoutRoot(dir: string): Promise<boolean> {
|
|
242
|
+
try {
|
|
243
|
+
await fs.access(join(dir, ".git"));
|
|
244
|
+
return true;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- git (five bounded, individually-reaped subprocesses) -------------------
|
|
251
|
+
|
|
252
|
+
async function gatherGit(cwd: string, run: Runner): Promise<Record<string, unknown>> {
|
|
253
|
+
const rec: Record<string, unknown> = {};
|
|
254
|
+
|
|
255
|
+
const [root, origin, branch, sha, status] = await Promise.all([
|
|
256
|
+
run(["git", "rev-parse", "--show-toplevel"], cwd, GIT_PROBE_TIMEOUT_MS),
|
|
257
|
+
run(["git", "config", "--get", "remote.origin.url"], cwd, GIT_PROBE_TIMEOUT_MS),
|
|
258
|
+
run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd, GIT_PROBE_TIMEOUT_MS),
|
|
259
|
+
run(["git", "rev-parse", "HEAD"], cwd, GIT_PROBE_TIMEOUT_MS),
|
|
260
|
+
run(["git", "status", "--porcelain"], cwd, GIT_PROBE_TIMEOUT_MS),
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const rootText = asText(root);
|
|
264
|
+
if (rootText !== undefined) rec.root = rootText;
|
|
265
|
+
const originText = asText(origin);
|
|
266
|
+
if (originText !== undefined) rec.origin_url = originText;
|
|
267
|
+
const branchText = asText(branch);
|
|
268
|
+
if (branchText !== undefined) rec.branch = branchText;
|
|
269
|
+
const shaText = asText(sha);
|
|
270
|
+
if (shaText !== undefined) rec.head_sha = shaText;
|
|
271
|
+
|
|
272
|
+
// `dirty` is a BOOLEAN fact, not free text: absent only when the PROBE
|
|
273
|
+
// itself failed (no `git`, not a repo, timeout) — never when the tree is
|
|
274
|
+
// simply clean, which is a legitimate `false` produced by EMPTY stdout on
|
|
275
|
+
// exit 0. Routing this through `asText` (which treats empty as absent)
|
|
276
|
+
// would make a dirty tree indistinguishable from an unmeasured one.
|
|
277
|
+
if (status.ok) rec.dirty = status.stdout.trim().length > 0;
|
|
278
|
+
|
|
279
|
+
return rec;
|
|
280
|
+
}
|