omnirush 0.1.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.
@@ -0,0 +1,126 @@
1
+ // omnirush collect — one-shot workspace snapshot upload (the manual
2
+ // trigger; the same snapshots also flow automatically during agent
3
+ // sessions via the omnirush pi extension).
4
+ //
5
+ // Builds the standard collect envelope (schema_version 1, snapshot_type
6
+ // "start") for a project directory and POSTs it zstd-compressed to
7
+ // {origin}/collect with the device access token. Requires the bundled
8
+ // bun (zstd) or Node >= 22.15.
9
+ //
10
+ // Usage: omnirush collect [--dir PATH] [--yes] [--dry-run]
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import path from "node:path";
14
+ import readline from "node:readline";
15
+
16
+ import { omniDir, resolveOrigin, gatewayUrlForOrigin } from "./extensions/omnirush/auth";
17
+ import {
18
+ buildEnvelopePayload,
19
+ collectFiles,
20
+ compressZstd,
21
+ MAX_COMPRESSED_BYTES,
22
+ MAX_COLLECTOR_SESSION_BYTES,
23
+ } from "./extensions/omnirush/collector-lib";
24
+
25
+ function parseArgs(argv: string[]): { dir: string; yes: boolean; dryRun: boolean } {
26
+ let dir = process.cwd();
27
+ let yes = false;
28
+ let dryRun = false;
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const arg = argv[i];
31
+ if (arg === "--dir" && argv[i + 1]) {
32
+ dir = path.resolve(argv[++i]);
33
+ } else if (arg === "--yes") {
34
+ yes = true;
35
+ } else if (arg === "--dry-run") {
36
+ dryRun = true;
37
+ } else if (arg === "-h" || arg === "--help") {
38
+ console.log("usage: omnirush collect [--dir PATH] [--yes] [--dry-run]");
39
+ process.exit(0);
40
+ }
41
+ }
42
+ return { dir, yes, dryRun };
43
+ }
44
+
45
+ async function confirm(question: string): Promise<boolean> {
46
+ if (!process.stdin.isTTY) return false;
47
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
48
+ const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
49
+ rl.close();
50
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
51
+ }
52
+
53
+ async function main() {
54
+ const { dir, yes, dryRun } = parseArgs(process.argv.slice(2));
55
+ const sessionId = `manual-${randomBytes(6).toString("hex")}`;
56
+ const workspaceId = path.basename(dir) || "workspace";
57
+
58
+ const files = await collectFiles(dir, MAX_COLLECTOR_SESSION_BYTES, workspaceId, {
59
+ id: sessionId,
60
+ segment: 1,
61
+ resumed: false,
62
+ });
63
+ const bytes = files.reduce((total, file) => total + Buffer.byteLength(file.content), 0);
64
+ if (files.length <= 1) {
65
+ console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
66
+ process.exit(1);
67
+ }
68
+ console.log(
69
+ `collect: ${files.length - 1} files (${bytes} bytes, denylist/caps applied) as session ${sessionId}`,
70
+ );
71
+
72
+ const state = { id: sessionId, segment: 1, resumed: false };
73
+ const payload = buildEnvelopePayload(state, "start", files);
74
+ const compressed = await compressZstd(payload);
75
+ console.log(
76
+ `envelope: ${payload.length} bytes plain, ${compressed.length} bytes zstd (cap ${MAX_COMPRESSED_BYTES})`,
77
+ );
78
+
79
+ if (dryRun) {
80
+ console.log("dry run — no upload performed");
81
+ return;
82
+ }
83
+ if (!yes && !(await confirm(`Upload ${files.length - 1} files from ${dir} to Omnirush? [y/N] `))) {
84
+ console.log("aborted");
85
+ process.exit(1);
86
+ }
87
+
88
+ const origin = resolveOrigin(process.env);
89
+ const collectUrl = gatewayUrlForOrigin(origin).replace(/\/v1$/, "/collect");
90
+ const accessToken = (
91
+ process.env.OMNIRUSH_ACCESS_TOKEN ||
92
+ process.env.OMNIRUSH_TOKEN ||
93
+ ""
94
+ ).trim();
95
+ if (!accessToken) {
96
+ console.error("omnirush collect: no access token (run `omnirush login`)");
97
+ process.exit(1);
98
+ }
99
+ const response = await fetch(collectUrl, {
100
+ method: "POST",
101
+ headers: {
102
+ Authorization: `Bearer ${accessToken}`,
103
+ "Content-Type": "application/zstd",
104
+ "X-Omnirush-Session-ID": sessionId,
105
+ },
106
+ body: compressed.buffer.slice(
107
+ compressed.byteOffset,
108
+ compressed.byteOffset + compressed.byteLength,
109
+ ) as ArrayBuffer,
110
+ signal: AbortSignal.timeout(60_000),
111
+ });
112
+ if (!response.ok) {
113
+ await response.body?.cancel().catch(() => undefined);
114
+ console.error(`upload failed: HTTP ${response.status}`);
115
+ process.exit(1);
116
+ }
117
+ const extra = [...response.headers]
118
+ .filter(([name]) => name.toLowerCase().startsWith("x-omnirush-"))
119
+ .map(([name, value]) => `${name}: ${value}`);
120
+ console.log(`uploaded (HTTP ${response.status})${extra.length ? "\n " + extra.join("\n ") : ""}`);
121
+ }
122
+
123
+ main().catch((error) => {
124
+ console.error(`omnirush collect failed: ${error?.message ?? error}`);
125
+ process.exit(1);
126
+ });
@@ -0,0 +1,419 @@
1
+ // Omnirush device-flow auth core — shared by the CLI (src/bin.js), the
2
+ // extensions (sota guard 401-refresh, collector), and node:test.
3
+ //
4
+ // Plain ESM JavaScript on node builtins only: pi extensions are loaded
5
+ // through jiti with a fixed virtual-module set (pi packages + typebox),
6
+ // so this file must never import an npm package. Loaded as a relative
7
+ // import from sibling .ts extensions and directly by node from the repo.
8
+ //
9
+ // Endpoint contract (live-verified 2026-09-22 against
10
+ // https://omnirush.ai/omnirush):
11
+ // POST /device/authorize {device_name, platform}
12
+ // -> 200 {device_code, user_code, verification_uri,
13
+ // verification_uri_complete, expires_in, interval}
14
+ // POST /device/token {device_code}
15
+ // -> 200 {access_token, refresh_token, gateway_url?} (approved)
16
+ // -> 428 {"detail":"authorization_pending"} (keep polling)
17
+ // -> 400 {"detail":"expired_token"} (unknown/expired)
18
+ // POST /device/refresh {refresh_token}
19
+ // -> 200 {access_token, refresh_token, gateway_url?} (rotate both)
20
+ // -> 401 {"detail":"refresh_token_invalid_or_expired"}
21
+ // GET /device/me -> 200 device identity | 401 device_token_invalid
22
+ // POST /collect -> zstd body, X-Omnirush-Session-ID header
23
+ //
24
+ // SECURITY: nothing in this module ever returns or logs a token; error
25
+ // messages carry only server `detail` strings and HTTP statuses.
26
+
27
+ import { spawn } from "node:child_process";
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+
32
+ export const DEFAULT_ORIGIN = "https://omnirush.ai/omnirush";
33
+ export const DEFAULT_POLL_INTERVAL_SEC = 5;
34
+ export const MAX_POLL_INTERVAL_SEC = 30;
35
+
36
+ const AUTH_FILE = "auth.json";
37
+ const PENDING_FILE = "device-flow.json";
38
+
39
+ /** Strip a trailing "/v1" (and slashes) from a gateway URL to get the manager origin. */
40
+ export function originFromGatewayUrl(gatewayUrl) {
41
+ return String(gatewayUrl || "")
42
+ .trim()
43
+ .replace(/\/+$/, "")
44
+ .replace(/\/v1$/, "");
45
+ }
46
+
47
+ /** The pi provider base URL for a manager origin. */
48
+ export function gatewayUrlForOrigin(origin) {
49
+ return `${String(origin).replace(/\/+$/, "")}/v1`;
50
+ }
51
+
52
+ /**
53
+ * Manager origin resolution order: OMNIRUSH_ORIGIN, OMNIRUSH_GATEWAY_URL
54
+ * (stripped of its /v1 suffix), shipped default.
55
+ */
56
+ export function resolveOrigin(env = process.env) {
57
+ const direct = (env.OMNIRUSH_ORIGIN || "").trim().replace(/\/+$/, "");
58
+ if (direct) return direct;
59
+ const viaGateway = originFromGatewayUrl(env.OMNIRUSH_GATEWAY_URL);
60
+ if (viaGateway) return viaGateway;
61
+ return DEFAULT_ORIGIN;
62
+ }
63
+
64
+ /** Runtime state dir (~/.omnirush by default); OMNIRUSH_DIR overrides (tests). */
65
+ export function omniDir(env = process.env) {
66
+ return env.OMNIRUSH_DIR
67
+ ? path.resolve(env.OMNIRUSH_DIR)
68
+ : path.join(os.homedir(), ".omnirush");
69
+ }
70
+
71
+ function ensureDir(dir) {
72
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
73
+ }
74
+
75
+ function readJsonFile(filePath) {
76
+ try {
77
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Enforce 0600 where POSIX mode bits exist (repairing files that were
85
+ * written too openly). On Windows chmod is a permissions no-op — it only
86
+ * toggles the read-only flag — so the store relies on the user-profile
87
+ * ACLs there and this is deliberately skipped.
88
+ */
89
+ function repairPosixMode(filePath) {
90
+ if (process.platform === "win32") return;
91
+ try {
92
+ fs.chmodSync(filePath, 0o600);
93
+ } catch {
94
+ /* best effort */
95
+ }
96
+ }
97
+
98
+ function writeJsonFile0600(dir, name, value) {
99
+ ensureDir(dir);
100
+ const filePath = path.join(dir, name);
101
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
102
+ repairPosixMode(filePath);
103
+ }
104
+
105
+ export function authFilePath(dir) {
106
+ return path.join(dir, AUTH_FILE);
107
+ }
108
+
109
+ /** Valid stored credentials or null. Repairs overly-open file modes. */
110
+ export function loadAuth(dir) {
111
+ const value = readJsonFile(authFilePath(dir));
112
+ if (!value || typeof value !== "object") return null;
113
+ const accessToken = typeof value.accessToken === "string" ? value.accessToken : "";
114
+ const refreshToken = typeof value.refreshToken === "string" ? value.refreshToken : "";
115
+ if (!accessToken || !refreshToken) return null;
116
+ const gatewayUrl =
117
+ typeof value.gatewayUrl === "string" && value.gatewayUrl ? value.gatewayUrl : undefined;
118
+ repairPosixMode(authFilePath(dir));
119
+ return { accessToken, refreshToken, ...(gatewayUrl ? { gatewayUrl } : {}) };
120
+ }
121
+
122
+ export function saveAuth(dir, auth) {
123
+ if (!auth?.accessToken || !auth?.refreshToken) {
124
+ throw new Error("refusing to store incomplete omnirush credentials");
125
+ }
126
+ writeJsonFile0600(dir, AUTH_FILE, {
127
+ accessToken: auth.accessToken,
128
+ refreshToken: auth.refreshToken,
129
+ ...(auth.gatewayUrl ? { gatewayUrl: auth.gatewayUrl } : {}),
130
+ savedAt: new Date().toISOString(),
131
+ });
132
+ }
133
+
134
+ export function clearAuth(dir) {
135
+ for (const name of [AUTH_FILE, PENDING_FILE]) {
136
+ try {
137
+ fs.rmSync(path.join(dir, name), { force: true });
138
+ } catch {
139
+ /* best effort */
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Login state kept while a device code awaits browser approval (doctor reads this). */
145
+ export function savePendingFlow(dir, flow) {
146
+ writeJsonFile0600(dir, PENDING_FILE, flow);
147
+ }
148
+
149
+ export function loadPendingFlow(dir) {
150
+ const value = readJsonFile(path.join(dir, PENDING_FILE));
151
+ if (!value || typeof value !== "object") return null;
152
+ if (typeof value.device_code !== "string" || !value.device_code) return null;
153
+ return value;
154
+ }
155
+
156
+ export function clearPendingFlow(dir) {
157
+ try {
158
+ fs.rmSync(path.join(dir, PENDING_FILE), { force: true });
159
+ } catch {
160
+ /* best effort */
161
+ }
162
+ }
163
+
164
+ function detailOf(payload, fallback) {
165
+ if (payload && typeof payload === "object") {
166
+ const detail = payload.detail;
167
+ if (typeof detail === "string") return detail;
168
+ if (detail != null) return "invalid request";
169
+ }
170
+ return fallback;
171
+ }
172
+
173
+ async function postJson(url, body, fetchImpl, timeoutMs = 20_000) {
174
+ const response = await fetchImpl(url, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify(body),
178
+ signal: AbortSignal.timeout(timeoutMs),
179
+ });
180
+ let payload = null;
181
+ try {
182
+ payload = await response.json();
183
+ } catch {
184
+ /* non-JSON body — fall through with payload null */
185
+ }
186
+ return { response, payload };
187
+ }
188
+
189
+ /** Step 1 of the device flow: create a pending authorization. */
190
+ export async function deviceAuthorize(origin, { deviceName, platform, fetchImpl } = {}) {
191
+ const doFetch = fetchImpl ?? globalThis.fetch;
192
+ const { response, payload } = await postJson(`${origin}/device/authorize`, {
193
+ device_name: deviceName,
194
+ platform,
195
+ }, doFetch);
196
+ if (!response.ok) {
197
+ throw new Error(`device authorize failed: HTTP ${response.status} ${detailOf(payload, "")}`.trim());
198
+ }
199
+ const deviceCode = typeof payload?.device_code === "string" ? payload.device_code : "";
200
+ const userCode = typeof payload?.user_code === "string" ? payload.user_code : "";
201
+ const verificationUriComplete =
202
+ typeof payload?.verification_uri_complete === "string"
203
+ ? payload.verification_uri_complete
204
+ : undefined;
205
+ if (!deviceCode || !userCode || !verificationUriComplete) {
206
+ throw new Error("device authorize response missing device_code/user_code/verification_uri_complete");
207
+ }
208
+ const intervalSec =
209
+ typeof payload.interval === "number" && payload.interval > 0
210
+ ? payload.interval
211
+ : DEFAULT_POLL_INTERVAL_SEC;
212
+ const expiresInSec =
213
+ typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : 600;
214
+ return {
215
+ deviceCode,
216
+ userCode,
217
+ verificationUri: typeof payload.verification_uri === "string" ? payload.verification_uri : undefined,
218
+ verificationUriComplete,
219
+ intervalSec,
220
+ expiresAt: Date.now() + expiresInSec * 1000,
221
+ };
222
+ }
223
+
224
+ export async function deviceToken(origin, { deviceCode, fetchImpl } = {}) {
225
+ const doFetch = fetchImpl ?? globalThis.fetch;
226
+ const { response, payload } = await postJson(`${origin}/device/token`, { device_code: deviceCode }, doFetch);
227
+ const detail = detailOf(payload, "");
228
+ if (response.ok) {
229
+ const accessToken = typeof payload?.access_token === "string" ? payload.access_token : "";
230
+ const refreshToken = typeof payload?.refresh_token === "string" ? payload.refresh_token : "";
231
+ if (!accessToken || !refreshToken) {
232
+ return { status: "error", detail: "token response missing access_token/refresh_token" };
233
+ }
234
+ const gatewayUrl = typeof payload?.gateway_url === "string" && payload.gateway_url
235
+ ? originFromGatewayUrl(payload.gateway_url)
236
+ : undefined;
237
+ return {
238
+ status: "ok",
239
+ tokens: { accessToken, refreshToken, ...(gatewayUrl ? { gatewayUrl } : {}) },
240
+ };
241
+ }
242
+ if (response.status === 428 || detail === "authorization_pending") return { status: "pending" };
243
+ if (response.status === 429 || detail === "slow_down") return { status: "slow_down" };
244
+ if (detail === "expired_token") return { status: "expired" };
245
+ return { status: "error", detail: `HTTP ${response.status} ${detail}`.trim() };
246
+ }
247
+
248
+ /**
249
+ * Poll /device/token until approval. `sleep` defaults to a real timer and
250
+ * is injectable for tests; `onWait` observes each waiting round (for the
251
+ * CLI spinner dots). Resolves with tokens or throws (expired/timeout).
252
+ */
253
+ export async function pollForToken(origin, {
254
+ deviceCode,
255
+ intervalSec = DEFAULT_POLL_INTERVAL_SEC,
256
+ expiresAt = Date.now() + 600_000,
257
+ fetchImpl,
258
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
259
+ onWait,
260
+ } = {}) {
261
+ let interval = Math.min(Math.max(intervalSec, 1), MAX_POLL_INTERVAL_SEC);
262
+ const doFetch = fetchImpl ?? globalThis.fetch;
263
+ for (;;) {
264
+ if (Date.now() >= expiresAt) {
265
+ throw new Error("device authorization expired — run `omnirush login` again");
266
+ }
267
+ const result = await deviceToken(origin, { deviceCode, fetchImpl: doFetch });
268
+ if (result.status === "ok") return result.tokens;
269
+ if (result.status === "expired") {
270
+ throw new Error("device authorization expired — run `omnirush login` again");
271
+ }
272
+ if (result.status === "slow_down") {
273
+ interval = Math.min(interval + 5, MAX_POLL_INTERVAL_SEC);
274
+ }
275
+ if (result.status === "error") throw new Error(`device token poll failed: ${result.detail}`);
276
+ onWait?.(interval);
277
+ await sleep(interval * 1000);
278
+ }
279
+ }
280
+
281
+ export class RefreshRejectedError extends Error {
282
+ constructor(detail) {
283
+ super(`refresh rejected: ${detail}`);
284
+ this.name = "RefreshRejectedError";
285
+ }
286
+ }
287
+
288
+ /** Rotate both tokens. Throws RefreshRejectedError when the refresh token is dead. */
289
+ export async function refreshTokens(origin, { refreshToken, fetchImpl } = {}) {
290
+ const doFetch = fetchImpl ?? globalThis.fetch;
291
+ const { response, payload } = await postJson(`${origin}/device/refresh`, { refresh_token: refreshToken }, doFetch);
292
+ if (!response.ok) {
293
+ throw new RefreshRejectedError(detailOf(payload, `HTTP ${response.status}`));
294
+ }
295
+ const accessToken = typeof payload?.access_token === "string" ? payload.access_token : "";
296
+ const nextRefreshToken = typeof payload?.refresh_token === "string" ? payload.refresh_token : "";
297
+ if (!accessToken || !nextRefreshToken) {
298
+ throw new Error("refresh response missing access_token/refresh_token");
299
+ }
300
+ const gatewayUrl = typeof payload?.gateway_url === "string" && payload.gateway_url
301
+ ? originFromGatewayUrl(payload.gateway_url)
302
+ : undefined;
303
+ return { accessToken, refreshToken: nextRefreshToken, ...(gatewayUrl ? { gatewayUrl } : {}) };
304
+ }
305
+
306
+ export async function deviceMe(origin, { accessToken, fetchImpl } = {}) {
307
+ const doFetch = fetchImpl ?? globalThis.fetch;
308
+ const response = await doFetch(`${origin}/device/me`, {
309
+ headers: { Authorization: `Bearer ${accessToken}` },
310
+ signal: AbortSignal.timeout(15_000),
311
+ });
312
+ if (response.status === 401) return null;
313
+ if (!response.ok) throw new Error(`device me failed: HTTP ${response.status}`);
314
+ return response.json();
315
+ }
316
+
317
+ /** Copy of an identity object safe to print: token-ish keys are dropped. */
318
+ export function redactForDisplay(value) {
319
+ if (Array.isArray(value)) return value.map((entry) => redactForDisplay(entry));
320
+ if (value && typeof value === "object") {
321
+ const out = {};
322
+ for (const [key, entry] of Object.entries(value)) {
323
+ if (/token|secret|password|api_?key/i.test(key)) continue;
324
+ out[key] = redactForDisplay(entry);
325
+ }
326
+ return out;
327
+ }
328
+ return value;
329
+ }
330
+
331
+ /**
332
+ * Single-flight token refresher (the GUI broker pattern): concurrent
333
+ * callers share one HTTP refresh; a caller whose expected token was
334
+ * already rotated resolves true without touching the network. Rotated
335
+ * credentials persist back to the state dir (0600) before resolution.
336
+ */
337
+ export function createRefresher({ dir, origin, fetchImpl, now = () => Date.now() } = {}) {
338
+ const doFetch = fetchImpl ?? globalThis.fetch;
339
+ let auth = dir ? loadAuth(dir) : null;
340
+ let inflight = null;
341
+ let lastPersistError = null;
342
+
343
+ const setAuth = (next) => {
344
+ auth = next;
345
+ };
346
+
347
+ const persist = async (next, attempt = 0) => {
348
+ try {
349
+ saveAuth(dir, next);
350
+ lastPersistError = null;
351
+ } catch (error) {
352
+ lastPersistError = error?.message ?? "persist failed";
353
+ if (attempt < 3 && auth?.refreshToken === next.refreshToken) {
354
+ await new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
355
+ await persist(next, attempt + 1);
356
+ }
357
+ }
358
+ };
359
+
360
+ const performRefresh = async (expectedAccessToken) => {
361
+ if (!auth || auth.accessToken !== expectedAccessToken) return true;
362
+ let next;
363
+ try {
364
+ next = await refreshTokens(origin, { refreshToken: auth.refreshToken, fetchImpl: doFetch });
365
+ } catch (error) {
366
+ if (error instanceof RefreshRejectedError) {
367
+ // Dead refresh token: caller surfaces a re-login hint. Keep the
368
+ // stored pair so doctor can still show an explicit invalid state.
369
+ return false;
370
+ }
371
+ throw error;
372
+ }
373
+ setAuth(next);
374
+ await persist(next);
375
+ return true;
376
+ };
377
+
378
+ return {
379
+ get auth() {
380
+ return auth;
381
+ },
382
+ get lastPersistError() {
383
+ return lastPersistError;
384
+ },
385
+ setAuth(next) {
386
+ setAuth(next);
387
+ },
388
+ reload() {
389
+ auth = dir ? loadAuth(dir) : null;
390
+ },
391
+ now,
392
+ async refresh(expectedAccessToken) {
393
+ if (!auth) return false;
394
+ if (auth.accessToken !== expectedAccessToken) return true;
395
+ inflight ??= performRefresh(expectedAccessToken).finally(() => {
396
+ inflight = null;
397
+ });
398
+ return inflight;
399
+ },
400
+ };
401
+ }
402
+
403
+ /** Best-effort browser open: xdg-open / open / cmd start. Never throws. */
404
+ export function openBrowser(url) {
405
+ try {
406
+ if (process.platform === "darwin") {
407
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
408
+ return true;
409
+ }
410
+ if (process.platform === "win32") {
411
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
412
+ return true;
413
+ }
414
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
415
+ return true;
416
+ } catch {
417
+ return false;
418
+ }
419
+ }