@atlaso-labs/opencode 0.1.0 → 0.2.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/lib/drain.ts ADDED
@@ -0,0 +1,186 @@
1
+ /** Outbox drain — the retry half of the durability guarantee.
2
+ *
3
+ * `outbox.ts` persists memories; this decides what a push attempt MEANT and moves
4
+ * each item to its next state. It is deliberately the only place the failure
5
+ * taxonomy lives, so "when do we give up on a memory" is one readable table rather
6
+ * than scattered conditionals.
7
+ *
8
+ * THE TAXONOMY. Getting this wrong fails in one of two directions — lose a user's
9
+ * memory, or wedge the queue behind an item that can never succeed:
10
+ *
11
+ * per-item `invalid` → QUARANTINE. The server rejected the SHAPE. Retrying
12
+ * is guaranteed to fail identically, forever.
13
+ * per-item anything else → SETTLED. added / duplicate / error are all "the
14
+ * server has seen this and formed a verdict". `error`
15
+ * settles because the batch endpoint returns it for a
16
+ * per-item engine failure it already logged; retrying
17
+ * would re-run the same deposit against the same
18
+ * engine. It is recorded, not silently discarded.
19
+ * item absent from results → RETRY. We cannot prove the server took it.
20
+ * HTTP 429 → RETRY, and STOP the pass. The server is shedding;
21
+ * hammering it is the opposite of helpful. Nothing is
22
+ * lost — this is exactly the case the outbox exists
23
+ * for, and the per-item budget makes 429 reachable.
24
+ * HTTP 401/403 → RETRY, and STOP. Either credentials are being
25
+ * rotated or an edge/WAF is blocking us. Both are
26
+ * transient from the memory's point of view, and the
27
+ * WAF sync-brick incident is precisely why a 403 must
28
+ * never cause us to discard data.
29
+ * HTTP 5xx / 0 (timeout) → RETRY, and STOP. Brain down, deploying, or network.
30
+ * HTTP 4xx (other) → QUARANTINE. A durable client-side defect.
31
+ *
32
+ * AMBIGUOUS TIMEOUTS ARE SAFE. A request that times out after the server committed
33
+ * the write is retried — and cannot duplicate, because `client_id` is the server's
34
+ * per-item idempotency key. The retry simply comes back `duplicate` and settles.
35
+ *
36
+ * A drain NEVER throws. It is called from editor hooks; a memory is worth less
37
+ * than the user's session.
38
+ */
39
+ import { depositDetailed, type Auth } from "./atlaso";
40
+ import { log } from "./log";
41
+ import {
42
+ maxDrainPerRun,
43
+ bumpAttempt,
44
+ enforceBounds,
45
+ hasPending,
46
+ pending,
47
+ quarantine,
48
+ settle,
49
+ type OutboxRecord,
50
+ } from "./outbox";
51
+
52
+ export interface DrainResult {
53
+ attempted: number;
54
+ settled: number;
55
+ retried: number;
56
+ quarantined: number;
57
+ stopped: boolean;
58
+ }
59
+
60
+ const EMPTY: DrainResult = { attempted: 0, settled: 0, retried: 0, quarantined: 0, stopped: false };
61
+
62
+ /** Statuses that mean "the server has formed a verdict on this item". Anything
63
+ * here leaves the queue. */
64
+ function isSettledStatus(s: string | undefined): boolean {
65
+ return s !== undefined && s !== "invalid";
66
+ }
67
+
68
+ /** Classify a whole-request failure. `stop` halts the pass: when the brain is down
69
+ * or shedding, the remaining items would fail identically and we would just be
70
+ * burning the user's hook budget. */
71
+ function classifyRequest(status: number): { retry: boolean; stop: boolean; why: string } {
72
+ if (status === 429) return { retry: true, stop: true, why: "rate limited" };
73
+ if (status === 401 || status === 403) return { retry: true, stop: true, why: `auth/edge ${status}` };
74
+ if (status === 0) return { retry: true, stop: true, why: "transport/timeout" };
75
+ if (status >= 500) return { retry: true, stop: true, why: `server ${status}` };
76
+ return { retry: false, stop: true, why: `client ${status}` }; // durable 4xx → quarantine
77
+ }
78
+
79
+ /**
80
+ * Attempt one bounded pass over the queue.
81
+ *
82
+ * Items go up ONE at a time rather than as one large batch on purpose: a batch
83
+ * shares a single fate, so one poisoned item would drag good memories into the
84
+ * same verdict and we could not tell which was which. Per-item costs more
85
+ * round-trips on a backlog, but a backlog is already the rare path, and
86
+ * correctness there is the entire point of this file.
87
+ */
88
+ /** The push used by a drain. Injectable so tests can drive the taxonomy directly
89
+ * without `mock.module`, which in Bun mutates the module registry for the WHOLE
90
+ * run and silently breaks any later file importing the same module. Production
91
+ * always uses the default. */
92
+ export type DepositFn = typeof depositDetailed;
93
+
94
+ export async function drain(
95
+ tool: string,
96
+ auth: Auth,
97
+ limit = maxDrainPerRun(),
98
+ deposit: DepositFn = depositDetailed,
99
+ ): Promise<DrainResult> {
100
+ const out: DrainResult = { ...EMPTY };
101
+ try {
102
+ enforceBounds(tool);
103
+ const items = pending(tool, limit);
104
+ if (!items.length) return out;
105
+
106
+ for (const rec of items) {
107
+ out.attempted++;
108
+ let res: Awaited<ReturnType<DepositFn>>;
109
+ try {
110
+ res = await deposit(auth, [rec.item]);
111
+ } catch (e) {
112
+ // depositDetailed already swallows transport errors; this is belt-and-braces
113
+ // so an unexpected throw can never abort the loop and strand the rest.
114
+ if (bumpAttempt(tool, rec, String(e)) === "quarantine") out.quarantined++;
115
+ else out.retried++;
116
+ out.stopped = true;
117
+ break;
118
+ }
119
+
120
+ if (res.ok) {
121
+ const verdict = res.results.find((r) => r.client_id === rec.client_id);
122
+ if (verdict && !isSettledStatus(verdict.status)) {
123
+ quarantine(tool, rec, `server rejected: ${verdict.status}`);
124
+ out.quarantined++;
125
+ } else if (verdict) {
126
+ settle(tool, rec.client_id);
127
+ out.settled++;
128
+ } else {
129
+ // 2xx but our item is not in the results — do not assume it landed.
130
+ if (bumpAttempt(tool, rec, "absent from results") === "quarantine") out.quarantined++;
131
+ else out.retried++;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ const c = classifyRequest(res.status);
137
+ if (!c.retry) {
138
+ quarantine(tool, rec, `${c.why}${res.error ? `: ${res.error}` : ""}`);
139
+ out.quarantined++;
140
+ } else if (bumpAttempt(tool, rec, c.why) === "quarantine") {
141
+ out.quarantined++;
142
+ } else {
143
+ out.retried++;
144
+ }
145
+ if (c.stop) {
146
+ out.stopped = true;
147
+ break;
148
+ }
149
+ }
150
+
151
+ if (out.attempted) {
152
+ log(
153
+ "drain",
154
+ `attempted=${out.attempted} settled=${out.settled} retried=${out.retried} ` +
155
+ `quarantined=${out.quarantined}${out.stopped ? " stopped" : ""}`,
156
+ );
157
+ }
158
+ } catch (e) {
159
+ // A drain must never break the editor session.
160
+ try {
161
+ log("drain", `error ${e}`);
162
+ } catch {
163
+ /* logging itself failed — nothing left to do */
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Cheap guard for latency-sensitive hooks: skip the whole drain (and its
170
+ * directory parsing) when the queue is empty, which is the overwhelmingly
171
+ * common case. */
172
+ export async function drainIfPending(
173
+ tool: string,
174
+ auth: Auth,
175
+ limit = maxDrainPerRun(),
176
+ deposit: DepositFn = depositDetailed,
177
+ ): Promise<DrainResult> {
178
+ try {
179
+ if (!hasPending(tool)) return { ...EMPTY };
180
+ } catch {
181
+ return { ...EMPTY };
182
+ }
183
+ return drain(tool, auth, limit, deposit);
184
+ }
185
+
186
+ export type { OutboxRecord };
@@ -16,7 +16,8 @@ export type { Verdict } from "./state";
16
16
  /** Should we attempt a cloud call right now? True = cloud-linked, False =
17
17
  * local-only this turn. Never throws (memory must never break a turn). Hits the
18
18
  * network only on a stale/foreign verdict (cached otherwise). */
19
- export async function online(auth: Auth | null, tool: string, deviceId: string | null): Promise<boolean> {
19
+ export async function online(auth: Auth | null, id: { tool: string; deviceId: string | null }): Promise<boolean> {
20
+ const { tool, deviceId } = id;
20
21
  if (!auth) return false;
21
22
  try {
22
23
  const st = state.get();
@@ -67,7 +68,8 @@ async function verify(auth: Auth, tool: string, deviceId: string | null): Promis
67
68
  }
68
69
 
69
70
  /** The current verdict for surfacing a user notice (no network). */
70
- export function cloudMode(auth: Auth | null, tool: string, deviceId: string | null): state.Verdict {
71
+ export function cloudMode(auth: Auth | null, id: { tool: string; deviceId: string | null }): state.Verdict {
72
+ const { tool, deviceId } = id;
71
73
  if (!auth) return { ...state.defaultState(), mode: state.LOCAL_ONLY, reason: state.NOT_CONNECTED };
72
74
  const st = state.get();
73
75
  if (!state.matches(st, tool, deviceId)) return state.defaultState(); // foreign → no notice
package/lib/lock.ts ADDED
@@ -0,0 +1,217 @@
1
+ /** Cross-process advisory lock for per-tool credential minting — a real kernel
2
+ * lock via bun:ffi flock(2), the TS counterpart of the Python client's
3
+ * fcntl.flock in `_credential.py`.
4
+ *
5
+ * WHY a kernel lock and not a lockfile-with-staleness: a self-managed lockfile
6
+ * needs a staleness rule, and every staleness rule is a TOCTOU race where two
7
+ * contenders both decide the other's lock is stale and stomp it. The kernel drops
8
+ * a flock when the holding process dies — no staleness to reason about, nothing to
9
+ * steal, and the lock file is NEVER unlinked (deleting a file another process holds
10
+ * an fd to is its own race).
11
+ *
12
+ * Where flock is unavailable (Windows, or any platform without bun:ffi) we fall back
13
+ * to an O_EXCL lockfile — see `withExclusiveFileLock` at the bottom for why that is
14
+ * acceptable there and was NOT acceptable as the primary mechanism. Before that
15
+ * fallback existed, Windows got `held: false` on every run, which meant it never
16
+ * called /v1/device/exchange, never observed `tool_revoked`, and so REMOVING A TOOL
17
+ * DID NOT STOP IT SYNCING on Windows. Every platform now mints per-tool credentials.
18
+ *
19
+ * Load-bearing invariant (never-brick): a lock we could not take is never a verdict.
20
+ * `held: false` still means "do not mint this run, keep the shared bearer, retry
21
+ * next run" — it must never take a tool offline.
22
+ */
23
+ import { closeSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
+
25
+ const LOCK_EX = 2;
26
+ const LOCK_NB = 4;
27
+ const LOCK_UN = 8;
28
+ const SPIN_MS = 50;
29
+
30
+ // How long to wait for a contended lock before giving up (→ shared bearer). Shorter
31
+ // than the exchange it guards (8s), so "proceed unlocked" is never tempting. Read
32
+ // per-call (not at module load) so it stays env-overridable for tests.
33
+ function lockTimeoutMs(): number {
34
+ const n = parseInt(process.env.ATLASO_LOCK_TIMEOUT_MS ?? "5000", 10);
35
+ return Number.isFinite(n) ? n : 5000;
36
+ }
37
+
38
+ type FlockFn = (fd: number, op: number) => number;
39
+
40
+ // Resolve flock(2) from libc once. null = no primitive on this platform → the
41
+ // caller degrades to the shared bearer. Cached (including the null) so we probe once.
42
+ let _flock: FlockFn | null | undefined;
43
+
44
+ function resolveFlock(): FlockFn | null {
45
+ if (_flock !== undefined) return _flock;
46
+ // Escape hatch: exercise the portable path on a machine that HAS flock. Tests use
47
+ // it, and it makes the Windows behaviour reproducible during support triage.
48
+ if (process.env.ATLASO_LOCK_NO_FLOCK === "1") {
49
+ _flock = null;
50
+ return null;
51
+ }
52
+ const libs =
53
+ process.platform === "darwin"
54
+ ? ["libSystem.B.dylib"]
55
+ : process.platform === "linux"
56
+ ? ["libc.so.6", "libc.so"]
57
+ : []; // win32 / others: flock is not the API — no primitive, degrade safely
58
+ for (const lib of libs) {
59
+ try {
60
+ // Lazy import so a platform without bun:ffi never trips over the import itself.
61
+ const { dlopen, FFIType } = require("bun:ffi");
62
+ const { symbols } = dlopen(lib, {
63
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
64
+ });
65
+ _flock = symbols.flock as unknown as FlockFn;
66
+ return _flock;
67
+ } catch {
68
+ /* try the next candidate */
69
+ }
70
+ }
71
+ _flock = null;
72
+ return null;
73
+ }
74
+
75
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
76
+
77
+ /**
78
+ * Run `fn(held)` holding an exclusive advisory lock on `lockPath`.
79
+ *
80
+ * - `held: true` — the lock is ours; it's safe to exchange + write the credential.
81
+ * - `held: false` — no primitive, couldn't open the file, or the 5s deadline passed
82
+ * while another instance held it. The caller MUST NOT mint; it falls back to the
83
+ * shared bearer and retries next run. The timeout is deliberately shorter than the
84
+ * exchange it guards, so "proceed unlocked" is never the tempting option.
85
+ *
86
+ * The lock is released (LOCK_UN) and the fd closed in `finally`, always. The lock
87
+ * file itself is left in place — never unlinked.
88
+ */
89
+ export async function withToolLock<T>(
90
+ lockPath: string,
91
+ fn: (held: boolean) => Promise<T>,
92
+ ): Promise<T> {
93
+ const flock = resolveFlock();
94
+ if (!flock) return withExclusiveFileLock(lockPath, fn);
95
+
96
+ let fd: number;
97
+ try {
98
+ // "a" = O_CREAT|O_WRONLY|O_APPEND — creates the lock file if absent, never
99
+ // truncates, and we never write to it; we only need a stable fd to flock.
100
+ fd = openSync(lockPath, "a", 0o600);
101
+ } catch {
102
+ return fn(false); // can't even open the lock file → don't mint unlocked
103
+ }
104
+
105
+ const deadline = Date.now() + lockTimeoutMs();
106
+ let held = false;
107
+ while (Date.now() < deadline) {
108
+ if (flock(fd, LOCK_EX | LOCK_NB) === 0) {
109
+ held = true;
110
+ break;
111
+ }
112
+ await sleep(SPIN_MS);
113
+ }
114
+
115
+ try {
116
+ return await fn(held);
117
+ } finally {
118
+ if (held) {
119
+ try {
120
+ flock(fd, LOCK_UN);
121
+ } catch {
122
+ /* releasing on close anyway */
123
+ }
124
+ }
125
+ try {
126
+ closeSync(fd);
127
+ } catch {
128
+ /* fd already gone */
129
+ }
130
+ }
131
+ }
132
+
133
+ // ── portable fallback lock (no flock: Windows, or any platform without bun:ffi) ──
134
+ //
135
+ // WHY THIS EXISTS. Yielding `held: false` on Windows looked conservative, but it was
136
+ // not: `resolveCredential` then returns the SHARED bearer without ever calling
137
+ // /v1/device/exchange, so the tool never mints its own credential and never observes
138
+ // a `tool_revoked` verdict. Net effect — on Windows, removing Cursor did not stop it
139
+ // syncing. The per-tool credential guarantee was simply false there.
140
+ // (cursor/plugins#157, Bugbot "Lock miss skips tool revoke", Medium/Security.)
141
+ //
142
+ // The obvious alternative — call exchange without holding a lock — is WRONG: the
143
+ // server's exchange ROTATES the credential in place (mints a new token, deletes the
144
+ // previous), so an un-persisted mint on every hook run would churn a fresh token row
145
+ // per invocation, forever.
146
+ //
147
+ // So: give Windows a real mutex instead. O_EXCL create is atomic on every platform,
148
+ // including Windows. The header above rejects lockfiles because staleness rules are
149
+ // TOCTOU races — that critique is correct, and the reason it is ACCEPTABLE here is
150
+ // the blast radius, not the absence of the race: this lock is held only across one
151
+ // exchange (≤8s), the stale threshold is an order of magnitude beyond that, and if
152
+ // two processes ever did mint concurrently the loser simply holds a rotated-away
153
+ // token, 401s on its next call, clears its own tool file and re-mints. Transient
154
+ // churn that self-heals — not corruption, and not a brick. Weighed against
155
+ // revocation silently not working on an entire operating system, that trade is easy.
156
+ const STALE_MS = 60_000;
157
+
158
+ function staleMs(): number {
159
+ const n = parseInt(process.env.ATLASO_LOCK_STALE_MS ?? "", 10);
160
+ return Number.isFinite(n) && n > 0 ? n : STALE_MS;
161
+ }
162
+
163
+ /** Separate path from the flock lock file: the two mechanisms must never contend
164
+ * on the same inode, and this one is unlinked on release while that one never is. */
165
+ function exclPath(lockPath: string): string {
166
+ return lockPath + ".excl";
167
+ }
168
+
169
+ async function withExclusiveFileLock<T>(
170
+ lockPath: string,
171
+ fn: (held: boolean) => Promise<T>,
172
+ ): Promise<T> {
173
+ const p = exclPath(lockPath);
174
+ const deadline = Date.now() + lockTimeoutMs();
175
+ let held = false;
176
+
177
+ while (Date.now() < deadline) {
178
+ try {
179
+ // "wx" = O_CREAT|O_EXCL|O_WRONLY — atomic create-or-fail on all platforms.
180
+ const fd = openSync(p, "wx", 0o600);
181
+ try {
182
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
183
+ } catch {
184
+ /* diagnostics only — holding the lock is what matters */
185
+ }
186
+ closeSync(fd);
187
+ held = true;
188
+ break;
189
+ } catch {
190
+ // Someone holds it, or it was orphaned by a process that died mid-exchange.
191
+ // Reclaim ONLY when far past any legitimate hold.
192
+ try {
193
+ if (Date.now() - statSync(p).mtimeMs > staleMs()) unlinkSync(p);
194
+ } catch {
195
+ /* vanished or unreadable — the next attempt settles it */
196
+ }
197
+ await sleep(SPIN_MS);
198
+ }
199
+ }
200
+
201
+ try {
202
+ return await fn(held);
203
+ } finally {
204
+ if (held) {
205
+ try {
206
+ unlinkSync(p); // release; an O_EXCL lock is held by the file's existence
207
+ } catch {
208
+ /* already reclaimed as stale — nothing to release */
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ /** Reset the cached flock probe. Tests only — lets one process exercise both paths. */
215
+ export function _resetFlockProbeForTests(): void {
216
+ _flock = undefined;
217
+ }