@longleash/cli 0.1.0-rc.10 → 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-YZYGkrmF.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 +828 -73
- package/runtime/app/dist/assets/app-CJy31Egy.js +0 -27
- package/runtime/app/dist/assets/styles-CamwzwbV.css +0 -1
- package/runtime/app/dist/assets/welcome-BOXvwUDw.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"]);
|
|
@@ -5496,6 +5569,8 @@ var sessionStartedPayload = external_exports.object({
|
|
|
5496
5569
|
origin: SessionOrigin.optional(),
|
|
5497
5570
|
/** Who owns the process now; unlike origin, this changes after a safe handoff. */
|
|
5498
5571
|
controller: external_exports.enum(["longleash", "external"]).optional(),
|
|
5572
|
+
/** Where the process is controlled now; origin remains its historical birthplace. */
|
|
5573
|
+
surface: SessionOrigin.optional(),
|
|
5499
5574
|
/** Observation-only means the native surface is visible but has not exposed process control. */
|
|
5500
5575
|
control: external_exports.enum(["full", "observe"]).optional(),
|
|
5501
5576
|
/**
|
|
@@ -5531,6 +5606,8 @@ var sessionStatusPayload = external_exports.object({
|
|
|
5531
5606
|
/** LongLeash's own gate for this session: whether it should page the phone at all. */
|
|
5532
5607
|
gate: SessionGate.optional(),
|
|
5533
5608
|
controller: external_exports.enum(["longleash", "external"]).optional(),
|
|
5609
|
+
/** Current UI/process surface after a handoff. */
|
|
5610
|
+
surface: SessionOrigin.optional(),
|
|
5534
5611
|
/** Observation-only sessions are never presented as remotely stoppable or writable. */
|
|
5535
5612
|
control: external_exports.enum(["full", "observe"]).optional(),
|
|
5536
5613
|
/** Current overrides. An empty object explicitly means provider defaults. */
|
|
@@ -5547,6 +5624,13 @@ var streamDeltaPayload = external_exports.object({
|
|
|
5547
5624
|
kind: external_exports.enum(["text", "tool", "thinking", "user"]),
|
|
5548
5625
|
text: external_exports.string()
|
|
5549
5626
|
}).passthrough();
|
|
5627
|
+
var transcriptResetPayload = external_exports.object({
|
|
5628
|
+
reason: external_exports.enum(["provider-snapshot", "recovery"]),
|
|
5629
|
+
blocks: external_exports.array(external_exports.object({
|
|
5630
|
+
kind: external_exports.enum(["text", "tool", "thinking", "user"]),
|
|
5631
|
+
text: external_exports.string().max(12e4)
|
|
5632
|
+
}).strict()).max(80).optional()
|
|
5633
|
+
}).passthrough();
|
|
5550
5634
|
var QuestionOption = external_exports.object({
|
|
5551
5635
|
label: external_exports.string().min(1),
|
|
5552
5636
|
description: external_exports.string().default(""),
|
|
@@ -5606,6 +5690,7 @@ function event(type, payload) {
|
|
|
5606
5690
|
var SessionEventSchema = external_exports.discriminatedUnion("type", [
|
|
5607
5691
|
event("session.started", sessionStartedPayload),
|
|
5608
5692
|
event("session.status", sessionStatusPayload),
|
|
5693
|
+
event("session.transcript.reset", transcriptResetPayload),
|
|
5609
5694
|
event("stream.delta", streamDeltaPayload),
|
|
5610
5695
|
event("approval.requested", approvalRequestedPayload),
|
|
5611
5696
|
event("approval.decided", approvalDecidedPayload),
|
|
@@ -5709,6 +5794,20 @@ var takeOverMessage = external_exports.object({
|
|
|
5709
5794
|
sessionId: external_exports.string().min(1),
|
|
5710
5795
|
text: external_exports.string().min(1)
|
|
5711
5796
|
}).passthrough();
|
|
5797
|
+
var reclaimSessionMessage = external_exports.object({
|
|
5798
|
+
...clientBase,
|
|
5799
|
+
type: external_exports.literal("reclaimSession"),
|
|
5800
|
+
sessionId: external_exports.string().min(1)
|
|
5801
|
+
}).passthrough();
|
|
5802
|
+
var renameSessionMessage = external_exports.object({
|
|
5803
|
+
...clientBase,
|
|
5804
|
+
type: external_exports.literal("renameSession"),
|
|
5805
|
+
sessionId: external_exports.string().min(1),
|
|
5806
|
+
title: external_exports.string().trim().min(1).max(80).refine(
|
|
5807
|
+
(value) => value.replace(/[\u0000-\u001f\u007f]/g, "").trim().length > 0,
|
|
5808
|
+
"A session name needs visible text."
|
|
5809
|
+
)
|
|
5810
|
+
}).passthrough();
|
|
5712
5811
|
var previewDelegationMessage = external_exports.object({
|
|
5713
5812
|
...clientBase,
|
|
5714
5813
|
type: external_exports.literal("previewDelegation"),
|
|
@@ -5851,6 +5950,8 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
|
|
|
5851
5950
|
pushUnsubscribeMessage,
|
|
5852
5951
|
pushTestMessage,
|
|
5853
5952
|
takeOverMessage,
|
|
5953
|
+
reclaimSessionMessage,
|
|
5954
|
+
renameSessionMessage,
|
|
5854
5955
|
setGateMessage,
|
|
5855
5956
|
previewDelegationMessage,
|
|
5856
5957
|
startDelegationMessage,
|
|
@@ -5884,7 +5985,12 @@ var EventLog = class {
|
|
|
5884
5985
|
type TEXT NOT NULL,
|
|
5885
5986
|
payload TEXT NOT NULL,
|
|
5886
5987
|
PRIMARY KEY (session_id, seq)
|
|
5887
|
-
)
|
|
5988
|
+
);
|
|
5989
|
+
CREATE TABLE IF NOT EXISTS session_aliases (
|
|
5990
|
+
session_id TEXT PRIMARY KEY,
|
|
5991
|
+
title TEXT NOT NULL,
|
|
5992
|
+
updated_at INTEGER NOT NULL
|
|
5993
|
+
);
|
|
5888
5994
|
`);
|
|
5889
5995
|
this.now = opts.now ?? Date.now;
|
|
5890
5996
|
this.insertStmt = this.rawDb.prepare(
|
|
@@ -5956,6 +6062,16 @@ var EventLog = class {
|
|
|
5956
6062
|
latestTimestamp(sessionId) {
|
|
5957
6063
|
return this.latestTsStmt.get(sessionId).ts;
|
|
5958
6064
|
}
|
|
6065
|
+
aliasFor(sessionId) {
|
|
6066
|
+
const row = this.rawDb.prepare("SELECT title FROM session_aliases WHERE session_id = ?").get(sessionId);
|
|
6067
|
+
return row?.title;
|
|
6068
|
+
}
|
|
6069
|
+
setAlias(sessionId, title) {
|
|
6070
|
+
this.rawDb.prepare(
|
|
6071
|
+
`INSERT INTO session_aliases (session_id, title, updated_at) VALUES (?, ?, ?)
|
|
6072
|
+
ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at`
|
|
6073
|
+
).run(sessionId, title, this.now());
|
|
6074
|
+
}
|
|
5959
6075
|
pruneBefore(sessionId, uptoExclusive) {
|
|
5960
6076
|
this.pruneStmt.run(sessionId, uptoExclusive);
|
|
5961
6077
|
}
|
|
@@ -5998,6 +6114,13 @@ var completePairingInput = external_exports.object({
|
|
|
5998
6114
|
deviceName: external_exports.string().min(1).max(64),
|
|
5999
6115
|
publicKey: external_exports.string().min(1).optional()
|
|
6000
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();
|
|
6001
6124
|
var id = (prefix) => `${prefix}_${randomBytes(12).toString("base64url")}`;
|
|
6002
6125
|
var sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
6003
6126
|
var hashesMatch = (a, b) => timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
@@ -6007,6 +6130,7 @@ var DeviceRegistry = class {
|
|
|
6007
6130
|
challengeTtlMs;
|
|
6008
6131
|
// Ephemeral by design: a daemon restart voids pending pairing QR codes.
|
|
6009
6132
|
challenges = /* @__PURE__ */ new Map();
|
|
6133
|
+
pairingResults = /* @__PURE__ */ new Map();
|
|
6010
6134
|
revokedListeners = /* @__PURE__ */ new Set();
|
|
6011
6135
|
pairedListeners = /* @__PURE__ */ new Set();
|
|
6012
6136
|
constructor(path, opts = {}) {
|
|
@@ -6029,17 +6153,22 @@ var DeviceRegistry = class {
|
|
|
6029
6153
|
this.now = opts.now ?? Date.now;
|
|
6030
6154
|
this.challengeTtlMs = opts.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
|
|
6031
6155
|
}
|
|
6032
|
-
createPairingChallenge() {
|
|
6156
|
+
createPairingChallenge(opts = {}) {
|
|
6033
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
|
+
}
|
|
6034
6163
|
const challengeId = id("chl");
|
|
6035
6164
|
const secret = randomBytes(32).toString("base64url");
|
|
6036
6165
|
const expiresAt = this.now() + this.challengeTtlMs;
|
|
6037
|
-
this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt });
|
|
6166
|
+
this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt, version });
|
|
6038
6167
|
return {
|
|
6039
6168
|
challengeId,
|
|
6040
6169
|
secret,
|
|
6041
6170
|
expiresAt,
|
|
6042
|
-
qrPayload: JSON.stringify({ v:
|
|
6171
|
+
qrPayload: JSON.stringify({ v: version, challengeId, secret })
|
|
6043
6172
|
};
|
|
6044
6173
|
}
|
|
6045
6174
|
completePairing(raw) {
|
|
@@ -6048,24 +6177,114 @@ var DeviceRegistry = class {
|
|
|
6048
6177
|
throw new PairingError("invalid-input", parsed.error.message);
|
|
6049
6178
|
}
|
|
6050
6179
|
const input = parsed.data;
|
|
6051
|
-
const challenge = this.
|
|
6052
|
-
if (
|
|
6053
|
-
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");
|
|
6054
6183
|
}
|
|
6055
|
-
|
|
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
|
+
}
|
|
6056
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);
|
|
6057
6270
|
throw new PairingError("expired", "Pairing challenge expired \u2014 generate a new QR code");
|
|
6058
6271
|
}
|
|
6059
|
-
|
|
6272
|
+
return challenge;
|
|
6273
|
+
}
|
|
6274
|
+
authorizeChallenge(challengeId, secret) {
|
|
6275
|
+
const challenge = this.requireChallenge(challengeId);
|
|
6276
|
+
if (!hashesMatch(sha256Hex(secret), challenge.secretHash)) {
|
|
6060
6277
|
throw new PairingError("bad-secret", "Wrong pairing secret");
|
|
6061
6278
|
}
|
|
6062
|
-
|
|
6279
|
+
return challenge;
|
|
6280
|
+
}
|
|
6281
|
+
commitDevice(deviceName, publicKey) {
|
|
6063
6282
|
const token = `llt_${randomBytes(32).toString("base64url")}`;
|
|
6064
6283
|
const relaySecret = randomBytes(32).toString("base64url");
|
|
6065
6284
|
const device = {
|
|
6066
6285
|
deviceId: id("dev"),
|
|
6067
|
-
name:
|
|
6068
|
-
publicKey:
|
|
6286
|
+
name: deviceName,
|
|
6287
|
+
publicKey: publicKey ?? null,
|
|
6069
6288
|
createdAt: this.now(),
|
|
6070
6289
|
lastSeenAt: null,
|
|
6071
6290
|
revokedAt: null
|
|
@@ -6133,12 +6352,15 @@ var DeviceRegistry = class {
|
|
|
6133
6352
|
return rows.map((row) => this.toDevice(row));
|
|
6134
6353
|
}
|
|
6135
6354
|
close() {
|
|
6355
|
+
this.challenges.clear();
|
|
6356
|
+
this.pairingResults.clear();
|
|
6136
6357
|
this.rawDb.close();
|
|
6137
6358
|
}
|
|
6138
6359
|
sweepExpiredChallenges() {
|
|
6139
6360
|
const now = this.now();
|
|
6361
|
+
for (const [id2, result] of this.pairingResults) if (now >= result.expiresAt) this.pairingResults.delete(id2);
|
|
6140
6362
|
for (const [challengeId, challenge] of this.challenges) {
|
|
6141
|
-
if (now
|
|
6363
|
+
if (now >= challenge.expiresAt) this.challenges.delete(challengeId);
|
|
6142
6364
|
}
|
|
6143
6365
|
}
|
|
6144
6366
|
toDevice(row) {
|
|
@@ -7109,6 +7331,26 @@ var SessionManager = class {
|
|
|
7109
7331
|
};
|
|
7110
7332
|
});
|
|
7111
7333
|
}
|
|
7334
|
+
/** Find the stable LongLeash card that already owns this provider conversation id. */
|
|
7335
|
+
sessionIdForAgentSession(agentSessionId) {
|
|
7336
|
+
const row = this.approvals.rawDb.prepare("SELECT session_id FROM sessions WHERE agent_session_id = ? ORDER BY started_at DESC LIMIT 1").get(agentSessionId);
|
|
7337
|
+
return row?.session_id;
|
|
7338
|
+
}
|
|
7339
|
+
/** Persist a human label independently of whatever title a provider derives later. */
|
|
7340
|
+
renameSession(sessionId, title, emit = true) {
|
|
7341
|
+
const changed = this.approvals.rawDb.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId).changes;
|
|
7342
|
+
if (changed === 0) return false;
|
|
7343
|
+
const live = this.sessions.get(sessionId);
|
|
7344
|
+
if (live !== void 0) live.title = title;
|
|
7345
|
+
this.eventLog.setAlias(sessionId, title);
|
|
7346
|
+
if (emit) {
|
|
7347
|
+
this.emit(sessionId, {
|
|
7348
|
+
type: "session.status",
|
|
7349
|
+
payload: { status: live?.status ?? "waiting", live: live !== void 0, title }
|
|
7350
|
+
});
|
|
7351
|
+
}
|
|
7352
|
+
return true;
|
|
7353
|
+
}
|
|
7112
7354
|
/**
|
|
7113
7355
|
* Adopt a finished TERMINAL session so a phone reply can wake it: the
|
|
7114
7356
|
* conversation lives in Claude Code's own storage, keyed by its resume id,
|
|
@@ -7821,7 +8063,7 @@ import { join as join2 } from "node:path";
|
|
|
7821
8063
|
import Fastify from "fastify";
|
|
7822
8064
|
import fastifyStatic from "@fastify/static";
|
|
7823
8065
|
import websocket from "@fastify/websocket";
|
|
7824
|
-
import { randomBytes as
|
|
8066
|
+
import { randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
7825
8067
|
|
|
7826
8068
|
// ../daemon/src/external.ts
|
|
7827
8069
|
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
@@ -7880,6 +8122,7 @@ var ExternalSessions = class {
|
|
|
7880
8122
|
stopTimeoutMs;
|
|
7881
8123
|
onEnded;
|
|
7882
8124
|
workspace;
|
|
8125
|
+
resolveSessionId;
|
|
7883
8126
|
unsubscribeWorkspace;
|
|
7884
8127
|
constructor(opts) {
|
|
7885
8128
|
this.eventLog = opts.eventLog;
|
|
@@ -7899,6 +8142,7 @@ var ExternalSessions = class {
|
|
|
7899
8142
|
this.waitForExit = opts.waitForExit ?? ((pid, timeoutMs) => this.pollForExit(pid, timeoutMs));
|
|
7900
8143
|
this.onEnded = opts.onEnded;
|
|
7901
8144
|
this.workspace = opts.workspace;
|
|
8145
|
+
this.resolveSessionId = opts.resolveSessionId;
|
|
7902
8146
|
this.unsubscribeWorkspace = this.workspace?.subscribe(() => this.reconcileWorkspace());
|
|
7903
8147
|
for (const orphan of this.approvals.closeOrphans(
|
|
7904
8148
|
"The daemon restarted; this was answered in the terminal."
|
|
@@ -7922,6 +8166,38 @@ var ExternalSessions = class {
|
|
|
7922
8166
|
hasLiveSession(sessionId) {
|
|
7923
8167
|
return [...this.sessions.values()].some((session) => session.sessionId === sessionId);
|
|
7924
8168
|
}
|
|
8169
|
+
renameSession(sessionId, title) {
|
|
8170
|
+
const entry = [...this.sessions.entries()].find(([, session2]) => session2.sessionId === sessionId);
|
|
8171
|
+
if (entry === void 0) return false;
|
|
8172
|
+
const [agentSessionId, session] = entry;
|
|
8173
|
+
session.title = title;
|
|
8174
|
+
session.named = true;
|
|
8175
|
+
this.eventLog.setAlias(sessionId, title);
|
|
8176
|
+
this.persist(agentSessionId, session);
|
|
8177
|
+
this.emit(sessionId, {
|
|
8178
|
+
type: "session.status",
|
|
8179
|
+
payload: {
|
|
8180
|
+
status: session.status,
|
|
8181
|
+
live: true,
|
|
8182
|
+
title,
|
|
8183
|
+
controller: "external",
|
|
8184
|
+
control: session.observedOnly ? "observe" : "full",
|
|
8185
|
+
surface: session.surface
|
|
8186
|
+
}
|
|
8187
|
+
});
|
|
8188
|
+
return true;
|
|
8189
|
+
}
|
|
8190
|
+
/**
|
|
8191
|
+
* End a native writer so the same conversation can be parked on the phone.
|
|
8192
|
+
* Observation-only sessions have no authenticated PID and are never remotely terminated.
|
|
8193
|
+
*/
|
|
8194
|
+
async reclaimToPhone(externalSessionId, decidedBy) {
|
|
8195
|
+
const session = [...this.sessions.values()].find((item) => item.sessionId === externalSessionId);
|
|
8196
|
+
if (session === void 0) return "not-found";
|
|
8197
|
+
if (session.observedOnly) return "observe-only";
|
|
8198
|
+
if (session.pid === null) return "unverified";
|
|
8199
|
+
return await this.stop(externalSessionId, decidedBy) ? "reclaimed" : "unverified";
|
|
8200
|
+
}
|
|
7925
8201
|
/**
|
|
7926
8202
|
* Take back the sessions a previous run was watching.
|
|
7927
8203
|
*
|
|
@@ -7950,7 +8226,7 @@ var ExternalSessions = class {
|
|
|
7950
8226
|
transcriptPath: row.transcriptPath,
|
|
7951
8227
|
status: "running",
|
|
7952
8228
|
startedAt: row.startedAt,
|
|
7953
|
-
title: row.title,
|
|
8229
|
+
title: this.eventLog.aliasFor(row.sessionId) ?? row.title,
|
|
7954
8230
|
pid: row.pid,
|
|
7955
8231
|
named: true,
|
|
7956
8232
|
permissionMode: null,
|
|
@@ -7968,7 +8244,13 @@ var ExternalSessions = class {
|
|
|
7968
8244
|
session.timer.unref?.();
|
|
7969
8245
|
this.emit(session.sessionId, {
|
|
7970
8246
|
type: "session.status",
|
|
7971
|
-
payload: {
|
|
8247
|
+
payload: {
|
|
8248
|
+
status: "running",
|
|
8249
|
+
live: true,
|
|
8250
|
+
controller: "external",
|
|
8251
|
+
control: "full",
|
|
8252
|
+
surface: session.surface
|
|
8253
|
+
}
|
|
7972
8254
|
});
|
|
7973
8255
|
}
|
|
7974
8256
|
}
|
|
@@ -7984,22 +8266,35 @@ var ExternalSessions = class {
|
|
|
7984
8266
|
/** Adopt a Codex transcript observed after startup, including sessions older than the hooks. */
|
|
7985
8267
|
observeCodexSession(info) {
|
|
7986
8268
|
const session = this.ensure(info.sessionId, info.cwd, info.transcriptPath, "codex", info.surface, true);
|
|
7987
|
-
|
|
8269
|
+
const alias = this.eventLog.aliasFor(session.sessionId);
|
|
8270
|
+
const nextTitle = alias ?? info.title;
|
|
8271
|
+
if (nextTitle !== void 0 && nextTitle !== session.title) {
|
|
7988
8272
|
session.named = true;
|
|
7989
|
-
session.title =
|
|
8273
|
+
session.title = nextTitle;
|
|
7990
8274
|
this.persist(info.sessionId, session);
|
|
7991
8275
|
}
|
|
8276
|
+
if (session.observedOnly && session.observedFingerprint === void 0) {
|
|
8277
|
+
session.observedFingerprint = JSON.stringify(info.snapshot);
|
|
8278
|
+
this.emit(session.sessionId, {
|
|
8279
|
+
type: "session.transcript.reset",
|
|
8280
|
+
payload: { reason: "provider-snapshot", blocks: info.snapshot }
|
|
8281
|
+
});
|
|
8282
|
+
}
|
|
7992
8283
|
this.emit(session.sessionId, {
|
|
7993
8284
|
type: "session.status",
|
|
7994
8285
|
payload: {
|
|
7995
8286
|
status: session.status,
|
|
7996
8287
|
live: true,
|
|
7997
8288
|
controller: "external",
|
|
7998
|
-
control: "observe",
|
|
7999
|
-
...
|
|
8289
|
+
control: session.observedOnly ? "observe" : "full",
|
|
8290
|
+
...nextTitle === void 0 ? {} : { title: nextTitle }
|
|
8000
8291
|
}
|
|
8001
8292
|
});
|
|
8002
|
-
|
|
8293
|
+
if (session.observedOnly) {
|
|
8294
|
+
if (session.workspaceConflict !== void 0) delete session.workspaceConflict;
|
|
8295
|
+
} else {
|
|
8296
|
+
this.claimWorkspace(session);
|
|
8297
|
+
}
|
|
8003
8298
|
}
|
|
8004
8299
|
/**
|
|
8005
8300
|
* Stop a terminal session from the phone — for real. The recorded pid is only
|
|
@@ -8415,14 +8710,26 @@ var ExternalSessions = class {
|
|
|
8415
8710
|
this.persist(claudeSessionId, existing);
|
|
8416
8711
|
this.emit(existing.sessionId, {
|
|
8417
8712
|
type: "session.status",
|
|
8418
|
-
payload: {
|
|
8713
|
+
payload: {
|
|
8714
|
+
status: existing.status,
|
|
8715
|
+
live: true,
|
|
8716
|
+
controller: "external",
|
|
8717
|
+
control: "full",
|
|
8718
|
+
surface: existing.surface
|
|
8719
|
+
}
|
|
8419
8720
|
});
|
|
8721
|
+
if (existing.timer === null) {
|
|
8722
|
+
existing.timer = setInterval(() => this.drain(existing), this.pollMs);
|
|
8723
|
+
existing.timer.unref?.();
|
|
8724
|
+
this.drain(existing);
|
|
8725
|
+
}
|
|
8420
8726
|
}
|
|
8421
8727
|
return existing;
|
|
8422
8728
|
}
|
|
8423
|
-
const sessionId = `ext_${claudeSessionId}`;
|
|
8729
|
+
const sessionId = this.resolveSessionId?.(claudeSessionId) ?? `ext_${claudeSessionId}`;
|
|
8424
8730
|
const replay = this.eventLog.replay(sessionId, 0);
|
|
8425
8731
|
const hasHistory = replay.gap === false ? replay.events.length > 0 : true;
|
|
8732
|
+
const alias = this.eventLog.aliasFor(sessionId);
|
|
8426
8733
|
const session = {
|
|
8427
8734
|
sessionId,
|
|
8428
8735
|
agent,
|
|
@@ -8431,9 +8738,9 @@ var ExternalSessions = class {
|
|
|
8431
8738
|
transcriptPath,
|
|
8432
8739
|
status: "running",
|
|
8433
8740
|
startedAt: this.now(),
|
|
8434
|
-
title: `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
|
|
8741
|
+
title: alias ?? `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
|
|
8435
8742
|
pid: null,
|
|
8436
|
-
named:
|
|
8743
|
+
named: alias !== void 0,
|
|
8437
8744
|
permissionMode: null,
|
|
8438
8745
|
gate: "ask",
|
|
8439
8746
|
offset: (tailOnly || hasHistory) && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
|
|
@@ -8454,6 +8761,7 @@ var ExternalSessions = class {
|
|
|
8454
8761
|
origin: surface,
|
|
8455
8762
|
controller: "external",
|
|
8456
8763
|
control: tailOnly ? "observe" : "full",
|
|
8764
|
+
surface,
|
|
8457
8765
|
resumeId: claudeSessionId
|
|
8458
8766
|
}
|
|
8459
8767
|
});
|
|
@@ -8464,7 +8772,8 @@ var ExternalSessions = class {
|
|
|
8464
8772
|
status: "running",
|
|
8465
8773
|
live: true,
|
|
8466
8774
|
controller: "external",
|
|
8467
|
-
control: tailOnly ? "observe" : "full"
|
|
8775
|
+
control: tailOnly ? "observe" : "full",
|
|
8776
|
+
surface
|
|
8468
8777
|
}
|
|
8469
8778
|
});
|
|
8470
8779
|
session.timer = setInterval(() => this.drain(session), this.pollMs);
|
|
@@ -8474,7 +8783,9 @@ var ExternalSessions = class {
|
|
|
8474
8783
|
}
|
|
8475
8784
|
/** Retry claims after startup reconciliation released dead managed-session ownership. */
|
|
8476
8785
|
reconcileWorkspace() {
|
|
8477
|
-
for (const session of this.sessions.values())
|
|
8786
|
+
for (const session of this.sessions.values()) {
|
|
8787
|
+
if (!session.observedOnly) this.claimWorkspace(session);
|
|
8788
|
+
}
|
|
8478
8789
|
}
|
|
8479
8790
|
claimWorkspace(session) {
|
|
8480
8791
|
if (this.workspace === void 0 || session.cwd.trim() === "") return;
|
|
@@ -8674,6 +8985,139 @@ function transcriptDeltas(line) {
|
|
|
8674
8985
|
return out;
|
|
8675
8986
|
}
|
|
8676
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
|
+
|
|
8677
9121
|
// ../daemon/src/relay-link.ts
|
|
8678
9122
|
import WebSocket from "ws";
|
|
8679
9123
|
function withRoom(endpoint, roomTag, role) {
|
|
@@ -9038,7 +9482,7 @@ var BriefingBuilder = class {
|
|
|
9038
9482
|
};
|
|
9039
9483
|
|
|
9040
9484
|
// ../daemon/src/delegations.ts
|
|
9041
|
-
import { randomBytes as
|
|
9485
|
+
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
9042
9486
|
var DelegationError = class extends Error {
|
|
9043
9487
|
constructor(reason, message) {
|
|
9044
9488
|
super(message);
|
|
@@ -9048,7 +9492,7 @@ var DelegationError = class extends Error {
|
|
|
9048
9492
|
};
|
|
9049
9493
|
var TARGET_AGENTS = /* @__PURE__ */ new Set(["claude", "codex"]);
|
|
9050
9494
|
var TERMINAL = /* @__PURE__ */ new Set(["returned", "cancelled", "failed"]);
|
|
9051
|
-
var newId3 = () => `del_${
|
|
9495
|
+
var newId3 = () => `del_${randomBytes5(9).toString("base64url")}`;
|
|
9052
9496
|
var DelegationStore = class {
|
|
9053
9497
|
db;
|
|
9054
9498
|
now;
|
|
@@ -10024,6 +10468,10 @@ var LongLeashServer = class {
|
|
|
10024
10468
|
delegations = null;
|
|
10025
10469
|
hookSecret = null;
|
|
10026
10470
|
localPairing = null;
|
|
10471
|
+
pairingChallenges = /* @__PURE__ */ new Map();
|
|
10472
|
+
pairingSockets = /* @__PURE__ */ new Set();
|
|
10473
|
+
pairingRequests = [];
|
|
10474
|
+
pairingCleanups = /* @__PURE__ */ new Set();
|
|
10027
10475
|
staticRoot;
|
|
10028
10476
|
/** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
|
|
10029
10477
|
expectedBuild;
|
|
@@ -10050,6 +10498,7 @@ var LongLeashServer = class {
|
|
|
10050
10498
|
ok: true,
|
|
10051
10499
|
name: "longleash",
|
|
10052
10500
|
protocol: PROTOCOL_VERSION,
|
|
10501
|
+
pairingVersion: 2,
|
|
10053
10502
|
// A version is not machine data. Exposing it turns "connection refused / is this old?"
|
|
10054
10503
|
// from guesswork into a doctor check and prevents testing a daemon that never restarted.
|
|
10055
10504
|
build: this.expectedBuild
|
|
@@ -10125,7 +10574,7 @@ var LongLeashServer = class {
|
|
|
10125
10574
|
});
|
|
10126
10575
|
const localOnly = (request) => {
|
|
10127
10576
|
const presented = request.headers["x-longleash-hook"];
|
|
10128
|
-
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));
|
|
10129
10578
|
};
|
|
10130
10579
|
this.app.get("/devices", async (request, reply) => {
|
|
10131
10580
|
if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
|
|
@@ -10153,13 +10602,82 @@ var LongLeashServer = class {
|
|
|
10153
10602
|
});
|
|
10154
10603
|
this.app.post("/local/pairing", async (request, reply) => {
|
|
10155
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());
|
|
10156
10608
|
if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
|
|
10157
10609
|
try {
|
|
10158
|
-
|
|
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 };
|
|
10159
10614
|
} catch {
|
|
10160
10615
|
return reply.code(503).send({ reason: "pairing-failed" });
|
|
10161
10616
|
}
|
|
10162
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
|
+
});
|
|
10163
10681
|
this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
|
|
10164
10682
|
this.app.post("/pair", async (request, reply) => {
|
|
10165
10683
|
const query2 = request.query;
|
|
@@ -10265,7 +10783,7 @@ var LongLeashServer = class {
|
|
|
10265
10783
|
*/
|
|
10266
10784
|
async rebind(host) {
|
|
10267
10785
|
const wanted = this.boundPort;
|
|
10268
|
-
await new Promise((resolve7) => this.app.server.close(() => resolve7()));
|
|
10786
|
+
if (this.app.server.listening) await new Promise((resolve7) => this.app.server.close(() => resolve7()));
|
|
10269
10787
|
const tryListen = (port) => new Promise((resolve7, reject) => {
|
|
10270
10788
|
const onError = (err) => {
|
|
10271
10789
|
this.app.server.removeListener("listening", onListening);
|
|
@@ -10302,6 +10820,21 @@ var LongLeashServer = class {
|
|
|
10302
10820
|
attachLocalPairing(create) {
|
|
10303
10821
|
this.localPairing = create;
|
|
10304
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
|
+
}
|
|
10305
10838
|
/** Persist an event, then fan it out to every subscriber of that session. */
|
|
10306
10839
|
publish(sessionId, input) {
|
|
10307
10840
|
const event2 = this.eventLog.append(sessionId, input);
|
|
@@ -10322,6 +10855,9 @@ var LongLeashServer = class {
|
|
|
10322
10855
|
return this.connections.size;
|
|
10323
10856
|
}
|
|
10324
10857
|
async close() {
|
|
10858
|
+
for (const cleanup of this.pairingCleanups) cleanup();
|
|
10859
|
+
for (const socket of this.pairingSockets) socket.terminate();
|
|
10860
|
+
this.pairingChallenges.clear();
|
|
10325
10861
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
10326
10862
|
this.heartbeatTimer = null;
|
|
10327
10863
|
this.unsubscribeRevoked?.();
|
|
@@ -10412,6 +10948,9 @@ var LongLeashServer = class {
|
|
|
10412
10948
|
}
|
|
10413
10949
|
/** Tell the client what it may do, so it never has to guess a project path. */
|
|
10414
10950
|
sendHello(connection) {
|
|
10951
|
+
const bySession = new Map(
|
|
10952
|
+
[...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => [session.sessionId, session])
|
|
10953
|
+
);
|
|
10415
10954
|
this.sendTo(connection, {
|
|
10416
10955
|
v: PROTOCOL_VERSION,
|
|
10417
10956
|
type: "hello",
|
|
@@ -10419,8 +10958,9 @@ var LongLeashServer = class {
|
|
|
10419
10958
|
roots: this.sessions?.listAllowedRoots() ?? [],
|
|
10420
10959
|
// Everything the daemon knows about, so a reloaded phone rebuilds instead of showing
|
|
10421
10960
|
// nothing — including sessions the human started in a terminal.
|
|
10422
|
-
sessions: [...
|
|
10961
|
+
sessions: [...bySession.values()].map((session) => ({
|
|
10423
10962
|
...session,
|
|
10963
|
+
title: this.eventLog.aliasFor(session.sessionId) ?? session.title,
|
|
10424
10964
|
lastActivityAt: this.eventLog.latestTimestamp(session.sessionId) || session.startedAt
|
|
10425
10965
|
})).sort((left, right) => left.startedAt - right.startedAt || left.sessionId.localeCompare(right.sessionId)),
|
|
10426
10966
|
capabilities: {
|
|
@@ -10794,8 +11334,60 @@ var LongLeashServer = class {
|
|
|
10794
11334
|
});
|
|
10795
11335
|
return;
|
|
10796
11336
|
}
|
|
11337
|
+
if (message.type === "renameSession") {
|
|
11338
|
+
const title = message.title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
11339
|
+
const externalRenamed = this.external?.renameSession(message.sessionId, title) === true;
|
|
11340
|
+
const managedRenamed = this.sessions.renameSession(message.sessionId, title, !externalRenamed);
|
|
11341
|
+
const renamed = externalRenamed || managedRenamed;
|
|
11342
|
+
this.log(`rename ${message.sessionId} -> ${renamed ? "renamed" : "unknown"}`);
|
|
11343
|
+
this.sendTo(connection, {
|
|
11344
|
+
v: PROTOCOL_VERSION,
|
|
11345
|
+
type: "ack",
|
|
11346
|
+
of: "renameSession",
|
|
11347
|
+
sessionId: message.sessionId,
|
|
11348
|
+
outcome: renamed ? "renamed" : "unknown-session"
|
|
11349
|
+
});
|
|
11350
|
+
if (!renamed) {
|
|
11351
|
+
this.sendTo(connection, {
|
|
11352
|
+
v: PROTOCOL_VERSION,
|
|
11353
|
+
type: "error",
|
|
11354
|
+
code: "unknown-session",
|
|
11355
|
+
message: "This conversation is no longer known to the laptop, so its name was not changed."
|
|
11356
|
+
});
|
|
11357
|
+
}
|
|
11358
|
+
return;
|
|
11359
|
+
}
|
|
11360
|
+
if (message.type === "reclaimSession") {
|
|
11361
|
+
const sessionManager = this.sessions;
|
|
11362
|
+
void (async () => {
|
|
11363
|
+
const outcome = await this.external?.reclaimToPhone(message.sessionId, connection.deviceId) ?? "not-found";
|
|
11364
|
+
let reopened = false;
|
|
11365
|
+
if (outcome === "reclaimed") {
|
|
11366
|
+
reopened = await sessionManager.resumeSession(message.sessionId, connection.deviceId);
|
|
11367
|
+
}
|
|
11368
|
+
const success = outcome === "reclaimed" && reopened;
|
|
11369
|
+
this.log(`reclaim ${message.sessionId} -> ${success ? "phone-ready" : outcome}`);
|
|
11370
|
+
this.sendTo(connection, {
|
|
11371
|
+
v: PROTOCOL_VERSION,
|
|
11372
|
+
type: "ack",
|
|
11373
|
+
of: "reclaimSession",
|
|
11374
|
+
sessionId: message.sessionId,
|
|
11375
|
+
outcome: success ? "phone-ready" : outcome
|
|
11376
|
+
});
|
|
11377
|
+
if (!success) {
|
|
11378
|
+
const detail = outcome === "observe-only" ? "This VS Code conversation is visible, but its lifecycle hook has not provided a verified process handle. Stop it in VS Code; LongLeash will make the same conversation reopenable when SessionEnd arrives." : outcome === "unverified" ? "LongLeash could not verify that the Terminal or VS Code writer exited, so it kept control there. Nothing was started on the phone." : outcome === "reclaimed" ? "The native writer ended, but this provider did not expose a recoverable conversation point. Nothing new was started." : "This native session is no longer connected to the laptop daemon.";
|
|
11379
|
+
this.sendTo(connection, {
|
|
11380
|
+
v: PROTOCOL_VERSION,
|
|
11381
|
+
type: "error",
|
|
11382
|
+
code: "cannot-reclaim",
|
|
11383
|
+
message: detail
|
|
11384
|
+
});
|
|
11385
|
+
}
|
|
11386
|
+
})();
|
|
11387
|
+
return;
|
|
11388
|
+
}
|
|
10797
11389
|
if (message.type === "setGate") {
|
|
10798
|
-
const applied = this.external !== null && message.sessionId
|
|
11390
|
+
const applied = this.external !== null && this.external.hasLiveSession(message.sessionId) ? this.external.setGate(message.sessionId, message.gate) : false;
|
|
10799
11391
|
this.log(`gate ${message.sessionId} -> ${message.gate}${applied ? "" : " (unknown session)"}`);
|
|
10800
11392
|
this.sendTo(connection, {
|
|
10801
11393
|
v: PROTOCOL_VERSION,
|
|
@@ -10873,7 +11465,7 @@ var LongLeashServer = class {
|
|
|
10873
11465
|
return;
|
|
10874
11466
|
}
|
|
10875
11467
|
if (message.type === "takeOver") {
|
|
10876
|
-
const reservationId = `takeover-${
|
|
11468
|
+
const reservationId = `takeover-${randomBytes6(9).toString("base64url")}`;
|
|
10877
11469
|
const sessions = this.sessions;
|
|
10878
11470
|
void (async () => {
|
|
10879
11471
|
const conflict = this.workspaceConflictMessage(message.sessionId);
|
|
@@ -10933,7 +11525,7 @@ var LongLeashServer = class {
|
|
|
10933
11525
|
return;
|
|
10934
11526
|
}
|
|
10935
11527
|
if (message.type === "stopSession") {
|
|
10936
|
-
if (message.sessionId
|
|
11528
|
+
if (this.external?.hasLiveSession(message.sessionId) === true) {
|
|
10937
11529
|
const sessions = this.sessions;
|
|
10938
11530
|
void this.external.stop(message.sessionId, connection.deviceId).then((stopped) => {
|
|
10939
11531
|
if (stopped) {
|
|
@@ -12325,8 +12917,11 @@ function inside(root, candidate) {
|
|
|
12325
12917
|
}
|
|
12326
12918
|
function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
|
|
12327
12919
|
let size;
|
|
12920
|
+
let activityAt;
|
|
12328
12921
|
try {
|
|
12329
|
-
|
|
12922
|
+
const stats = statSync2(path);
|
|
12923
|
+
size = stats.size;
|
|
12924
|
+
activityAt = stats.mtimeMs;
|
|
12330
12925
|
} catch {
|
|
12331
12926
|
return null;
|
|
12332
12927
|
}
|
|
@@ -12345,6 +12940,7 @@ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
|
|
|
12345
12940
|
const lines = tailText.split("\n");
|
|
12346
12941
|
if (tailOffset > 0) lines.shift();
|
|
12347
12942
|
let title;
|
|
12943
|
+
const snapshot = [];
|
|
12348
12944
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
12349
12945
|
const line = lines[index];
|
|
12350
12946
|
if (!line?.includes('"role":"user"')) continue;
|
|
@@ -12359,8 +12955,31 @@ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
|
|
|
12359
12955
|
} catch {
|
|
12360
12956
|
}
|
|
12361
12957
|
}
|
|
12958
|
+
for (const line of lines) {
|
|
12959
|
+
if (line.length === 0 || line.length > 1e6) continue;
|
|
12960
|
+
try {
|
|
12961
|
+
for (const delta of transcriptDeltas(JSON.parse(line))) {
|
|
12962
|
+
if (delta.type !== "stream.delta" || delta.payload.text.trim() === "") continue;
|
|
12963
|
+
snapshot.push({ kind: delta.payload.kind, text: delta.payload.text });
|
|
12964
|
+
}
|
|
12965
|
+
} catch {
|
|
12966
|
+
}
|
|
12967
|
+
}
|
|
12968
|
+
let retained = snapshot.slice(-80);
|
|
12969
|
+
let characters = retained.reduce((sum, block) => sum + block.text.length, 0);
|
|
12970
|
+
while (retained.length > 1 && characters > 12e4) {
|
|
12971
|
+
characters -= retained.shift()?.text.length ?? 0;
|
|
12972
|
+
}
|
|
12362
12973
|
if (title === void 0) title = titleFrom(latestUserText(tailText) ?? "") ?? void 0;
|
|
12363
|
-
return {
|
|
12974
|
+
return {
|
|
12975
|
+
sessionId,
|
|
12976
|
+
cwd,
|
|
12977
|
+
transcriptPath: path,
|
|
12978
|
+
surface,
|
|
12979
|
+
activityAt,
|
|
12980
|
+
snapshot: retained,
|
|
12981
|
+
...title === void 0 ? {} : { title }
|
|
12982
|
+
};
|
|
12364
12983
|
} catch {
|
|
12365
12984
|
return null;
|
|
12366
12985
|
}
|
|
@@ -12384,7 +13003,7 @@ var CodexSessionWatcher = class {
|
|
|
12384
13003
|
this.timer = null;
|
|
12385
13004
|
}
|
|
12386
13005
|
scan(initial = false) {
|
|
12387
|
-
const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
|
|
13006
|
+
const root = this.options.sessionsRoot ?? join6(process.env.CODEX_HOME || join6(homedir3(), ".codex"), "sessions");
|
|
12388
13007
|
const now = this.options.now?.() ?? Date.now();
|
|
12389
13008
|
let observed = 0;
|
|
12390
13009
|
for (const path of jsonlFiles(root)) {
|
|
@@ -12396,7 +13015,7 @@ var CodexSessionWatcher = class {
|
|
|
12396
13015
|
}
|
|
12397
13016
|
const previous = this.mtimes.get(path);
|
|
12398
13017
|
this.mtimes.set(path, modified);
|
|
12399
|
-
const changed = previous
|
|
13018
|
+
const changed = previous === void 0 ? !initial : modified !== previous;
|
|
12400
13019
|
const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
|
|
12401
13020
|
if (!changed && !recentlyActive) continue;
|
|
12402
13021
|
const previousSession = this.known.get(path);
|
|
@@ -12593,6 +13212,7 @@ async function startDaemon(options) {
|
|
|
12593
13212
|
registry: sessionRegistry,
|
|
12594
13213
|
onEvent: mirror,
|
|
12595
13214
|
workspace,
|
|
13215
|
+
resolveSessionId: (agentSessionId) => sessions.sessionIdForAgentSession(agentSessionId),
|
|
12596
13216
|
// A live socket means the app is open and can answer in seconds. A push REGISTRATION is
|
|
12597
13217
|
// permanent and proves nothing about whether anyone is looking — treating it as presence
|
|
12598
13218
|
// is what froze the keyboard for two minutes with the phone in a drawer.
|
|
@@ -12617,7 +13237,7 @@ async function startDaemon(options) {
|
|
|
12617
13237
|
external.startSweeping();
|
|
12618
13238
|
const delegationStore = new DelegationStore(approvals.rawDb);
|
|
12619
13239
|
workspace.reconcile({
|
|
12620
|
-
activeSessionIds: external.listSessions().map((session) => session.sessionId),
|
|
13240
|
+
activeSessionIds: external.listSessions().filter((session) => session.control === "full").map((session) => session.sessionId),
|
|
12621
13241
|
validReservationIds: delegationStore.list().filter((record) => record.status === "starting").map((record) => record.delegationId)
|
|
12622
13242
|
});
|
|
12623
13243
|
external.reconcileWorkspace();
|
|
@@ -12635,14 +13255,20 @@ async function startDaemon(options) {
|
|
|
12635
13255
|
pauseSession: (session, actor, reason) => pauseCurrentSessionOwner(sessions, external, session, actor, reason)
|
|
12636
13256
|
});
|
|
12637
13257
|
server.attachDelegations(delegationManager);
|
|
12638
|
-
const hookSecret =
|
|
13258
|
+
const hookSecret = randomBytes7(24).toString("base64url");
|
|
12639
13259
|
server.attachExternal(external, hookSecret);
|
|
12640
13260
|
const { port } = await server.listen();
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
|
|
12644
|
-
{
|
|
12645
|
-
|
|
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);
|
|
12646
13272
|
const stopMaintenance = sessions.startMaintenance();
|
|
12647
13273
|
let bridge = null;
|
|
12648
13274
|
if (options.relayUrl !== void 0) {
|
|
@@ -12652,6 +13278,11 @@ async function startDaemon(options) {
|
|
|
12652
13278
|
}
|
|
12653
13279
|
return {
|
|
12654
13280
|
server,
|
|
13281
|
+
rebindLocal: async (host) => {
|
|
13282
|
+
const reboundPort = await server.rebind(host);
|
|
13283
|
+
publishHookEndpoint(host, reboundPort);
|
|
13284
|
+
return reboundPort;
|
|
13285
|
+
},
|
|
12655
13286
|
sessions,
|
|
12656
13287
|
registry,
|
|
12657
13288
|
eventLog,
|
|
@@ -12711,7 +13342,62 @@ function save(path, config) {
|
|
|
12711
13342
|
}
|
|
12712
13343
|
|
|
12713
13344
|
// ../daemon/src/pairing-host.ts
|
|
13345
|
+
import WebSocket3 from "ws";
|
|
13346
|
+
|
|
13347
|
+
// ../daemon/src/pairing-relay-v2.ts
|
|
12714
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
|
|
12715
13401
|
var CompletePairing = external_exports.object({
|
|
12716
13402
|
v: external_exports.literal(1),
|
|
12717
13403
|
type: external_exports.literal("completePairing"),
|
|
@@ -12720,6 +13406,7 @@ var CompletePairing = external_exports.object({
|
|
|
12720
13406
|
deviceName: external_exports.string().min(1).max(64)
|
|
12721
13407
|
});
|
|
12722
13408
|
function hostPairing(opts) {
|
|
13409
|
+
if (JSON.parse(opts.challenge.qrPayload).v === 2) return hostVerifiedPairing(opts);
|
|
12723
13410
|
const log = opts.log ?? (() => {
|
|
12724
13411
|
});
|
|
12725
13412
|
let socket = null;
|
|
@@ -12738,7 +13425,7 @@ function hostPairing(opts) {
|
|
|
12738
13425
|
return;
|
|
12739
13426
|
}
|
|
12740
13427
|
if (disposed) return;
|
|
12741
|
-
const ws = new
|
|
13428
|
+
const ws = new WebSocket3(withRoom(opts.relayUrl, identity.roomTag, "host"));
|
|
12742
13429
|
socket = ws;
|
|
12743
13430
|
ws.on("open", () => {
|
|
12744
13431
|
ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" }));
|
|
@@ -12781,7 +13468,7 @@ function hostPairing(opts) {
|
|
|
12781
13468
|
reason: err instanceof PairingError ? err.reason : "error"
|
|
12782
13469
|
};
|
|
12783
13470
|
}
|
|
12784
|
-
if (ws.readyState ===
|
|
13471
|
+
if (ws.readyState === WebSocket3.OPEN) {
|
|
12785
13472
|
ws.send(JSON.stringify({ v: 1, type: "frame", payload: await seal(identity, JSON.stringify(reply)) }));
|
|
12786
13473
|
}
|
|
12787
13474
|
if (succeeded) setTimeout(dispose, 500);
|
|
@@ -12794,10 +13481,10 @@ function hostPairing(opts) {
|
|
|
12794
13481
|
}
|
|
12795
13482
|
|
|
12796
13483
|
// ../daemon/src/pairing-url.ts
|
|
12797
|
-
function pairingUrl(appOrigin, challengeId, secret) {
|
|
13484
|
+
function pairingUrl(appOrigin, challengeId, secret, version = 1) {
|
|
12798
13485
|
const url2 = new URL(appOrigin);
|
|
12799
13486
|
url2.search = "";
|
|
12800
|
-
url2.hash = new URLSearchParams({ c: challengeId, s: secret }).toString();
|
|
13487
|
+
url2.hash = new URLSearchParams({ c: challengeId, s: secret, ...version === 2 ? { v: "2" } : {} }).toString();
|
|
12801
13488
|
return url2.toString();
|
|
12802
13489
|
}
|
|
12803
13490
|
|
|
@@ -12945,7 +13632,7 @@ function noAddressReason(interfaces = networkInterfaces()) {
|
|
|
12945
13632
|
}
|
|
12946
13633
|
|
|
12947
13634
|
// ../daemon/src/instance-lock.ts
|
|
12948
|
-
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";
|
|
12949
13636
|
import { randomUUID } from "node:crypto";
|
|
12950
13637
|
import { join as join9 } from "node:path";
|
|
12951
13638
|
var LOCK_NAME = "daemon.lock";
|
|
@@ -12979,7 +13666,7 @@ function acquireDaemonInstance(dataDir2) {
|
|
|
12979
13666
|
if (processAlive(owner.pid)) {
|
|
12980
13667
|
throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
|
|
12981
13668
|
}
|
|
12982
|
-
|
|
13669
|
+
rmSync2(path);
|
|
12983
13670
|
create();
|
|
12984
13671
|
}
|
|
12985
13672
|
let held = true;
|
|
@@ -12990,7 +13677,7 @@ function acquireDaemonInstance(dataDir2) {
|
|
|
12990
13677
|
held = false;
|
|
12991
13678
|
try {
|
|
12992
13679
|
const owner = JSON.parse(readFileSync5(path, "utf8"));
|
|
12993
|
-
if (owner.token === token)
|
|
13680
|
+
if (owner.token === token) rmSync2(path);
|
|
12994
13681
|
} catch {
|
|
12995
13682
|
}
|
|
12996
13683
|
}
|
|
@@ -13141,20 +13828,61 @@ function relayAppOrigin(wsUrl) {
|
|
|
13141
13828
|
parsed.pathname = "/";
|
|
13142
13829
|
return parsed.toString();
|
|
13143
13830
|
}
|
|
13144
|
-
|
|
13145
|
-
|
|
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
|
+
}
|
|
13146
13842
|
if (relay !== null) {
|
|
13147
|
-
hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge
|
|
13148
|
-
|
|
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);
|
|
13149
13850
|
}
|
|
13150
13851
|
return pairingUrl(
|
|
13151
13852
|
`http://${servingHost}:${servingPort}/`,
|
|
13152
13853
|
challenge.challengeId,
|
|
13153
|
-
challenge.secret
|
|
13854
|
+
challenge.secret,
|
|
13855
|
+
2
|
|
13154
13856
|
);
|
|
13155
13857
|
}
|
|
13156
|
-
daemon.server.attachLocalPairing(freshPairingUrl);
|
|
13157
|
-
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?.();
|
|
13158
13886
|
console.log("\n=== LongLeash ===\n");
|
|
13159
13887
|
var warn = vpnWarning();
|
|
13160
13888
|
if (warn) console.log(`!!! ${warn}
|
|
@@ -13198,23 +13926,28 @@ Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
|
|
|
13198
13926
|
console.log(
|
|
13199
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."
|
|
13200
13928
|
);
|
|
13201
|
-
|
|
13202
|
-
|
|
13203
|
-
|
|
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(`
|
|
13204
13933
|
${url}
|
|
13205
13934
|
`);
|
|
13206
|
-
|
|
13207
|
-
|
|
13208
|
-
|
|
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.");
|
|
13209
13939
|
}
|
|
13210
13940
|
var NETWORK_WATCH_MS = 5e3;
|
|
13941
|
+
var networkRebindInFlight = false;
|
|
13211
13942
|
var networkWatch = setInterval(() => {
|
|
13943
|
+
if (networkRebindInFlight) return;
|
|
13212
13944
|
const nextBest = findCandidates()[0];
|
|
13213
13945
|
const nextHost = nextBest?.address ?? "127.0.0.1";
|
|
13214
13946
|
if (nextHost === servingHost) return;
|
|
13215
13947
|
const was = servingHost;
|
|
13216
|
-
|
|
13217
|
-
void daemon.
|
|
13948
|
+
networkRebindInFlight = true;
|
|
13949
|
+
void daemon.rebindLocal(nextHost).then((port) => {
|
|
13950
|
+
servingHost = nextHost;
|
|
13218
13951
|
servingPort = port;
|
|
13219
13952
|
console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
|
|
13220
13953
|
Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
|
|
@@ -13225,6 +13958,8 @@ Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
|
|
|
13225
13958
|
console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
|
|
13226
13959
|
Network changed but rebinding failed (${String(err)}).`);
|
|
13227
13960
|
console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
|
|
13961
|
+
}).finally(() => {
|
|
13962
|
+
networkRebindInFlight = false;
|
|
13228
13963
|
});
|
|
13229
13964
|
}, NETWORK_WATCH_MS);
|
|
13230
13965
|
networkWatch.unref?.();
|
|
@@ -13232,20 +13967,38 @@ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter
|
|
|
13232
13967
|
process.stdin.setEncoding("utf8");
|
|
13233
13968
|
process.stdin.on("data", (chunk) => {
|
|
13234
13969
|
const key = chunk.trim().toLowerCase();
|
|
13235
|
-
if (key === "n" &&
|
|
13236
|
-
|
|
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
|
+
}
|
|
13237
13978
|
console.log("\nScan this with your phone:\n");
|
|
13238
13979
|
console.log(terminalQr(next));
|
|
13239
13980
|
console.log(`
|
|
13240
13981
|
${next}
|
|
13241
13982
|
`);
|
|
13242
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
|
+
}
|
|
13243
13994
|
if (key === "r") {
|
|
13244
13995
|
const active = daemon.registry.listDevices().filter((d) => d.revokedAt === null);
|
|
13245
13996
|
for (const device of active) daemon.registry.revokeDevice(device.deviceId);
|
|
13246
13997
|
console.log(`>>> revoked ${active.length} device(s); their live connections are cut`);
|
|
13247
13998
|
}
|
|
13248
13999
|
if (key === "q") {
|
|
14000
|
+
clearInterval(pairingWatch);
|
|
14001
|
+
for (const dispose of relayPairings) dispose();
|
|
13249
14002
|
clearInterval(networkWatch);
|
|
13250
14003
|
void daemon.stop().then(() => {
|
|
13251
14004
|
instanceLock.release();
|
|
@@ -13254,6 +14007,8 @@ process.stdin.on("data", (chunk) => {
|
|
|
13254
14007
|
}
|
|
13255
14008
|
});
|
|
13256
14009
|
var shutdown = () => {
|
|
14010
|
+
clearInterval(pairingWatch);
|
|
14011
|
+
for (const dispose of relayPairings) dispose();
|
|
13257
14012
|
clearInterval(networkWatch);
|
|
13258
14013
|
void daemon.stop().then(() => {
|
|
13259
14014
|
instanceLock.release();
|