@longleash/cli 0.1.0-rc.11 → 0.1.0-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/bin/longleash.mjs +152 -32
- package/npm-shrinkwrap.json +94 -79
- package/package.json +2 -2
- package/runtime/app/dist/assets/app-CSvRpOMO.js +27 -0
- package/runtime/app/dist/assets/styles-By_DSD7A.css +1 -0
- package/runtime/app/dist/assets/{styles-ej0i2z2o.js → styles-Dfo-8KGF.js} +4 -4
- package/runtime/app/dist/assets/welcome-BPvArovG.js +41 -0
- package/runtime/app/dist/build.json +1 -1
- package/runtime/app/dist/index.html +3 -3
- package/runtime/app/dist/welcome.html +3 -3
- package/runtime/daemon/bin/longleashd.mjs +592 -53
- package/runtime/app/dist/assets/app-Dv_E_rRc.js +0 -27
- package/runtime/app/dist/assets/styles-Dr4-130w.css +0 -1
- package/runtime/app/dist/assets/welcome-CbADUhH1.js +0 -40
|
@@ -12,8 +12,8 @@ import { dirname as dirname4, join as join10, resolve as resolve6 } from "node:p
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
14
|
// ../daemon/src/daemon.ts
|
|
15
|
-
import { accessSync, constants, mkdirSync as mkdirSync3, existsSync as existsSync6, writeFileSync as writeFileSync2 } from "node:fs";
|
|
16
|
-
import { randomBytes as
|
|
15
|
+
import { accessSync, constants, mkdirSync as mkdirSync3, existsSync as existsSync6, writeFileSync as writeFileSync2, renameSync, rmSync } from "node:fs";
|
|
16
|
+
import { randomBytes as randomBytes7 } from "node:crypto";
|
|
17
17
|
|
|
18
18
|
// ../daemon/src/delegation-handoff.ts
|
|
19
19
|
async function pauseCurrentSessionOwner(managed, external, session, actor, reason) {
|
|
@@ -5425,6 +5425,79 @@ var IdeOpenSessionResultSchema = external_exports.object({
|
|
|
5425
5425
|
}
|
|
5426
5426
|
});
|
|
5427
5427
|
|
|
5428
|
+
// ../protocol/src/pairing.ts
|
|
5429
|
+
var VERIFIED_PAIRING_VERSION = 2;
|
|
5430
|
+
var PairingNonce = external_exports.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
5431
|
+
var PairingDeviceName = external_exports.string().min(1).max(64).refine(
|
|
5432
|
+
(name) => name.trim().length > 0 && !/[\p{Cc}\p{Cf}]/u.test(name),
|
|
5433
|
+
"Device name must be visible text without control characters"
|
|
5434
|
+
);
|
|
5435
|
+
var PairingTranscript = external_exports.object({
|
|
5436
|
+
v: external_exports.literal(VERIFIED_PAIRING_VERSION),
|
|
5437
|
+
challengeId: external_exports.string().min(1).max(128),
|
|
5438
|
+
attemptId: PairingNonce,
|
|
5439
|
+
phoneNonce: PairingNonce,
|
|
5440
|
+
daemonNonce: PairingNonce,
|
|
5441
|
+
deviceName: PairingDeviceName,
|
|
5442
|
+
expiresAt: external_exports.number().int().positive().safe()
|
|
5443
|
+
}).strict();
|
|
5444
|
+
var utf8 = (value) => new TextEncoder().encode(value);
|
|
5445
|
+
var hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
5446
|
+
function deriveVerifiedPairing(secret, raw) {
|
|
5447
|
+
PairingNonce.parse(secret);
|
|
5448
|
+
const t = PairingTranscript.parse(raw);
|
|
5449
|
+
const transcript = JSON.stringify([
|
|
5450
|
+
"longleash-pairing-v2",
|
|
5451
|
+
t.v,
|
|
5452
|
+
t.challengeId,
|
|
5453
|
+
t.attemptId,
|
|
5454
|
+
["phone", t.phoneNonce, t.deviceName],
|
|
5455
|
+
["daemon", t.daemonNonce],
|
|
5456
|
+
t.expiresAt
|
|
5457
|
+
]);
|
|
5458
|
+
const salt = sha256(utf8(transcript));
|
|
5459
|
+
const derive = (purpose) => hkdf(sha256, utf8(secret), salt, utf8(`longleash-pairing-v2/${purpose}`), 32);
|
|
5460
|
+
const digits = (BigInt(`0x${hex(derive("display-code"))}`) % 100000000n).toString().padStart(8, "0");
|
|
5461
|
+
return {
|
|
5462
|
+
code: `${digits.slice(0, 4)} ${digits.slice(4)}`,
|
|
5463
|
+
identity: { roomTag: hex(derive("room-tag")), frameKey: derive("frame-key") }
|
|
5464
|
+
};
|
|
5465
|
+
}
|
|
5466
|
+
var PairingBegin = external_exports.object({
|
|
5467
|
+
v: external_exports.literal(2),
|
|
5468
|
+
type: external_exports.literal("pair-begin"),
|
|
5469
|
+
challengeId: external_exports.string().min(1).max(128),
|
|
5470
|
+
phoneNonce: PairingNonce,
|
|
5471
|
+
deviceName: PairingDeviceName
|
|
5472
|
+
}).strict();
|
|
5473
|
+
var PairingDecision = external_exports.object({
|
|
5474
|
+
v: external_exports.literal(2),
|
|
5475
|
+
type: external_exports.enum(["pair-confirm", "pair-reject", "pair-ping"]),
|
|
5476
|
+
attemptId: PairingNonce
|
|
5477
|
+
}).strict();
|
|
5478
|
+
var PairingReply = external_exports.discriminatedUnion("type", [
|
|
5479
|
+
external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-transcript"), transcript: PairingTranscript, remainingMs: external_exports.number().int().min(1).max(3e5) }).strict(),
|
|
5480
|
+
external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-waiting"), attemptId: PairingNonce }).strict(),
|
|
5481
|
+
external_exports.object({
|
|
5482
|
+
v: external_exports.literal(2),
|
|
5483
|
+
type: external_exports.literal("paired"),
|
|
5484
|
+
attemptId: PairingNonce,
|
|
5485
|
+
token: external_exports.string().regex(/^llt_[A-Za-z0-9_-]{43}$/),
|
|
5486
|
+
relaySecret: PairingNonce,
|
|
5487
|
+
deviceId: external_exports.string().min(1).max(128)
|
|
5488
|
+
}).strict(),
|
|
5489
|
+
external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-error"), reason: external_exports.enum([
|
|
5490
|
+
"update-required",
|
|
5491
|
+
"invalid-input",
|
|
5492
|
+
"expired",
|
|
5493
|
+
"competing-attempt",
|
|
5494
|
+
"cancelled",
|
|
5495
|
+
"disconnected",
|
|
5496
|
+
"unavailable"
|
|
5497
|
+
]) }).strict()
|
|
5498
|
+
]);
|
|
5499
|
+
var PAIRING_FRAME_LIMIT = 8192;
|
|
5500
|
+
|
|
5428
5501
|
// ../protocol/src/index.ts
|
|
5429
5502
|
var PROTOCOL_VERSION = 1;
|
|
5430
5503
|
var AgentKind = external_exports.enum(["claude", "gemini", "codex", "terminal"]);
|
|
@@ -6041,6 +6114,13 @@ var completePairingInput = external_exports.object({
|
|
|
6041
6114
|
deviceName: external_exports.string().min(1).max(64),
|
|
6042
6115
|
publicKey: external_exports.string().min(1).optional()
|
|
6043
6116
|
});
|
|
6117
|
+
var beginVerifiedInput = external_exports.object({
|
|
6118
|
+
v: external_exports.literal(2),
|
|
6119
|
+
challengeId: external_exports.string().min(1).max(128),
|
|
6120
|
+
secret: PairingNonce,
|
|
6121
|
+
deviceName: PairingDeviceName,
|
|
6122
|
+
phoneNonce: PairingNonce
|
|
6123
|
+
}).strict();
|
|
6044
6124
|
var id = (prefix) => `${prefix}_${randomBytes(12).toString("base64url")}`;
|
|
6045
6125
|
var sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
6046
6126
|
var hashesMatch = (a, b) => timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
@@ -6050,6 +6130,7 @@ var DeviceRegistry = class {
|
|
|
6050
6130
|
challengeTtlMs;
|
|
6051
6131
|
// Ephemeral by design: a daemon restart voids pending pairing QR codes.
|
|
6052
6132
|
challenges = /* @__PURE__ */ new Map();
|
|
6133
|
+
pairingResults = /* @__PURE__ */ new Map();
|
|
6053
6134
|
revokedListeners = /* @__PURE__ */ new Set();
|
|
6054
6135
|
pairedListeners = /* @__PURE__ */ new Set();
|
|
6055
6136
|
constructor(path, opts = {}) {
|
|
@@ -6072,17 +6153,22 @@ var DeviceRegistry = class {
|
|
|
6072
6153
|
this.now = opts.now ?? Date.now;
|
|
6073
6154
|
this.challengeTtlMs = opts.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
|
|
6074
6155
|
}
|
|
6075
|
-
createPairingChallenge() {
|
|
6156
|
+
createPairingChallenge(opts = {}) {
|
|
6076
6157
|
this.sweepExpiredChallenges();
|
|
6158
|
+
const version = opts.version ?? PAIRING_QR_VERSION;
|
|
6159
|
+
if (version !== 1 && version !== 2) throw new PairingError("invalid-input", "Unsupported pairing version");
|
|
6160
|
+
if (version === 2 && [...this.challenges.values()].filter((c) => c.version === 2).length >= 8) {
|
|
6161
|
+
throw new PairingError("pending-limit", "Too many pending verifications \u2014 finish or wait for expiry");
|
|
6162
|
+
}
|
|
6077
6163
|
const challengeId = id("chl");
|
|
6078
6164
|
const secret = randomBytes(32).toString("base64url");
|
|
6079
6165
|
const expiresAt = this.now() + this.challengeTtlMs;
|
|
6080
|
-
this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt });
|
|
6166
|
+
this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt, version });
|
|
6081
6167
|
return {
|
|
6082
6168
|
challengeId,
|
|
6083
6169
|
secret,
|
|
6084
6170
|
expiresAt,
|
|
6085
|
-
qrPayload: JSON.stringify({ v:
|
|
6171
|
+
qrPayload: JSON.stringify({ v: version, challengeId, secret })
|
|
6086
6172
|
};
|
|
6087
6173
|
}
|
|
6088
6174
|
completePairing(raw) {
|
|
@@ -6091,24 +6177,114 @@ var DeviceRegistry = class {
|
|
|
6091
6177
|
throw new PairingError("invalid-input", parsed.error.message);
|
|
6092
6178
|
}
|
|
6093
6179
|
const input = parsed.data;
|
|
6094
|
-
const challenge = this.
|
|
6095
|
-
if (
|
|
6096
|
-
throw new PairingError("
|
|
6180
|
+
const challenge = this.authorizeChallenge(input.challengeId, input.secret);
|
|
6181
|
+
if (challenge.version !== 1) {
|
|
6182
|
+
throw new PairingError("verification-required", "This QR requires verification on the phone and laptop");
|
|
6097
6183
|
}
|
|
6098
|
-
|
|
6184
|
+
this.challenges.delete(input.challengeId);
|
|
6185
|
+
return this.commitDevice(input.deviceName, input.publicKey);
|
|
6186
|
+
}
|
|
6187
|
+
/** Foundation only: transport adapters must authenticate/decrypt BEFORE calling this. */
|
|
6188
|
+
beginVerifiedPairing(raw, owner) {
|
|
6189
|
+
const parsed = beginVerifiedInput.safeParse(raw);
|
|
6190
|
+
if (!parsed.success) throw new PairingError("invalid-input", "Invalid v2 pairing request");
|
|
6191
|
+
const input = parsed.data;
|
|
6192
|
+
const challenge = this.authorizeChallenge(input.challengeId, input.secret);
|
|
6193
|
+
if (challenge.version !== 2) {
|
|
6194
|
+
throw new PairingError("update-required", "Update LongLeash and generate a new verified QR code");
|
|
6195
|
+
}
|
|
6196
|
+
if (challenge.pending) {
|
|
6197
|
+
const previous = challenge.pending.transcript;
|
|
6198
|
+
if (challenge.pending.owner === owner && previous.phoneNonce === input.phoneNonce && previous.deviceName === input.deviceName) {
|
|
6199
|
+
return { ...previous };
|
|
6200
|
+
}
|
|
6099
6201
|
this.challenges.delete(input.challengeId);
|
|
6202
|
+
throw new PairingError("competing-attempt", "A competing pairing attempt was detected \u2014 generate a new QR code");
|
|
6203
|
+
}
|
|
6204
|
+
const transcript = {
|
|
6205
|
+
v: 2,
|
|
6206
|
+
challengeId: input.challengeId,
|
|
6207
|
+
attemptId: randomBytes(32).toString("base64url"),
|
|
6208
|
+
phoneNonce: input.phoneNonce,
|
|
6209
|
+
daemonNonce: randomBytes(32).toString("base64url"),
|
|
6210
|
+
deviceName: input.deviceName,
|
|
6211
|
+
expiresAt: challenge.expiresAt
|
|
6212
|
+
};
|
|
6213
|
+
const { code } = deriveVerifiedPairing(input.secret, transcript);
|
|
6214
|
+
challenge.pending = { transcript, code, localConfirmed: false, ...owner ? { owner } : {} };
|
|
6215
|
+
return { ...transcript };
|
|
6216
|
+
}
|
|
6217
|
+
getPairingResult(challengeId) {
|
|
6218
|
+
this.sweepExpiredChallenges();
|
|
6219
|
+
const result = this.pairingResults.get(challengeId);
|
|
6220
|
+
return result ? { deviceId: result.deviceId } : null;
|
|
6221
|
+
}
|
|
6222
|
+
hasPairingChallenge(challengeId) {
|
|
6223
|
+
this.sweepExpiredChallenges();
|
|
6224
|
+
return this.challenges.has(challengeId);
|
|
6225
|
+
}
|
|
6226
|
+
/** Local operator cancellation also works before a phone has connected. */
|
|
6227
|
+
cancelPairingChallenge(challengeId) {
|
|
6228
|
+
this.challenges.delete(challengeId);
|
|
6229
|
+
}
|
|
6230
|
+
/** LOCAL ONLY. Never expose the laptop display code through a phone-facing route. */
|
|
6231
|
+
getPairingVerification(challengeId) {
|
|
6232
|
+
this.sweepExpiredChallenges();
|
|
6233
|
+
const pending = this.challenges.get(challengeId)?.pending;
|
|
6234
|
+
if (!pending) return null;
|
|
6235
|
+
const { attemptId, deviceName, expiresAt } = pending.transcript;
|
|
6236
|
+
return { attemptId, code: pending.code, deviceName, expiresAt };
|
|
6237
|
+
}
|
|
6238
|
+
/** LOCAL ONLY: a secret-authenticated loopback/CLI action, never a remote message. */
|
|
6239
|
+
confirmPairingLocally(challengeId, attemptId) {
|
|
6240
|
+
this.requireAttempt(challengeId, attemptId).localConfirmed = true;
|
|
6241
|
+
}
|
|
6242
|
+
/** Call only for an explicit phone 'Codes match' on this attempt's encrypted channel. */
|
|
6243
|
+
finishVerifiedPairing(challengeId, attemptId) {
|
|
6244
|
+
const pending = this.requireAttempt(challengeId, attemptId);
|
|
6245
|
+
if (!pending.localConfirmed) {
|
|
6246
|
+
throw new PairingError("local-confirmation-required", "Compare and confirm the code on your laptop first");
|
|
6247
|
+
}
|
|
6248
|
+
this.challenges.delete(challengeId);
|
|
6249
|
+
const result = this.commitDevice(pending.transcript.deviceName);
|
|
6250
|
+
this.pairingResults.set(challengeId, { deviceId: result.device.deviceId, expiresAt: this.now() + 6e4 });
|
|
6251
|
+
return result;
|
|
6252
|
+
}
|
|
6253
|
+
/** Rejection and transport disconnect share the same terminal transition. */
|
|
6254
|
+
cancelVerifiedPairing(challengeId, attemptId) {
|
|
6255
|
+
this.requireAttempt(challengeId, attemptId);
|
|
6256
|
+
this.challenges.delete(challengeId);
|
|
6257
|
+
}
|
|
6258
|
+
requireAttempt(challengeId, attemptId) {
|
|
6259
|
+
const challenge = this.requireChallenge(challengeId);
|
|
6260
|
+
if (!challenge.pending || challenge.pending.transcript.attemptId !== attemptId) {
|
|
6261
|
+
throw new PairingError("unknown-attempt", "Unknown pairing attempt");
|
|
6262
|
+
}
|
|
6263
|
+
return challenge.pending;
|
|
6264
|
+
}
|
|
6265
|
+
requireChallenge(challengeId) {
|
|
6266
|
+
const challenge = this.challenges.get(challengeId);
|
|
6267
|
+
if (!challenge) throw new PairingError("unknown-challenge", "Unknown or already-used pairing challenge");
|
|
6268
|
+
if (this.now() >= challenge.expiresAt) {
|
|
6269
|
+
this.challenges.delete(challengeId);
|
|
6100
6270
|
throw new PairingError("expired", "Pairing challenge expired \u2014 generate a new QR code");
|
|
6101
6271
|
}
|
|
6102
|
-
|
|
6272
|
+
return challenge;
|
|
6273
|
+
}
|
|
6274
|
+
authorizeChallenge(challengeId, secret) {
|
|
6275
|
+
const challenge = this.requireChallenge(challengeId);
|
|
6276
|
+
if (!hashesMatch(sha256Hex(secret), challenge.secretHash)) {
|
|
6103
6277
|
throw new PairingError("bad-secret", "Wrong pairing secret");
|
|
6104
6278
|
}
|
|
6105
|
-
|
|
6279
|
+
return challenge;
|
|
6280
|
+
}
|
|
6281
|
+
commitDevice(deviceName, publicKey) {
|
|
6106
6282
|
const token = `llt_${randomBytes(32).toString("base64url")}`;
|
|
6107
6283
|
const relaySecret = randomBytes(32).toString("base64url");
|
|
6108
6284
|
const device = {
|
|
6109
6285
|
deviceId: id("dev"),
|
|
6110
|
-
name:
|
|
6111
|
-
publicKey:
|
|
6286
|
+
name: deviceName,
|
|
6287
|
+
publicKey: publicKey ?? null,
|
|
6112
6288
|
createdAt: this.now(),
|
|
6113
6289
|
lastSeenAt: null,
|
|
6114
6290
|
revokedAt: null
|
|
@@ -6176,12 +6352,15 @@ var DeviceRegistry = class {
|
|
|
6176
6352
|
return rows.map((row) => this.toDevice(row));
|
|
6177
6353
|
}
|
|
6178
6354
|
close() {
|
|
6355
|
+
this.challenges.clear();
|
|
6356
|
+
this.pairingResults.clear();
|
|
6179
6357
|
this.rawDb.close();
|
|
6180
6358
|
}
|
|
6181
6359
|
sweepExpiredChallenges() {
|
|
6182
6360
|
const now = this.now();
|
|
6361
|
+
for (const [id2, result] of this.pairingResults) if (now >= result.expiresAt) this.pairingResults.delete(id2);
|
|
6183
6362
|
for (const [challengeId, challenge] of this.challenges) {
|
|
6184
|
-
if (now
|
|
6363
|
+
if (now >= challenge.expiresAt) this.challenges.delete(challengeId);
|
|
6185
6364
|
}
|
|
6186
6365
|
}
|
|
6187
6366
|
toDevice(row) {
|
|
@@ -7884,7 +8063,7 @@ import { join as join2 } from "node:path";
|
|
|
7884
8063
|
import Fastify from "fastify";
|
|
7885
8064
|
import fastifyStatic from "@fastify/static";
|
|
7886
8065
|
import websocket from "@fastify/websocket";
|
|
7887
|
-
import { randomBytes as
|
|
8066
|
+
import { randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
7888
8067
|
|
|
7889
8068
|
// ../daemon/src/external.ts
|
|
7890
8069
|
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
@@ -8806,6 +8985,139 @@ function transcriptDeltas(line) {
|
|
|
8806
8985
|
return out;
|
|
8807
8986
|
}
|
|
8808
8987
|
|
|
8988
|
+
// ../daemon/src/pairing-session.ts
|
|
8989
|
+
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
8990
|
+
function createPairingSession(opts) {
|
|
8991
|
+
const owner = randomBytes4(32).toString("base64url");
|
|
8992
|
+
const rendezvous = derivePairingIdentity(opts.challenge.secret);
|
|
8993
|
+
let transcript = null;
|
|
8994
|
+
let identity = null;
|
|
8995
|
+
let confirmed = false;
|
|
8996
|
+
let disposed = false;
|
|
8997
|
+
let committed = false;
|
|
8998
|
+
let lastSeen = Date.now();
|
|
8999
|
+
let windowAt = Date.now();
|
|
9000
|
+
let frames = 0;
|
|
9001
|
+
let queued = 0;
|
|
9002
|
+
let chain = Promise.resolve();
|
|
9003
|
+
let closing;
|
|
9004
|
+
const dispose = () => {
|
|
9005
|
+
if (disposed) return;
|
|
9006
|
+
disposed = true;
|
|
9007
|
+
clearInterval(timer);
|
|
9008
|
+
clearTimeout(closing);
|
|
9009
|
+
if (transcript && !committed) opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
|
|
9010
|
+
opts.close();
|
|
9011
|
+
};
|
|
9012
|
+
const send = async (body, key = identity) => {
|
|
9013
|
+
const payload = await seal(key ?? await rendezvous, JSON.stringify(body));
|
|
9014
|
+
if (!disposed) opts.send(payload);
|
|
9015
|
+
};
|
|
9016
|
+
const fail = async (reason) => {
|
|
9017
|
+
await send({ v: 2, type: "pair-error", reason });
|
|
9018
|
+
dispose();
|
|
9019
|
+
};
|
|
9020
|
+
const tick = async () => {
|
|
9021
|
+
if (disposed || committed) return;
|
|
9022
|
+
if (Date.now() >= opts.challenge.expiresAt) return fail("expired");
|
|
9023
|
+
if (!opts.registry.hasPairingChallenge(opts.challenge.challengeId)) return fail("cancelled");
|
|
9024
|
+
if (transcript && Date.now() - lastSeen > 3e4) return fail("disconnected");
|
|
9025
|
+
if (!confirmed || !transcript) return;
|
|
9026
|
+
try {
|
|
9027
|
+
const { device, token, relaySecret } = opts.registry.finishVerifiedPairing(opts.challenge.challengeId, transcript.attemptId);
|
|
9028
|
+
committed = true;
|
|
9029
|
+
await send({ v: 2, type: "paired", attemptId: transcript.attemptId, deviceId: device.deviceId, token, relaySecret });
|
|
9030
|
+
clearInterval(timer);
|
|
9031
|
+
closing = setTimeout(dispose, 500);
|
|
9032
|
+
closing.unref?.();
|
|
9033
|
+
} catch (err) {
|
|
9034
|
+
if (err instanceof PairingError && err.reason === "local-confirmation-required") return;
|
|
9035
|
+
await fail("unavailable");
|
|
9036
|
+
}
|
|
9037
|
+
};
|
|
9038
|
+
const processFrame = async (payload) => {
|
|
9039
|
+
if (disposed || committed) return;
|
|
9040
|
+
const text = await open(identity ?? await rendezvous, payload);
|
|
9041
|
+
if (disposed || committed) return;
|
|
9042
|
+
if (text === null) {
|
|
9043
|
+
if (identity) {
|
|
9044
|
+
const competing = await open(await rendezvous, payload);
|
|
9045
|
+
if (competing !== null) {
|
|
9046
|
+
try {
|
|
9047
|
+
const begin = PairingBegin.safeParse(JSON.parse(competing));
|
|
9048
|
+
if (begin.success && begin.data.challengeId === opts.challenge.challengeId) {
|
|
9049
|
+
opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
|
|
9050
|
+
await fail("competing-attempt");
|
|
9051
|
+
}
|
|
9052
|
+
} catch {
|
|
9053
|
+
}
|
|
9054
|
+
}
|
|
9055
|
+
}
|
|
9056
|
+
return;
|
|
9057
|
+
}
|
|
9058
|
+
let raw;
|
|
9059
|
+
try {
|
|
9060
|
+
raw = JSON.parse(text);
|
|
9061
|
+
} catch {
|
|
9062
|
+
return;
|
|
9063
|
+
}
|
|
9064
|
+
if (!transcript) {
|
|
9065
|
+
if (raw?.v === 1) {
|
|
9066
|
+
await send({ v: 1, type: "pair-error", reason: "update-required" });
|
|
9067
|
+
dispose();
|
|
9068
|
+
return;
|
|
9069
|
+
}
|
|
9070
|
+
const parsed2 = PairingBegin.safeParse(raw);
|
|
9071
|
+
if (!parsed2.success || parsed2.data.challengeId !== opts.challenge.challengeId) return;
|
|
9072
|
+
try {
|
|
9073
|
+
transcript = opts.registry.beginVerifiedPairing({
|
|
9074
|
+
v: 2,
|
|
9075
|
+
challengeId: parsed2.data.challengeId,
|
|
9076
|
+
secret: opts.challenge.secret,
|
|
9077
|
+
deviceName: parsed2.data.deviceName,
|
|
9078
|
+
phoneNonce: parsed2.data.phoneNonce
|
|
9079
|
+
}, owner);
|
|
9080
|
+
await send({ v: 2, type: "pair-transcript", transcript, remainingMs: Math.max(1, Math.min(3e5, opts.challenge.expiresAt - Date.now())) }, await rendezvous);
|
|
9081
|
+
identity = deriveVerifiedPairing(opts.challenge.secret, transcript).identity;
|
|
9082
|
+
lastSeen = Date.now();
|
|
9083
|
+
} catch (err) {
|
|
9084
|
+
await fail(err instanceof PairingError && err.reason === "competing-attempt" ? "competing-attempt" : "unavailable");
|
|
9085
|
+
}
|
|
9086
|
+
return;
|
|
9087
|
+
}
|
|
9088
|
+
const parsed = PairingDecision.safeParse(raw);
|
|
9089
|
+
if (!parsed.success || parsed.data.attemptId !== transcript.attemptId) return;
|
|
9090
|
+
lastSeen = Date.now();
|
|
9091
|
+
if (parsed.data.type === "pair-reject") return fail("cancelled");
|
|
9092
|
+
if (parsed.data.type === "pair-confirm") {
|
|
9093
|
+
confirmed = true;
|
|
9094
|
+
await send({ v: 2, type: "pair-waiting", attemptId: transcript.attemptId });
|
|
9095
|
+
await tick();
|
|
9096
|
+
}
|
|
9097
|
+
};
|
|
9098
|
+
const receive = (payload) => {
|
|
9099
|
+
if (disposed || committed) return Promise.resolve();
|
|
9100
|
+
if (Date.now() - windowAt >= 6e4) {
|
|
9101
|
+
windowAt = Date.now();
|
|
9102
|
+
frames = 0;
|
|
9103
|
+
}
|
|
9104
|
+
if (payload.length > PAIRING_FRAME_LIMIT || ++frames > 60 || queued >= 8) {
|
|
9105
|
+
dispose();
|
|
9106
|
+
return Promise.resolve();
|
|
9107
|
+
}
|
|
9108
|
+
queued++;
|
|
9109
|
+
chain = chain.then(() => processFrame(payload)).catch(() => dispose()).finally(() => {
|
|
9110
|
+
queued--;
|
|
9111
|
+
});
|
|
9112
|
+
return chain;
|
|
9113
|
+
};
|
|
9114
|
+
const timer = setInterval(() => {
|
|
9115
|
+
chain = chain.then(tick).catch(() => dispose());
|
|
9116
|
+
}, 250);
|
|
9117
|
+
timer.unref?.();
|
|
9118
|
+
return { receive, dispose, tick };
|
|
9119
|
+
}
|
|
9120
|
+
|
|
8809
9121
|
// ../daemon/src/relay-link.ts
|
|
8810
9122
|
import WebSocket from "ws";
|
|
8811
9123
|
function withRoom(endpoint, roomTag, role) {
|
|
@@ -9170,7 +9482,7 @@ var BriefingBuilder = class {
|
|
|
9170
9482
|
};
|
|
9171
9483
|
|
|
9172
9484
|
// ../daemon/src/delegations.ts
|
|
9173
|
-
import { randomBytes as
|
|
9485
|
+
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
9174
9486
|
var DelegationError = class extends Error {
|
|
9175
9487
|
constructor(reason, message) {
|
|
9176
9488
|
super(message);
|
|
@@ -9180,7 +9492,7 @@ var DelegationError = class extends Error {
|
|
|
9180
9492
|
};
|
|
9181
9493
|
var TARGET_AGENTS = /* @__PURE__ */ new Set(["claude", "codex"]);
|
|
9182
9494
|
var TERMINAL = /* @__PURE__ */ new Set(["returned", "cancelled", "failed"]);
|
|
9183
|
-
var newId3 = () => `del_${
|
|
9495
|
+
var newId3 = () => `del_${randomBytes5(9).toString("base64url")}`;
|
|
9184
9496
|
var DelegationStore = class {
|
|
9185
9497
|
db;
|
|
9186
9498
|
now;
|
|
@@ -10156,6 +10468,10 @@ var LongLeashServer = class {
|
|
|
10156
10468
|
delegations = null;
|
|
10157
10469
|
hookSecret = null;
|
|
10158
10470
|
localPairing = null;
|
|
10471
|
+
pairingChallenges = /* @__PURE__ */ new Map();
|
|
10472
|
+
pairingSockets = /* @__PURE__ */ new Set();
|
|
10473
|
+
pairingRequests = [];
|
|
10474
|
+
pairingCleanups = /* @__PURE__ */ new Set();
|
|
10159
10475
|
staticRoot;
|
|
10160
10476
|
/** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
|
|
10161
10477
|
expectedBuild;
|
|
@@ -10182,6 +10498,7 @@ var LongLeashServer = class {
|
|
|
10182
10498
|
ok: true,
|
|
10183
10499
|
name: "longleash",
|
|
10184
10500
|
protocol: PROTOCOL_VERSION,
|
|
10501
|
+
pairingVersion: 2,
|
|
10185
10502
|
// A version is not machine data. Exposing it turns "connection refused / is this old?"
|
|
10186
10503
|
// from guesswork into a doctor check and prevents testing a daemon that never restarted.
|
|
10187
10504
|
build: this.expectedBuild
|
|
@@ -10257,7 +10574,7 @@ var LongLeashServer = class {
|
|
|
10257
10574
|
});
|
|
10258
10575
|
const localOnly = (request) => {
|
|
10259
10576
|
const presented = request.headers["x-longleash-hook"];
|
|
10260
|
-
return this.hookSecret !== null && typeof presented === "string" && presented
|
|
10577
|
+
return !request.headers.origin && this.hookSecret !== null && typeof presented === "string" && Buffer.byteLength(presented) === Buffer.byteLength(this.hookSecret) && timingSafeEqual2(Buffer.from(presented), Buffer.from(this.hookSecret));
|
|
10261
10578
|
};
|
|
10262
10579
|
this.app.get("/devices", async (request, reply) => {
|
|
10263
10580
|
if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
|
|
@@ -10285,13 +10602,82 @@ var LongLeashServer = class {
|
|
|
10285
10602
|
});
|
|
10286
10603
|
this.app.post("/local/pairing", async (request, reply) => {
|
|
10287
10604
|
if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
|
|
10605
|
+
this.pairingRequests = this.pairingRequests.filter((at) => Date.now() - at < 6e4);
|
|
10606
|
+
if (this.pairingRequests.length >= 10) return reply.code(429).send({ reason: "rate-limited" });
|
|
10607
|
+
this.pairingRequests.push(Date.now());
|
|
10288
10608
|
if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
|
|
10289
10609
|
try {
|
|
10290
|
-
|
|
10610
|
+
const url2 = this.localPairing();
|
|
10611
|
+
const challengeId = new URLSearchParams(new URL(url2).hash.slice(1)).get("c");
|
|
10612
|
+
const challenge = challengeId ? this.pairingChallenges.get(challengeId) : void 0;
|
|
10613
|
+
return { url: url2, version: 2, challengeId, expiresAt: challenge?.expiresAt };
|
|
10291
10614
|
} catch {
|
|
10292
10615
|
return reply.code(503).send({ reason: "pairing-failed" });
|
|
10293
10616
|
}
|
|
10294
10617
|
});
|
|
10618
|
+
const localPairingInput = external_exports.object({
|
|
10619
|
+
challengeId: external_exports.string().min(1).max(128),
|
|
10620
|
+
attemptId: external_exports.string().regex(/^[A-Za-z0-9_-]{43}$/).optional()
|
|
10621
|
+
}).strict();
|
|
10622
|
+
for (const action of ["status", "confirm", "cancel"]) {
|
|
10623
|
+
this.app.post(`/local/pairing/${action}`, async (request, reply) => {
|
|
10624
|
+
if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
|
|
10625
|
+
const input = localPairingInput.safeParse(request.body);
|
|
10626
|
+
if (!input.success) return reply.code(400).send({ reason: "invalid-input" });
|
|
10627
|
+
const { challengeId, attemptId } = input.data;
|
|
10628
|
+
if (action === "cancel") {
|
|
10629
|
+
this.registry.cancelPairingChallenge(challengeId);
|
|
10630
|
+
return { cancelled: true };
|
|
10631
|
+
}
|
|
10632
|
+
const result = this.registry.getPairingResult(challengeId);
|
|
10633
|
+
if (action === "status" && result) return { state: "paired", deviceId: result.deviceId };
|
|
10634
|
+
if (!this.registry.hasPairingChallenge(challengeId)) return reply.code(410).send({ reason: "ended" });
|
|
10635
|
+
if (action === "status") return { verification: this.registry.getPairingVerification(challengeId) };
|
|
10636
|
+
if (!attemptId) return reply.code(400).send({ reason: "invalid-input" });
|
|
10637
|
+
try {
|
|
10638
|
+
this.registry.confirmPairingLocally(challengeId, attemptId);
|
|
10639
|
+
return { confirmed: true };
|
|
10640
|
+
} catch {
|
|
10641
|
+
return reply.code(409).send({ reason: "attempt-ended" });
|
|
10642
|
+
}
|
|
10643
|
+
});
|
|
10644
|
+
}
|
|
10645
|
+
this.app.get("/pair/v2", { websocket: true }, (socket, request) => {
|
|
10646
|
+
const challengeId = request.query.c ?? "";
|
|
10647
|
+
const challenge = this.pairingChallenges.get(challengeId);
|
|
10648
|
+
if (!challenge || !this.registry.hasPairingChallenge(challengeId) || this.pairingSockets.size >= 16) {
|
|
10649
|
+
socket.close(4403, "Pairing unavailable; generate a fresh QR");
|
|
10650
|
+
return;
|
|
10651
|
+
}
|
|
10652
|
+
this.pairingSockets.add(socket);
|
|
10653
|
+
const session = createPairingSession({
|
|
10654
|
+
registry: this.registry,
|
|
10655
|
+
challenge,
|
|
10656
|
+
send: (payload) => {
|
|
10657
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ v: 1, type: "frame", payload }));
|
|
10658
|
+
},
|
|
10659
|
+
close: () => socket.close()
|
|
10660
|
+
});
|
|
10661
|
+
this.pairingCleanups.add(session.dispose);
|
|
10662
|
+
socket.on("message", (raw) => {
|
|
10663
|
+
if (String(raw).length > 9e3) {
|
|
10664
|
+
session.dispose();
|
|
10665
|
+
return;
|
|
10666
|
+
}
|
|
10667
|
+
try {
|
|
10668
|
+
const message = JSON.parse(String(raw));
|
|
10669
|
+
if (message.type === "frame" && typeof message.payload === "string") void session.receive(message.payload);
|
|
10670
|
+
} catch {
|
|
10671
|
+
}
|
|
10672
|
+
});
|
|
10673
|
+
socket.on("close", () => {
|
|
10674
|
+
session.dispose();
|
|
10675
|
+
this.pairingSockets.delete(socket);
|
|
10676
|
+
this.pairingCleanups.delete(session.dispose);
|
|
10677
|
+
});
|
|
10678
|
+
socket.on("error", session.dispose);
|
|
10679
|
+
socket.send(JSON.stringify({ v: 2, type: "pair-ready" }));
|
|
10680
|
+
});
|
|
10295
10681
|
this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
|
|
10296
10682
|
this.app.post("/pair", async (request, reply) => {
|
|
10297
10683
|
const query2 = request.query;
|
|
@@ -10397,7 +10783,7 @@ var LongLeashServer = class {
|
|
|
10397
10783
|
*/
|
|
10398
10784
|
async rebind(host) {
|
|
10399
10785
|
const wanted = this.boundPort;
|
|
10400
|
-
await new Promise((resolve7) => this.app.server.close(() => resolve7()));
|
|
10786
|
+
if (this.app.server.listening) await new Promise((resolve7) => this.app.server.close(() => resolve7()));
|
|
10401
10787
|
const tryListen = (port) => new Promise((resolve7, reject) => {
|
|
10402
10788
|
const onError = (err) => {
|
|
10403
10789
|
this.app.server.removeListener("listening", onListening);
|
|
@@ -10434,6 +10820,21 @@ var LongLeashServer = class {
|
|
|
10434
10820
|
attachLocalPairing(create) {
|
|
10435
10821
|
this.localPairing = create;
|
|
10436
10822
|
}
|
|
10823
|
+
/** Only transient memory; URLs/secrets must never reach logs or the event database. */
|
|
10824
|
+
registerPairingChallenge(challenge) {
|
|
10825
|
+
for (const [id2, previous] of this.pairingChallenges) {
|
|
10826
|
+
if (previous.expiresAt <= Date.now() || !this.registry.hasPairingChallenge(id2)) this.pairingChallenges.delete(id2);
|
|
10827
|
+
}
|
|
10828
|
+
this.pairingChallenges.set(challenge.challengeId, challenge);
|
|
10829
|
+
const cleanup = () => {
|
|
10830
|
+
clearTimeout(timer);
|
|
10831
|
+
this.pairingChallenges.delete(challenge.challengeId);
|
|
10832
|
+
this.pairingCleanups.delete(cleanup);
|
|
10833
|
+
};
|
|
10834
|
+
const timer = setTimeout(cleanup, Math.max(0, challenge.expiresAt - Date.now()));
|
|
10835
|
+
timer.unref?.();
|
|
10836
|
+
this.pairingCleanups.add(cleanup);
|
|
10837
|
+
}
|
|
10437
10838
|
/** Persist an event, then fan it out to every subscriber of that session. */
|
|
10438
10839
|
publish(sessionId, input) {
|
|
10439
10840
|
const event2 = this.eventLog.append(sessionId, input);
|
|
@@ -10454,6 +10855,9 @@ var LongLeashServer = class {
|
|
|
10454
10855
|
return this.connections.size;
|
|
10455
10856
|
}
|
|
10456
10857
|
async close() {
|
|
10858
|
+
for (const cleanup of this.pairingCleanups) cleanup();
|
|
10859
|
+
for (const socket of this.pairingSockets) socket.terminate();
|
|
10860
|
+
this.pairingChallenges.clear();
|
|
10457
10861
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
10458
10862
|
this.heartbeatTimer = null;
|
|
10459
10863
|
this.unsubscribeRevoked?.();
|
|
@@ -11061,7 +11465,7 @@ var LongLeashServer = class {
|
|
|
11061
11465
|
return;
|
|
11062
11466
|
}
|
|
11063
11467
|
if (message.type === "takeOver") {
|
|
11064
|
-
const reservationId = `takeover-${
|
|
11468
|
+
const reservationId = `takeover-${randomBytes6(9).toString("base64url")}`;
|
|
11065
11469
|
const sessions = this.sessions;
|
|
11066
11470
|
void (async () => {
|
|
11067
11471
|
const conflict = this.workspaceConflictMessage(message.sessionId);
|
|
@@ -12599,7 +13003,7 @@ var CodexSessionWatcher = class {
|
|
|
12599
13003
|
this.timer = null;
|
|
12600
13004
|
}
|
|
12601
13005
|
scan(initial = false) {
|
|
12602
|
-
const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
|
|
13006
|
+
const root = this.options.sessionsRoot ?? join6(process.env.CODEX_HOME || join6(homedir3(), ".codex"), "sessions");
|
|
12603
13007
|
const now = this.options.now?.() ?? Date.now();
|
|
12604
13008
|
let observed = 0;
|
|
12605
13009
|
for (const path of jsonlFiles(root)) {
|
|
@@ -12611,7 +13015,7 @@ var CodexSessionWatcher = class {
|
|
|
12611
13015
|
}
|
|
12612
13016
|
const previous = this.mtimes.get(path);
|
|
12613
13017
|
this.mtimes.set(path, modified);
|
|
12614
|
-
const changed = previous
|
|
13018
|
+
const changed = previous === void 0 ? !initial : modified !== previous;
|
|
12615
13019
|
const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
|
|
12616
13020
|
if (!changed && !recentlyActive) continue;
|
|
12617
13021
|
const previousSession = this.known.get(path);
|
|
@@ -12851,14 +13255,20 @@ async function startDaemon(options) {
|
|
|
12851
13255
|
pauseSession: (session, actor, reason) => pauseCurrentSessionOwner(sessions, external, session, actor, reason)
|
|
12852
13256
|
});
|
|
12853
13257
|
server.attachDelegations(delegationManager);
|
|
12854
|
-
const hookSecret =
|
|
13258
|
+
const hookSecret = randomBytes7(24).toString("base64url");
|
|
12855
13259
|
server.attachExternal(external, hookSecret);
|
|
12856
13260
|
const { port } = await server.listen();
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
{
|
|
12861
|
-
|
|
13261
|
+
const endpointPath = join7(dataDir2, "hook-endpoint.json");
|
|
13262
|
+
const publishHookEndpoint = (host, boundPort) => {
|
|
13263
|
+
const staged = `${endpointPath}.${randomBytes7(8).toString("hex")}.tmp`;
|
|
13264
|
+
try {
|
|
13265
|
+
writeFileSync2(staged, JSON.stringify({ url: `http://${host}:${boundPort}/hook`, secret: hookSecret }, null, 2) + "\n", { mode: 384 });
|
|
13266
|
+
renameSync(staged, endpointPath);
|
|
13267
|
+
} finally {
|
|
13268
|
+
rmSync(staged, { force: true });
|
|
13269
|
+
}
|
|
13270
|
+
};
|
|
13271
|
+
publishHookEndpoint(options.host, port);
|
|
12862
13272
|
const stopMaintenance = sessions.startMaintenance();
|
|
12863
13273
|
let bridge = null;
|
|
12864
13274
|
if (options.relayUrl !== void 0) {
|
|
@@ -12868,6 +13278,11 @@ async function startDaemon(options) {
|
|
|
12868
13278
|
}
|
|
12869
13279
|
return {
|
|
12870
13280
|
server,
|
|
13281
|
+
rebindLocal: async (host) => {
|
|
13282
|
+
const reboundPort = await server.rebind(host);
|
|
13283
|
+
publishHookEndpoint(host, reboundPort);
|
|
13284
|
+
return reboundPort;
|
|
13285
|
+
},
|
|
12871
13286
|
sessions,
|
|
12872
13287
|
registry,
|
|
12873
13288
|
eventLog,
|
|
@@ -12927,7 +13342,62 @@ function save(path, config) {
|
|
|
12927
13342
|
}
|
|
12928
13343
|
|
|
12929
13344
|
// ../daemon/src/pairing-host.ts
|
|
13345
|
+
import WebSocket3 from "ws";
|
|
13346
|
+
|
|
13347
|
+
// ../daemon/src/pairing-relay-v2.ts
|
|
12930
13348
|
import WebSocket2 from "ws";
|
|
13349
|
+
function hostVerifiedPairing(opts) {
|
|
13350
|
+
let socket = null;
|
|
13351
|
+
let session = null;
|
|
13352
|
+
let disposed = false;
|
|
13353
|
+
const dispose = () => {
|
|
13354
|
+
if (disposed) return;
|
|
13355
|
+
disposed = true;
|
|
13356
|
+
clearTimeout(deadline);
|
|
13357
|
+
clearInterval(keepalive);
|
|
13358
|
+
session?.dispose();
|
|
13359
|
+
opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
|
|
13360
|
+
socket?.close();
|
|
13361
|
+
};
|
|
13362
|
+
const deadline = setTimeout(dispose, Math.max(0, opts.challenge.expiresAt - Date.now()));
|
|
13363
|
+
deadline.unref?.();
|
|
13364
|
+
const keepalive = setInterval(() => {
|
|
13365
|
+
if (!opts.registry.hasPairingChallenge(opts.challenge.challengeId)) {
|
|
13366
|
+
dispose();
|
|
13367
|
+
return;
|
|
13368
|
+
}
|
|
13369
|
+
if (socket?.readyState === WebSocket2.OPEN) socket.send(JSON.stringify({ v: 1, type: "ping" }));
|
|
13370
|
+
}, 1e4);
|
|
13371
|
+
keepalive.unref?.();
|
|
13372
|
+
void derivePairingIdentity(opts.challenge.secret).then((identity) => {
|
|
13373
|
+
if (disposed) return;
|
|
13374
|
+
const ws = new WebSocket2(withRoom(opts.relayUrl, identity.roomTag, "host"), { maxPayload: 1e4 });
|
|
13375
|
+
socket = ws;
|
|
13376
|
+
session = createPairingSession({
|
|
13377
|
+
registry: opts.registry,
|
|
13378
|
+
challenge: opts.challenge,
|
|
13379
|
+
send: (payload) => {
|
|
13380
|
+
if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify({ v: 1, type: "frame", payload }));
|
|
13381
|
+
},
|
|
13382
|
+
close: dispose
|
|
13383
|
+
});
|
|
13384
|
+
ws.on("open", () => ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" })));
|
|
13385
|
+
ws.on("close", dispose);
|
|
13386
|
+
ws.on("error", dispose);
|
|
13387
|
+
ws.on("message", (raw) => {
|
|
13388
|
+
if (disposed) return;
|
|
13389
|
+
try {
|
|
13390
|
+
const message = JSON.parse(String(raw));
|
|
13391
|
+
if (message.type === "frame" && typeof message.payload === "string") void session?.receive(message.payload);
|
|
13392
|
+
if (message.type === "peer" && message.role === "guest" && message.event === "left") dispose();
|
|
13393
|
+
} catch {
|
|
13394
|
+
}
|
|
13395
|
+
});
|
|
13396
|
+
}).catch(dispose);
|
|
13397
|
+
return dispose;
|
|
13398
|
+
}
|
|
13399
|
+
|
|
13400
|
+
// ../daemon/src/pairing-host.ts
|
|
12931
13401
|
var CompletePairing = external_exports.object({
|
|
12932
13402
|
v: external_exports.literal(1),
|
|
12933
13403
|
type: external_exports.literal("completePairing"),
|
|
@@ -12936,6 +13406,7 @@ var CompletePairing = external_exports.object({
|
|
|
12936
13406
|
deviceName: external_exports.string().min(1).max(64)
|
|
12937
13407
|
});
|
|
12938
13408
|
function hostPairing(opts) {
|
|
13409
|
+
if (JSON.parse(opts.challenge.qrPayload).v === 2) return hostVerifiedPairing(opts);
|
|
12939
13410
|
const log = opts.log ?? (() => {
|
|
12940
13411
|
});
|
|
12941
13412
|
let socket = null;
|
|
@@ -12954,7 +13425,7 @@ function hostPairing(opts) {
|
|
|
12954
13425
|
return;
|
|
12955
13426
|
}
|
|
12956
13427
|
if (disposed) return;
|
|
12957
|
-
const ws = new
|
|
13428
|
+
const ws = new WebSocket3(withRoom(opts.relayUrl, identity.roomTag, "host"));
|
|
12958
13429
|
socket = ws;
|
|
12959
13430
|
ws.on("open", () => {
|
|
12960
13431
|
ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" }));
|
|
@@ -12997,7 +13468,7 @@ function hostPairing(opts) {
|
|
|
12997
13468
|
reason: err instanceof PairingError ? err.reason : "error"
|
|
12998
13469
|
};
|
|
12999
13470
|
}
|
|
13000
|
-
if (ws.readyState ===
|
|
13471
|
+
if (ws.readyState === WebSocket3.OPEN) {
|
|
13001
13472
|
ws.send(JSON.stringify({ v: 1, type: "frame", payload: await seal(identity, JSON.stringify(reply)) }));
|
|
13002
13473
|
}
|
|
13003
13474
|
if (succeeded) setTimeout(dispose, 500);
|
|
@@ -13010,10 +13481,10 @@ function hostPairing(opts) {
|
|
|
13010
13481
|
}
|
|
13011
13482
|
|
|
13012
13483
|
// ../daemon/src/pairing-url.ts
|
|
13013
|
-
function pairingUrl(appOrigin, challengeId, secret) {
|
|
13484
|
+
function pairingUrl(appOrigin, challengeId, secret, version = 1) {
|
|
13014
13485
|
const url2 = new URL(appOrigin);
|
|
13015
13486
|
url2.search = "";
|
|
13016
|
-
url2.hash = new URLSearchParams({ c: challengeId, s: secret }).toString();
|
|
13487
|
+
url2.hash = new URLSearchParams({ c: challengeId, s: secret, ...version === 2 ? { v: "2" } : {} }).toString();
|
|
13017
13488
|
return url2.toString();
|
|
13018
13489
|
}
|
|
13019
13490
|
|
|
@@ -13161,7 +13632,7 @@ function noAddressReason(interfaces = networkInterfaces()) {
|
|
|
13161
13632
|
}
|
|
13162
13633
|
|
|
13163
13634
|
// ../daemon/src/instance-lock.ts
|
|
13164
|
-
import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
13635
|
+
import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
13165
13636
|
import { randomUUID } from "node:crypto";
|
|
13166
13637
|
import { join as join9 } from "node:path";
|
|
13167
13638
|
var LOCK_NAME = "daemon.lock";
|
|
@@ -13195,7 +13666,7 @@ function acquireDaemonInstance(dataDir2) {
|
|
|
13195
13666
|
if (processAlive(owner.pid)) {
|
|
13196
13667
|
throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
|
|
13197
13668
|
}
|
|
13198
|
-
|
|
13669
|
+
rmSync2(path);
|
|
13199
13670
|
create();
|
|
13200
13671
|
}
|
|
13201
13672
|
let held = true;
|
|
@@ -13206,7 +13677,7 @@ function acquireDaemonInstance(dataDir2) {
|
|
|
13206
13677
|
held = false;
|
|
13207
13678
|
try {
|
|
13208
13679
|
const owner = JSON.parse(readFileSync5(path, "utf8"));
|
|
13209
|
-
if (owner.token === token)
|
|
13680
|
+
if (owner.token === token) rmSync2(path);
|
|
13210
13681
|
} catch {
|
|
13211
13682
|
}
|
|
13212
13683
|
}
|
|
@@ -13357,20 +13828,61 @@ function relayAppOrigin(wsUrl) {
|
|
|
13357
13828
|
parsed.pathname = "/";
|
|
13358
13829
|
return parsed.toString();
|
|
13359
13830
|
}
|
|
13360
|
-
|
|
13361
|
-
|
|
13831
|
+
var foregroundChallenge = null;
|
|
13832
|
+
var displayedAttempt = null;
|
|
13833
|
+
var relayPairings = /* @__PURE__ */ new Set();
|
|
13834
|
+
function freshPairingUrl(foreground = false) {
|
|
13835
|
+
if (foreground && foregroundChallenge) daemon.registry.cancelPairingChallenge(foregroundChallenge);
|
|
13836
|
+
const challenge = daemon.registry.createPairingChallenge({ version: 2 });
|
|
13837
|
+
daemon.server.registerPairingChallenge(challenge);
|
|
13838
|
+
if (foreground) {
|
|
13839
|
+
foregroundChallenge = challenge.challengeId;
|
|
13840
|
+
displayedAttempt = null;
|
|
13841
|
+
}
|
|
13362
13842
|
if (relay !== null) {
|
|
13363
|
-
hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge
|
|
13364
|
-
|
|
13843
|
+
const dispose = hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge });
|
|
13844
|
+
relayPairings.add(dispose);
|
|
13845
|
+
setTimeout(() => {
|
|
13846
|
+
dispose();
|
|
13847
|
+
relayPairings.delete(dispose);
|
|
13848
|
+
}, Math.max(0, challenge.expiresAt - Date.now())).unref?.();
|
|
13849
|
+
return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret, 2);
|
|
13365
13850
|
}
|
|
13366
13851
|
return pairingUrl(
|
|
13367
13852
|
`http://${servingHost}:${servingPort}/`,
|
|
13368
13853
|
challenge.challengeId,
|
|
13369
|
-
challenge.secret
|
|
13854
|
+
challenge.secret,
|
|
13855
|
+
2
|
|
13370
13856
|
);
|
|
13371
13857
|
}
|
|
13372
|
-
daemon.server.attachLocalPairing(freshPairingUrl);
|
|
13373
|
-
var
|
|
13858
|
+
daemon.server.attachLocalPairing(() => freshPairingUrl());
|
|
13859
|
+
var interactivePairing = !serviceMode && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
13860
|
+
var url = interactivePairing ? freshPairingUrl(true) : null;
|
|
13861
|
+
var pairingWatch = setInterval(() => {
|
|
13862
|
+
if (!foregroundChallenge) return;
|
|
13863
|
+
const result = daemon.registry.getPairingResult(foregroundChallenge);
|
|
13864
|
+
if (result) {
|
|
13865
|
+
console.log(`Paired ${result.deviceId}. Keep the QR private.`);
|
|
13866
|
+
foregroundChallenge = null;
|
|
13867
|
+
displayedAttempt = null;
|
|
13868
|
+
return;
|
|
13869
|
+
}
|
|
13870
|
+
if (!daemon.registry.hasPairingChallenge(foregroundChallenge)) {
|
|
13871
|
+
console.log("Pairing ended. Press n + Enter for a fresh QR.");
|
|
13872
|
+
foregroundChallenge = null;
|
|
13873
|
+
displayedAttempt = null;
|
|
13874
|
+
return;
|
|
13875
|
+
}
|
|
13876
|
+
const verification = daemon.registry.getPairingVerification(foregroundChallenge);
|
|
13877
|
+
if (verification && verification.attemptId !== displayedAttempt) {
|
|
13878
|
+
displayedAttempt = verification.attemptId;
|
|
13879
|
+
console.log(`
|
|
13880
|
+
Compare with your phone: ${verification.code}
|
|
13881
|
+
Type y + Enter only if every digit matches, or x + Enter to reject.
|
|
13882
|
+
`);
|
|
13883
|
+
}
|
|
13884
|
+
}, 250);
|
|
13885
|
+
pairingWatch.unref?.();
|
|
13374
13886
|
console.log("\n=== LongLeash ===\n");
|
|
13375
13887
|
var warn = vpnWarning();
|
|
13376
13888
|
if (warn) console.log(`!!! ${warn}
|
|
@@ -13414,23 +13926,28 @@ Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
|
|
|
13414
13926
|
console.log(
|
|
13415
13927
|
relay !== null ? `Relay: ${relay.url} \u2014 your phone can reach this laptop from anywhere.` : "Relay: not configured (LAN only). Set LONGLEASH_RELAY_URL once to enable remote access \u2014 it is remembered."
|
|
13416
13928
|
);
|
|
13417
|
-
|
|
13418
|
-
|
|
13419
|
-
|
|
13929
|
+
if (url) {
|
|
13930
|
+
console.log("\nScan this with your phone, then compare both verification codes. Keep this terminal open:\n");
|
|
13931
|
+
console.log(terminalQr(url));
|
|
13932
|
+
console.log(`
|
|
13420
13933
|
${url}
|
|
13421
13934
|
`);
|
|
13422
|
-
|
|
13423
|
-
|
|
13424
|
-
|
|
13935
|
+
if (relay !== null && servedOnLan) {
|
|
13936
|
+
console.log("Use this same QR for the encrypted relay pairing flow.");
|
|
13937
|
+
}
|
|
13938
|
+
} else console.log("Run longleash pair in an interactive terminal to pair. No QR is printed to redirected output.");
|
|
13425
13939
|
}
|
|
13426
13940
|
var NETWORK_WATCH_MS = 5e3;
|
|
13941
|
+
var networkRebindInFlight = false;
|
|
13427
13942
|
var networkWatch = setInterval(() => {
|
|
13943
|
+
if (networkRebindInFlight) return;
|
|
13428
13944
|
const nextBest = findCandidates()[0];
|
|
13429
13945
|
const nextHost = nextBest?.address ?? "127.0.0.1";
|
|
13430
13946
|
if (nextHost === servingHost) return;
|
|
13431
13947
|
const was = servingHost;
|
|
13432
|
-
|
|
13433
|
-
void daemon.
|
|
13948
|
+
networkRebindInFlight = true;
|
|
13949
|
+
void daemon.rebindLocal(nextHost).then((port) => {
|
|
13950
|
+
servingHost = nextHost;
|
|
13434
13951
|
servingPort = port;
|
|
13435
13952
|
console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
|
|
13436
13953
|
Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
|
|
@@ -13441,6 +13958,8 @@ Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
|
|
|
13441
13958
|
console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
|
|
13442
13959
|
Network changed but rebinding failed (${String(err)}).`);
|
|
13443
13960
|
console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
|
|
13961
|
+
}).finally(() => {
|
|
13962
|
+
networkRebindInFlight = false;
|
|
13444
13963
|
});
|
|
13445
13964
|
}, NETWORK_WATCH_MS);
|
|
13446
13965
|
networkWatch.unref?.();
|
|
@@ -13448,20 +13967,38 @@ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter
|
|
|
13448
13967
|
process.stdin.setEncoding("utf8");
|
|
13449
13968
|
process.stdin.on("data", (chunk) => {
|
|
13450
13969
|
const key = chunk.trim().toLowerCase();
|
|
13451
|
-
if (key === "n" &&
|
|
13452
|
-
|
|
13970
|
+
if (key === "n" && interactivePairing) {
|
|
13971
|
+
let next;
|
|
13972
|
+
try {
|
|
13973
|
+
next = freshPairingUrl(true);
|
|
13974
|
+
} catch {
|
|
13975
|
+
console.log("Too many pending QRs. Wait for expiry before trying again.");
|
|
13976
|
+
return;
|
|
13977
|
+
}
|
|
13453
13978
|
console.log("\nScan this with your phone:\n");
|
|
13454
13979
|
console.log(terminalQr(next));
|
|
13455
13980
|
console.log(`
|
|
13456
13981
|
${next}
|
|
13457
13982
|
`);
|
|
13458
13983
|
}
|
|
13984
|
+
if ((key === "y" || key === "x") && foregroundChallenge && displayedAttempt && interactivePairing) {
|
|
13985
|
+
try {
|
|
13986
|
+
if (key === "y") {
|
|
13987
|
+
daemon.registry.confirmPairingLocally(foregroundChallenge, displayedAttempt);
|
|
13988
|
+
console.log("Laptop confirmed. Choose Codes match on your phone to finish.");
|
|
13989
|
+
} else daemon.registry.cancelPairingChallenge(foregroundChallenge);
|
|
13990
|
+
} catch {
|
|
13991
|
+
console.log("That pairing ended. Press n + Enter for a fresh QR.");
|
|
13992
|
+
}
|
|
13993
|
+
}
|
|
13459
13994
|
if (key === "r") {
|
|
13460
13995
|
const active = daemon.registry.listDevices().filter((d) => d.revokedAt === null);
|
|
13461
13996
|
for (const device of active) daemon.registry.revokeDevice(device.deviceId);
|
|
13462
13997
|
console.log(`>>> revoked ${active.length} device(s); their live connections are cut`);
|
|
13463
13998
|
}
|
|
13464
13999
|
if (key === "q") {
|
|
14000
|
+
clearInterval(pairingWatch);
|
|
14001
|
+
for (const dispose of relayPairings) dispose();
|
|
13465
14002
|
clearInterval(networkWatch);
|
|
13466
14003
|
void daemon.stop().then(() => {
|
|
13467
14004
|
instanceLock.release();
|
|
@@ -13470,6 +14007,8 @@ process.stdin.on("data", (chunk) => {
|
|
|
13470
14007
|
}
|
|
13471
14008
|
});
|
|
13472
14009
|
var shutdown = () => {
|
|
14010
|
+
clearInterval(pairingWatch);
|
|
14011
|
+
for (const dispose of relayPairings) dispose();
|
|
13473
14012
|
clearInterval(networkWatch);
|
|
13474
14013
|
void daemon.stop().then(() => {
|
|
13475
14014
|
instanceLock.release();
|