@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/CHANGELOG.md +31 -0
- package/lib/atlaso.ts +270 -15
- package/lib/capture_stats.ts +318 -0
- package/lib/connect.ts +43 -11
- package/lib/credential.ts +148 -0
- package/lib/drain.ts +186 -0
- package/lib/entitlement.ts +4 -2
- package/lib/lock.ts +217 -0
- package/lib/mcp.ts +300 -0
- package/lib/outbox.ts +348 -0
- package/lib/project.ts +268 -32
- package/package.json +7 -3
- package/src/index.ts +68 -13
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/** Content-free capture-attempt counters — the TS port of the Python client's
|
|
2
|
+
* capture_stats (atlaso_client/cache.py), one estimator across all hook tools
|
|
3
|
+
* (lab ruling 85a5c41b: semantic parity required, reason labels MAP onto the
|
|
4
|
+
* closed Python vocabulary — the server whitelist never grows).
|
|
5
|
+
*
|
|
6
|
+
* Semantics (byte-identical to Python, non-negotiable):
|
|
7
|
+
* attempts = gate-passed evaluations (reasons `signal` | `substantive`)
|
|
8
|
+
* accepted = server-confirmed "added" ONLY (a "duplicate" result goes to
|
|
9
|
+
* drops.duplicate — replaying one memory must read ~0%, not 100%)
|
|
10
|
+
* per-UTC-day cumulative ints + hours_active/max_hour_attempts (hour spread)
|
|
11
|
+
* accepted <= attempts clamped AT EMIT (a corrupt snapshot fails the tile
|
|
12
|
+
* closed server-side — a well-behaved client never trips it)
|
|
13
|
+
* payload = last <=35 days, additive `capture_stats` field on the batch body
|
|
14
|
+
*
|
|
15
|
+
* Counts only — no content and no content-derived hashes ever enter this file
|
|
16
|
+
* or the payload (enforced by the vocabulary-whitelist test).
|
|
17
|
+
*
|
|
18
|
+
* Cursor-specific hazard handled here: stop + sessionEnd can BOTH fire for one
|
|
19
|
+
* turn (two processes). Gate evaluations dedupe on the turn's idempotency key,
|
|
20
|
+
* and a "duplicate" result for a key we already counted is our own double-fire
|
|
21
|
+
* echo, not a real replay — it is skipped.
|
|
22
|
+
*
|
|
23
|
+
* Cross-process safety: read-modify-write under the shared flock helper; when
|
|
24
|
+
* the lock is unavailable (held:false — e.g. Windows) we still write, and the
|
|
25
|
+
* server's max-merge means a lost race can only UNDER-count, never inflate.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
closeSync,
|
|
29
|
+
fsyncSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
openSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
renameSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
38
|
+
import { atlasoDir } from "./atlaso";
|
|
39
|
+
import { withToolLock } from "./lock";
|
|
40
|
+
|
|
41
|
+
// ── the closed vocabulary (lab ruling 85a5c41b) ─────────────────────────────
|
|
42
|
+
// Every gate reason a TS connector can produce MUST map to exactly one of the
|
|
43
|
+
// Python labels below. An unmappable reason is a build bug, never a reason to
|
|
44
|
+
// extend the vocabulary (map-completeness test enforces totality).
|
|
45
|
+
export const ATTEMPT_REASONS = ["signal", "substantive"] as const;
|
|
46
|
+
export const REASON_MAP: Record<string, string> = {
|
|
47
|
+
empty: "empty",
|
|
48
|
+
chatter: "chatter",
|
|
49
|
+
too_short: "too_short",
|
|
50
|
+
too_long: "too_long",
|
|
51
|
+
system_turn: "system_turn",
|
|
52
|
+
// Cursor's meta_recall = "the user turn is our own injected recall block" —
|
|
53
|
+
// semantically a system-authored turn, so it maps there.
|
|
54
|
+
meta_recall: "system_turn",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MAX_DAYS = 35;
|
|
58
|
+
// Cursor fires stop AND sessionEnd for one turn seconds apart (two processes).
|
|
59
|
+
// A same-key event inside this window is that echo and must not double-count;
|
|
60
|
+
// beyond it, a repeated key is a REAL replay and counts — exactly like the
|
|
61
|
+
// Python client, which has no double-fire and counts every replay.
|
|
62
|
+
export const DOUBLE_FIRE_WINDOW_S = 120;
|
|
63
|
+
|
|
64
|
+
type DayRow = {
|
|
65
|
+
attempts: number;
|
|
66
|
+
accepted: number;
|
|
67
|
+
drops: Record<string, number>;
|
|
68
|
+
hours: Record<string, number>; // "00".."23" → attempts that hour
|
|
69
|
+
};
|
|
70
|
+
// Result stamps carry the counted VERDICT and the day it landed on, so a
|
|
71
|
+
// same-key echo can be reconciled order-independently (CodeRedTeam: the
|
|
72
|
+
// server may answer the losing hook's "duplicate" BEFORE the winning hook's
|
|
73
|
+
// "added" — the turn was captured, and the counters must say so whichever
|
|
74
|
+
// response is processed first).
|
|
75
|
+
type ResultStamp = { t: number; s: "added" | "duplicate"; d: string };
|
|
76
|
+
type Store = {
|
|
77
|
+
version: 1;
|
|
78
|
+
days: Record<string, DayRow>;
|
|
79
|
+
// content-free turn-key hashes → unix seconds of the last COUNTED event
|
|
80
|
+
gate_seen: Record<string, number>;
|
|
81
|
+
result_seen: Record<string, ResultStamp>;
|
|
82
|
+
last_sent_hash: string | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type CaptureStatsDay = {
|
|
86
|
+
day: string;
|
|
87
|
+
attempts: number;
|
|
88
|
+
accepted: number;
|
|
89
|
+
hours_active: number;
|
|
90
|
+
max_hour_attempts: number;
|
|
91
|
+
drops: Record<string, number>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function statsPath(): string {
|
|
95
|
+
return join(atlasoDir(), "capture_stats.json");
|
|
96
|
+
}
|
|
97
|
+
function lockPath(): string {
|
|
98
|
+
return join(atlasoDir(), "capture_stats.lock");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function utcDay(d = new Date()): string {
|
|
102
|
+
return d.toISOString().slice(0, 10);
|
|
103
|
+
}
|
|
104
|
+
function utcHour(d = new Date()): string {
|
|
105
|
+
return d.toISOString().slice(11, 13);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function emptyStore(): Store {
|
|
109
|
+
return { version: 1, days: {}, gate_seen: {}, result_seen: {}, last_sent_hash: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function load(): Store {
|
|
113
|
+
try {
|
|
114
|
+
const raw = JSON.parse(readFileSync(statsPath(), "utf8"));
|
|
115
|
+
if (raw && raw.version === 1 && typeof raw.days === "object") {
|
|
116
|
+
raw.gate_seen = raw.gate_seen ?? {};
|
|
117
|
+
// Tolerate pre-reconciliation stamps (bare unix seconds): keep the
|
|
118
|
+
// timestamp, assume the safe verdict ("added" never triggers an
|
|
119
|
+
// upgrade), and let the window expire them naturally.
|
|
120
|
+
const rs: Record<string, ResultStamp> = {};
|
|
121
|
+
for (const [k, v] of Object.entries(raw.result_seen ?? {})) {
|
|
122
|
+
if (typeof v === "number") rs[k] = { t: v, s: "added", d: "" };
|
|
123
|
+
else if (v && typeof (v as ResultStamp).t === "number") rs[k] = v as ResultStamp;
|
|
124
|
+
}
|
|
125
|
+
raw.result_seen = rs;
|
|
126
|
+
return raw as Store;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* missing / malformed → fresh */
|
|
130
|
+
}
|
|
131
|
+
return emptyStore();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function save(s: Store): void {
|
|
135
|
+
// prune to the newest MAX_DAYS so the file never grows unbounded
|
|
136
|
+
const days = Object.keys(s.days).sort();
|
|
137
|
+
for (const d of days.slice(0, Math.max(0, days.length - MAX_DAYS))) delete s.days[d];
|
|
138
|
+
// prune stale dedupe stamps relative to the NEWEST stamp (a logical clock —
|
|
139
|
+
// wall-clock pruning would wrongly drop stamps after a clock jump, and breaks
|
|
140
|
+
// deterministic tests that inject historical times)
|
|
141
|
+
{
|
|
142
|
+
const stamps = Object.values(s.gate_seen);
|
|
143
|
+
if (stamps.length) {
|
|
144
|
+
const cutoff = Math.max(...stamps) - 2 * DOUBLE_FIRE_WINDOW_S;
|
|
145
|
+
for (const k of Object.keys(s.gate_seen)) if (s.gate_seen[k] < cutoff) delete s.gate_seen[k];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
{
|
|
149
|
+
const stamps = Object.values(s.result_seen).map((v) => v.t);
|
|
150
|
+
if (stamps.length) {
|
|
151
|
+
const cutoff = Math.max(...stamps) - 2 * DOUBLE_FIRE_WINDOW_S;
|
|
152
|
+
for (const k of Object.keys(s.result_seen)) {
|
|
153
|
+
if (s.result_seen[k].t < cutoff) delete s.result_seen[k];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const target = statsPath();
|
|
158
|
+
const dir = dirname(target);
|
|
159
|
+
mkdirSync(dir, { recursive: true });
|
|
160
|
+
const tmp = join(dir, `.capture_stats.${process.pid}.${randomUUID()}.tmp`);
|
|
161
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(fd, JSON.stringify(s));
|
|
164
|
+
fsyncSync(fd);
|
|
165
|
+
} finally {
|
|
166
|
+
closeSync(fd);
|
|
167
|
+
}
|
|
168
|
+
renameSync(tmp, target);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function day(s: Store, key: string): DayRow {
|
|
172
|
+
let row = s.days[key];
|
|
173
|
+
if (!row) {
|
|
174
|
+
row = { attempts: 0, accepted: 0, drops: {}, hours: {} };
|
|
175
|
+
s.days[key] = row;
|
|
176
|
+
}
|
|
177
|
+
return row;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function mutate(fn: (s: Store) => void): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
await withToolLock(lockPath(), async () => {
|
|
183
|
+
const s = load();
|
|
184
|
+
fn(s);
|
|
185
|
+
save(s);
|
|
186
|
+
});
|
|
187
|
+
} catch {
|
|
188
|
+
/* counters are best-effort — never break capture */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Record one gate evaluation. `turnKey` (the deposit idempotency key) dedupes
|
|
193
|
+
* the stop+sessionEnd double-fire: the same key inside DOUBLE_FIRE_WINDOW_S is
|
|
194
|
+
* an echo and is skipped; beyond the window it counts (real-replay parity with
|
|
195
|
+
* the Python client). */
|
|
196
|
+
export async function recordGate(
|
|
197
|
+
reason: string,
|
|
198
|
+
opts: { turnKey?: string | null; now?: Date } = {},
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const now = opts.now ?? new Date();
|
|
201
|
+
const nowS = Math.floor(now.getTime() / 1000);
|
|
202
|
+
const dk = utcDay(now);
|
|
203
|
+
await mutate((s) => {
|
|
204
|
+
if (opts.turnKey) {
|
|
205
|
+
const last = s.gate_seen[opts.turnKey];
|
|
206
|
+
if (last !== undefined && nowS - last < DOUBLE_FIRE_WINDOW_S) return; // echo
|
|
207
|
+
s.gate_seen[opts.turnKey] = nowS;
|
|
208
|
+
}
|
|
209
|
+
const row = day(s, dk);
|
|
210
|
+
if ((ATTEMPT_REASONS as readonly string[]).includes(reason)) {
|
|
211
|
+
row.attempts += 1;
|
|
212
|
+
const h = utcHour(now);
|
|
213
|
+
row.hours[h] = (row.hours[h] ?? 0) + 1;
|
|
214
|
+
} else {
|
|
215
|
+
const mapped = REASON_MAP[reason];
|
|
216
|
+
if (!mapped) return; // unmappable = build bug; never invent a label
|
|
217
|
+
row.drops[mapped] = (row.drops[mapped] ?? 0) + 1;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Count the server's per-item verdicts: "added" → accepted; "duplicate" → the
|
|
223
|
+
* duplicate bucket — UNLESS the item's key is one we already counted (that is
|
|
224
|
+
* our own double-fire echo, not a real replay).
|
|
225
|
+
*
|
|
226
|
+
* Order-independent reconciliation (CodeRedTeam gate): when stop+sessionEnd
|
|
227
|
+
* race, the server hands one hook "added" and the other "duplicate", and the
|
|
228
|
+
* LOCAL processing order of those two responses is arbitrary. Whichever lands
|
|
229
|
+
* first, one captured turn must read accepted=1, duplicate=0 — so a same-key
|
|
230
|
+
* "added" inside the window UPGRADES an earlier "duplicate" (the pair's truth
|
|
231
|
+
* is "captured"), and everything else is a skip. Only counted verdicts stamp
|
|
232
|
+
* the window: an "error"/"invalid" result must never suppress the retry's
|
|
233
|
+
* real verdict. */
|
|
234
|
+
export async function recordDepositResults(
|
|
235
|
+
results: Array<{ client_id?: string; status?: string }>,
|
|
236
|
+
opts: { now?: Date } = {},
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
if (!Array.isArray(results) || results.length === 0) return;
|
|
239
|
+
const now = opts.now ?? new Date();
|
|
240
|
+
const nowS = Math.floor(now.getTime() / 1000);
|
|
241
|
+
const dk = utcDay(now);
|
|
242
|
+
await mutate((s) => {
|
|
243
|
+
for (const r of results) {
|
|
244
|
+
const status =
|
|
245
|
+
r?.status === "added" ? "added" : r?.status === "duplicate" ? "duplicate" : null;
|
|
246
|
+
if (!status) continue; // uncounted verdicts never stamp the echo window
|
|
247
|
+
const key = r?.client_id;
|
|
248
|
+
if (key) {
|
|
249
|
+
const prev = s.result_seen[key];
|
|
250
|
+
if (prev && nowS - prev.t < DOUBLE_FIRE_WINDOW_S) {
|
|
251
|
+
if (prev.s === "duplicate" && status === "added") {
|
|
252
|
+
// adverse order: the echo's duplicate landed first — undo it and
|
|
253
|
+
// credit the accept on the same day it was counted.
|
|
254
|
+
const row0 = s.days[prev.d];
|
|
255
|
+
if (row0 && (row0.drops["duplicate"] ?? 0) > 0) {
|
|
256
|
+
row0.drops["duplicate"] -= 1;
|
|
257
|
+
if (row0.drops["duplicate"] === 0) delete row0.drops["duplicate"];
|
|
258
|
+
row0.accepted += 1;
|
|
259
|
+
} else {
|
|
260
|
+
day(s, dk).accepted += 1;
|
|
261
|
+
}
|
|
262
|
+
s.result_seen[key] = { t: prev.t, s: "added", d: prev.d };
|
|
263
|
+
}
|
|
264
|
+
continue; // echo — never double-counts
|
|
265
|
+
}
|
|
266
|
+
s.result_seen[key] = { t: nowS, s: status, d: dk };
|
|
267
|
+
}
|
|
268
|
+
const row = day(s, dk);
|
|
269
|
+
if (status === "added") {
|
|
270
|
+
row.accepted += 1;
|
|
271
|
+
} else {
|
|
272
|
+
row.drops["duplicate"] = (row.drops["duplicate"] ?? 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The additive batch-body payload: last <=35 UTC days, cumulative, clamped. */
|
|
279
|
+
export function buildCaptureStats(store?: Store): CaptureStatsDay[] {
|
|
280
|
+
const s = store ?? load();
|
|
281
|
+
return Object.keys(s.days)
|
|
282
|
+
.sort()
|
|
283
|
+
.slice(-MAX_DAYS)
|
|
284
|
+
.map((dk) => {
|
|
285
|
+
const row = s.days[dk];
|
|
286
|
+
const hours = Object.values(row.hours ?? {});
|
|
287
|
+
return {
|
|
288
|
+
day: dk,
|
|
289
|
+
attempts: row.attempts,
|
|
290
|
+
accepted: Math.min(row.accepted, row.attempts), // emit invariant
|
|
291
|
+
hours_active: Math.min(24, hours.filter((n) => n > 0).length),
|
|
292
|
+
max_hour_attempts: hours.length ? Math.max(...hours) : 0,
|
|
293
|
+
drops: { ...row.drops },
|
|
294
|
+
};
|
|
295
|
+
})
|
|
296
|
+
.filter((d) => d.attempts > 0 || d.accepted > 0 || Object.keys(d.drops).length > 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function payloadHash(days: CaptureStatsDay[]): string {
|
|
300
|
+
// hash of COUNTS ONLY — content never enters this module
|
|
301
|
+
return createHash("sha256").update(JSON.stringify(days)).digest("hex");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Returns the payload when the counters changed since the last confirmed send,
|
|
305
|
+
* else null (skip attaching). Call markStatsSent() after a successful push. */
|
|
306
|
+
export function pendingCaptureStats(): CaptureStatsDay[] | null {
|
|
307
|
+
const s = load();
|
|
308
|
+
const days = buildCaptureStats(s);
|
|
309
|
+
if (days.length === 0) return null;
|
|
310
|
+
return payloadHash(days) === s.last_sent_hash ? null : days;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function markStatsSent(days: CaptureStatsDay[]): Promise<void> {
|
|
314
|
+
const h = payloadHash(days);
|
|
315
|
+
await mutate((s) => {
|
|
316
|
+
s.last_sent_hash = h;
|
|
317
|
+
});
|
|
318
|
+
}
|
package/lib/connect.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { spawn } from "node:child_process";
|
|
|
24
24
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
25
|
import {
|
|
26
26
|
appendFileSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync,
|
|
27
|
-
renameSync, statSync, unlinkSync, writeFileSync, writeSync,
|
|
27
|
+
renameSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync,
|
|
28
28
|
} from "node:fs";
|
|
29
29
|
import { createServer, type Server } from "node:http";
|
|
30
30
|
import { hostname } from "node:os";
|
|
@@ -37,6 +37,7 @@ const LOCK_NAME = ".connecting";
|
|
|
37
37
|
const LOCK_TTL_MS = 15 * 60 * 1000;
|
|
38
38
|
const START_TIMEOUT_MS = 15000;
|
|
39
39
|
const POLL_TIMEOUT_MS = 15000;
|
|
40
|
+
const LOCK_HEARTBEAT_MS = 60 * 1000;
|
|
40
41
|
|
|
41
42
|
export function hasToken(): boolean {
|
|
42
43
|
return !!loadAuth()?.token;
|
|
@@ -50,11 +51,14 @@ function lockPath(): string {
|
|
|
50
51
|
* unpredictable temp name (O_EXCL, no symlink follow), fsync the file, atomic
|
|
51
52
|
* rename, then fsync the directory. Mirrors the Python `connect.save_auth`. */
|
|
52
53
|
export function writeAuth(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
opts: {
|
|
55
|
+
server: string;
|
|
56
|
+
token: string;
|
|
57
|
+
user_id: string;
|
|
58
|
+
device_id: string | null;
|
|
59
|
+
},
|
|
57
60
|
): string {
|
|
61
|
+
const { server, token, user_id, device_id } = opts;
|
|
58
62
|
const dir = atlasoDir();
|
|
59
63
|
mkdirSync(dir, { recursive: true });
|
|
60
64
|
try {
|
|
@@ -95,7 +99,11 @@ function openBrowser(url: string): void {
|
|
|
95
99
|
const plt = process.platform;
|
|
96
100
|
const cmd = plt === "darwin" ? "open" : plt === "win32" ? "cmd" : "xdg-open";
|
|
97
101
|
const args = plt === "win32" ? ["/c", "start", "", url] : [url];
|
|
98
|
-
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
102
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
103
|
+
// spawn failures arrive asynchronously on EventEmitter.error, outside the
|
|
104
|
+
// surrounding try/catch. Swallow them: the URL is also written to connect.log.
|
|
105
|
+
child.once("error", () => {});
|
|
106
|
+
child.unref();
|
|
99
107
|
} catch {
|
|
100
108
|
/* best-effort */
|
|
101
109
|
}
|
|
@@ -158,6 +166,18 @@ function waitForCode(server: Server, expectedState: string, timeoutMs: number):
|
|
|
158
166
|
/** Run the connect handshake to completion. 0 on success. Releases the lock. */
|
|
159
167
|
export async function runConnect(): Promise<number> {
|
|
160
168
|
let server: Server | null = null;
|
|
169
|
+
// Authorization can outlive the stale-lock TTL advertised by the server. Refresh
|
|
170
|
+
// the lock while this process is alive so another sessionStart cannot reclaim it
|
|
171
|
+
// and launch a second browser flow.
|
|
172
|
+
const heartbeat = setInterval(() => {
|
|
173
|
+
try {
|
|
174
|
+
const now = new Date();
|
|
175
|
+
utimesSync(lockPath(), now, now);
|
|
176
|
+
} catch {
|
|
177
|
+
/* manual connect or unwritable lock — best-effort */
|
|
178
|
+
}
|
|
179
|
+
}, LOCK_HEARTBEAT_MS);
|
|
180
|
+
heartbeat.unref();
|
|
161
181
|
try {
|
|
162
182
|
const base = (process.env.ATLASO_SERVER || loadAuth()?.server || defaultServer()).replace(/\/+$/, "");
|
|
163
183
|
const label = (hostname() || "this device").slice(0, 80);
|
|
@@ -226,11 +246,17 @@ export async function runConnect(): Promise<number> {
|
|
|
226
246
|
}
|
|
227
247
|
if (t?.status === "approved") {
|
|
228
248
|
if (!t.token || !t.user_id) return 1;
|
|
229
|
-
writeAuth(
|
|
249
|
+
writeAuth({
|
|
250
|
+
server: base,
|
|
251
|
+
token: t.token,
|
|
252
|
+
user_id: t.user_id,
|
|
253
|
+
device_id: t.device_id ?? null,
|
|
254
|
+
});
|
|
230
255
|
return 0;
|
|
231
256
|
}
|
|
232
257
|
return 1;
|
|
233
258
|
} finally {
|
|
259
|
+
clearInterval(heartbeat);
|
|
234
260
|
try {
|
|
235
261
|
server?.close();
|
|
236
262
|
} catch {
|
|
@@ -292,13 +318,19 @@ export function maybeAutoconnect(tool = "opencode"): boolean {
|
|
|
292
318
|
const lock = lockPath();
|
|
293
319
|
if (!acquireLock(lock)) return false;
|
|
294
320
|
try {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
321
|
+
const entry = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "connect.ts");
|
|
322
|
+
// Reuse the exact Bun executable running this hook. This avoids PATH drift in
|
|
323
|
+
// GUI launches; the override exists for deterministic failure-path tests.
|
|
324
|
+
const bun = process.env.ATLASO_BUN_PATH || process.execPath || "bun";
|
|
325
|
+
const child = spawn(bun, ["run", entry], {
|
|
298
326
|
stdio: "ignore",
|
|
299
327
|
detached: true,
|
|
300
328
|
env: { ...process.env, ATLASO_TOOL: tool },
|
|
301
|
-
})
|
|
329
|
+
});
|
|
330
|
+
// ENOENT and similar failures are emitted asynchronously, not thrown. Release
|
|
331
|
+
// our filesystem lock so the next session can retry immediately.
|
|
332
|
+
child.once("error", () => releaseConnectLock());
|
|
333
|
+
child.unref();
|
|
302
334
|
return true;
|
|
303
335
|
} catch {
|
|
304
336
|
try {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Per-tool credential resolution — the TS port of the Python client's
|
|
2
|
+
* `_credential.py` resolve()/exchange() state machine.
|
|
3
|
+
*
|
|
4
|
+
* Every Atlaso integration on a machine shares ONE bearer (~/.atlaso/auth.json), so
|
|
5
|
+
* the brain can't tell two tools on a device apart — "remove tool X" can't actually
|
|
6
|
+
* stop X. The fix: each tool trades the shared bearer for its OWN credential at
|
|
7
|
+
* ~/.atlaso/tools/<tool>.json, minted via POST /v1/device/exchange, under a kernel
|
|
8
|
+
* lock only that tool participates in. The brain then keys off the tool's own token.
|
|
9
|
+
*
|
|
10
|
+
* Two invariants are load-bearing (break either and the feature is a lie):
|
|
11
|
+
* 1. NEVER BRICK. Only a VERIFIED verdict from our own server (x-atlaso-response: 1)
|
|
12
|
+
* may take a tool offline. A failed exchange / edge 403 / 5xx / lock-miss is NOT
|
|
13
|
+
* a verdict — keep the shared bearer and retry next run. (On a normal device the
|
|
14
|
+
* shared bearer still works on the data plane; this is the WAF-brick lesson.)
|
|
15
|
+
* 2. TOMBSTONE. A verified `tool_revoked` means the user removed this tool — go
|
|
16
|
+
* local-only and do NOT fall back to the shared bearer, or we'd resurrect it.
|
|
17
|
+
* Only an EXPLICIT reconnect (the browser flow) lifts it, never this automatic path.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
clearToolAuth, defaultServer, loadAuth, loadToolAuth, saveToolAuth,
|
|
21
|
+
toolLockPath, toolsDir, type Auth,
|
|
22
|
+
} from "./atlaso";
|
|
23
|
+
import { withToolLock } from "./lock";
|
|
24
|
+
import { mkdirSync } from "node:fs";
|
|
25
|
+
import * as state from "./state";
|
|
26
|
+
|
|
27
|
+
const EXCHANGE_TIMEOUT_MS = 8000;
|
|
28
|
+
|
|
29
|
+
/** A credential can't be attributed unless server + user + device all match the
|
|
30
|
+
* shared bearer — a reconnect into a different account must not reuse a stale
|
|
31
|
+
* tool file. A null device id is a valid legacy identity when BOTH files agree. */
|
|
32
|
+
function sameIdentity(a: Auth, b: Auth): boolean {
|
|
33
|
+
return (
|
|
34
|
+
!!a.server && !!a.user_id &&
|
|
35
|
+
a.server === b.server &&
|
|
36
|
+
a.user_id === b.user_id &&
|
|
37
|
+
(a.device_id ?? null) === (b.device_id ?? null)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ExchangeResult =
|
|
42
|
+
| { kind: "minted"; token: string }
|
|
43
|
+
| { kind: "revoked" } // verified 403 tool_revoked — the user removed this tool
|
|
44
|
+
| { kind: "not_entitled" } // verified 409 — free plan, another tool owns the slot
|
|
45
|
+
| { kind: "unverified" }; // network / edge / 5xx / 200-without-token — not a verdict
|
|
46
|
+
|
|
47
|
+
/** Trade the shared bearer for this tool's own token. Only a 200-with-token mints;
|
|
48
|
+
* every unverified outcome returns `unverified` so the caller keeps the shared
|
|
49
|
+
* bearer. Verified 403 tool_revoked / 409 are the only offline-taking verdicts. */
|
|
50
|
+
async function exchange(shared: Auth, tool: string): Promise<ExchangeResult> {
|
|
51
|
+
const ctrl = new AbortController();
|
|
52
|
+
const timer = setTimeout(() => ctrl.abort(), EXCHANGE_TIMEOUT_MS);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(shared.server.replace(/\/+$/, "") + "/v1/device/exchange", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { Authorization: `Bearer ${shared.token}`, "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify({ tool }),
|
|
58
|
+
signal: ctrl.signal,
|
|
59
|
+
});
|
|
60
|
+
const verified = res.headers?.get("x-atlaso-response") === "1";
|
|
61
|
+
if (res.ok) {
|
|
62
|
+
const data = (await res.json().catch(() => null)) as { token?: unknown } | null;
|
|
63
|
+
if (data && typeof data.token === "string" && data.token) return { kind: "minted", token: data.token };
|
|
64
|
+
return { kind: "unverified" }; // 200 but no token → treat as transient, keep shared
|
|
65
|
+
}
|
|
66
|
+
if (verified) {
|
|
67
|
+
const err = res.headers?.get("x-atlaso-error") || "";
|
|
68
|
+
if (res.status === 403 && err === "tool_revoked") return { kind: "revoked" };
|
|
69
|
+
if (res.status === 409) return { kind: "not_entitled" };
|
|
70
|
+
// A verified 401 means the SHARED bearer itself is dead — not this tool. Fall
|
|
71
|
+
// back so the subsequent data-plane call retires the shared bearer (its job).
|
|
72
|
+
}
|
|
73
|
+
return { kind: "unverified" };
|
|
74
|
+
} catch {
|
|
75
|
+
return { kind: "unverified" };
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the credential to make cloud calls with for `tool`.
|
|
83
|
+
* - own credential present + same identity → use it (fast path, no network).
|
|
84
|
+
* - else mint one under the kernel lock (POST /v1/device/exchange).
|
|
85
|
+
* - unverified failure / no lock → the SHARED bearer (never-brick).
|
|
86
|
+
* - verified tombstone / not-entitled → null (go local-only, do NOT fall back).
|
|
87
|
+
* Returns null only when the tool must stay offline this run (or we're not connected).
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveCredential(tool: string): Promise<Auth | null> {
|
|
90
|
+
const shared = loadAuth();
|
|
91
|
+
if (!tool) return shared ? { ...shared, source: "shared" } : null;
|
|
92
|
+
// Not connected at all → nothing to mint from.
|
|
93
|
+
if (!shared || !shared.token || !shared.server) return null;
|
|
94
|
+
|
|
95
|
+
// Fast path: our own credential, provably ours → use it, no round-trip.
|
|
96
|
+
const own = loadToolAuth(tool);
|
|
97
|
+
if (own && sameIdentity(own, shared)) return { ...own, source: "own", tool };
|
|
98
|
+
|
|
99
|
+
// Mint under the lock. Ensure the dir exists so the lock file can be created.
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(toolsDir(), { recursive: true });
|
|
102
|
+
} catch {
|
|
103
|
+
/* if we can't make the dir, withToolLock will fail to lock → shared bearer */
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return await withToolLock(toolLockPath(tool), async (held) => {
|
|
107
|
+
if (!held) {
|
|
108
|
+
// No kernel lock (Windows, or contended past the deadline) → don't mint
|
|
109
|
+
// unlocked; ride the shared bearer this run and retry next time.
|
|
110
|
+
return { ...shared, source: "shared" } as Auth;
|
|
111
|
+
}
|
|
112
|
+
// Re-check under the lock — a peer may have minted while we waited.
|
|
113
|
+
const fresh = loadToolAuth(tool);
|
|
114
|
+
if (fresh && sameIdentity(fresh, shared)) return { ...fresh, source: "own", tool } as Auth;
|
|
115
|
+
if (fresh && !sameIdentity(fresh, shared)) clearToolAuth(tool); // foreign leftover
|
|
116
|
+
|
|
117
|
+
const r = await exchange(shared, tool);
|
|
118
|
+
if (r.kind === "minted") {
|
|
119
|
+
const cred = {
|
|
120
|
+
server: shared.server, token: r.token,
|
|
121
|
+
user_id: shared.user_id, device_id: shared.device_id, tool, version: 1,
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
saveToolAuth(tool, cred);
|
|
125
|
+
} catch {
|
|
126
|
+
/* couldn't persist — still usable this run */
|
|
127
|
+
}
|
|
128
|
+
// A fresh mint must not inherit the previous (dead) credential's suppression.
|
|
129
|
+
state.invalidate();
|
|
130
|
+
return { ...cred, source: "own" } as Auth;
|
|
131
|
+
}
|
|
132
|
+
if (r.kind === "revoked") {
|
|
133
|
+
clearToolAuth(tool);
|
|
134
|
+
// Tombstone: stay down; falling back to the shared bearer would resurrect a
|
|
135
|
+
// tool the user just removed. Only an explicit reconnect lifts this.
|
|
136
|
+
state.setLocalOnly(state.REVOKED, { tool, device_id: shared.device_id ?? null });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (r.kind === "not_entitled") {
|
|
140
|
+
// Free plan, a different tool owns the single slot → local-only. Don't fall
|
|
141
|
+
// back to the shared bearer (that would let this tool masquerade as entitled).
|
|
142
|
+
state.setLocalOnly(state.NOT_ENTITLED, { tool, device_id: shared.device_id ?? null });
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// Unverified → never-brick: keep the shared bearer, retry next run.
|
|
146
|
+
return { ...shared, source: "shared" } as Auth;
|
|
147
|
+
});
|
|
148
|
+
}
|