ocremote 1.3.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/install.sh +1 -0
- package/lib/crypto.mjs +1 -0
- package/lib/identity.mjs +7 -1
- package/lib/relay.mjs +66 -33
- package/lib/rpc.mjs +35 -11
- package/oc-remote.mjs +73 -24
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# oc-remote
|
|
2
2
|
|
|
3
|
-
> **Plataformas.** El companion
|
|
3
|
+
> **Plataformas.** El companion requiere Node >= 22.13 y soporta macOS, Linux y
|
|
4
4
|
> Windows: `npx ocremote --pair` en cualquiera de los tres. Solo en macOS:
|
|
5
5
|
> `--daemon` (LaunchAgent) y mDNS (`--no-mdns` lo desactiva; en otros sistemas ni
|
|
6
6
|
> se intenta). En Linux, para tenerlo siempre activo usa una unidad de usuario de
|
|
@@ -8,7 +8,30 @@
|
|
|
8
8
|
> de tareas al iniciar sesión. Los túneles (`--tunnel cloudflare`) descargan
|
|
9
9
|
> `cloudflared` para la plataforma correcta. Windows está corregido en código
|
|
10
10
|
> (rutas, `.exe`, `;` en PATH, cloudflared .exe) pero no verificado en una máquina
|
|
11
|
-
> Windows real.
|
|
11
|
+
> Windows real.
|
|
12
|
+
|
|
13
|
+
## Primer arranque (2.0.0, preparado para publicar)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g opencode-ai
|
|
17
|
+
opencode auth login
|
|
18
|
+
npx ocremote --pair
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--pair` selecciona el relay alojado por defecto y no necesita VPN ni descubrimiento
|
|
22
|
+
de red local. El proxy queda en loopback. El QR sólo se muestra cuando la invitación
|
|
23
|
+
está registrada: abre la app, escanéalo y conserva la terminal abierta mientras la
|
|
24
|
+
usas. Caduca en 15 minutos y es de un solo uso. Para mostrar otro QR de una instancia
|
|
25
|
+
en marcha: `npx ocremote pair`. En macOS, `npx ocremote --daemon` instala el servicio.
|
|
26
|
+
|
|
27
|
+
La nueva app comprueba la firma del ordenador contra la clave fijada por el QR. Es
|
|
28
|
+
necesario actualizar app, companion y relay juntos y volver a emparejar las entradas
|
|
29
|
+
antiguas que no tengan esa clave. La versión publicada no cambia hasta publicar el
|
|
30
|
+
paquete; este repositorio prepara 2.0.0.
|
|
31
|
+
|
|
32
|
+
Las secciones de proxy/túneles de abajo describen también el modo directo opcional.
|
|
33
|
+
Con `--relay` o `--pair`, el valor predeterminado de `--host` es `127.0.0.1`, el túnel
|
|
34
|
+
queda desactivado y Bonjour no se anuncia salvo petición explícita.
|
|
12
35
|
|
|
13
36
|
Proceso Node sin dependencias externas (solo APIs nativas de Node 22+) que
|
|
14
37
|
convierte un Mac en un host de **opencode** accesible desde la app iOS
|
|
@@ -34,7 +57,7 @@ push opcionales.
|
|
|
34
57
|
|
|
35
58
|
## Requisitos
|
|
36
59
|
|
|
37
|
-
-
|
|
60
|
+
- Node 22.13 o superior (`node --version`).
|
|
38
61
|
- El binario `opencode` (por defecto `~/.opencode/bin/opencode`).
|
|
39
62
|
- Opcional: `tailscale` (acceso remoto), `ntfy` (push, no requiere instalar
|
|
40
63
|
nada en el Mac), `dns-sd` (viene con macOS).
|
package/install.sh
CHANGED
|
@@ -175,6 +175,7 @@ ln -sfn "$ENTRY" "$BIN_DIR/oc-remote"
|
|
|
175
175
|
echo ' <array>'
|
|
176
176
|
echo " <string>$(xml_escape "$NODE_BIN")</string>"
|
|
177
177
|
echo " <string>$(xml_escape "$BIN_DIR/oc-remote")</string>"
|
|
178
|
+
echo ' <string>--no-print-qr</string>'
|
|
178
179
|
echo ' <string>--dir</string>'
|
|
179
180
|
echo " <string>$(xml_escape "$PROJECT_DIR")</string>"
|
|
180
181
|
echo ' <string>--port</string>'
|
package/lib/crypto.mjs
CHANGED
|
@@ -102,6 +102,7 @@ export function openWire(key, expectedDir, payload, minCounter = -1n) {
|
|
|
102
102
|
if (dir !== expectedDir) throw new Error('bad direction')
|
|
103
103
|
let counter = 0n
|
|
104
104
|
for (const byte of payload.subarray(1, 12)) counter = (counter << 8n) | BigInt(byte)
|
|
105
|
+
if (counter > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('counter out of range')
|
|
105
106
|
if (counter <= minCounter) throw new Error('replayed counter')
|
|
106
107
|
const plaintext = open(key, dir, counter, payload.subarray(12))
|
|
107
108
|
return { counter: Number(counter), plaintext }
|
package/lib/identity.mjs
CHANGED
|
@@ -31,8 +31,13 @@ export class Identity {
|
|
|
31
31
|
privateKey: privateKeyFromSeed(unb64(parsed.privateKey)),
|
|
32
32
|
createdAt: parsed.createdAt,
|
|
33
33
|
}
|
|
34
|
+
const publicRaw = exportRawPublic(crypto.createPublicKey(this.identity.privateKey))
|
|
35
|
+
if (this.identity.id !== deviceIDFromPublic(publicRaw) || !publicRaw.equals(this.identity.publicRaw)) {
|
|
36
|
+
throw new Error('Identity keys do not match')
|
|
37
|
+
}
|
|
34
38
|
return this
|
|
35
|
-
} catch {
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT') throw new Error(`Cannot read identity: ${error.message}`)
|
|
36
41
|
this.identity = null
|
|
37
42
|
return null
|
|
38
43
|
}
|
|
@@ -100,6 +105,7 @@ export class Identity {
|
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
pairFile(pairID) {
|
|
108
|
+
if (typeof pairID !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(pairID)) throw new Error('Invalid pair ID')
|
|
103
109
|
return path.join(this.pairsDir, `${pairID}.json`)
|
|
104
110
|
}
|
|
105
111
|
|
package/lib/relay.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export class RelayClient {
|
|
|
33
33
|
this.identity = identity
|
|
34
34
|
this.adapter = adapter
|
|
35
35
|
this.logger = logger
|
|
36
|
-
this.invites = invites
|
|
36
|
+
this.invites = invites ?? new Map()
|
|
37
37
|
this.onEvent = onEvent || (() => {})
|
|
38
38
|
this.ws = null
|
|
39
39
|
this.sessions = new Map()
|
|
@@ -42,6 +42,7 @@ export class RelayClient {
|
|
|
42
42
|
this.lastMessageAt = 0
|
|
43
43
|
this.pingTimer = null
|
|
44
44
|
this.reconnectTimer = null
|
|
45
|
+
this.ready = false
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
log(message) {
|
|
@@ -91,7 +92,7 @@ export class RelayClient {
|
|
|
91
92
|
async registerPair(pair) {
|
|
92
93
|
const ts = Math.floor(Date.now() / 1000)
|
|
93
94
|
const deviceID = this.identity.deviceID()
|
|
94
|
-
const message = `ocremote-pair-
|
|
95
|
+
const message = `ocremote-pair-v2|${deviceID}|${pair.pairID}|${pair.clientID}|${pair.clientPK}|${pair.name || ''}|${ts}`
|
|
95
96
|
await this.api('/v1/pairs', {
|
|
96
97
|
body: {
|
|
97
98
|
deviceID,
|
|
@@ -133,23 +134,31 @@ export class RelayClient {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
async createInvite() {
|
|
137
|
+
await this.waitReady()
|
|
138
|
+
const now = Date.now()
|
|
136
139
|
const invite = {
|
|
137
140
|
pairID: `p_${randomBytes(6).toString('hex')}`,
|
|
138
141
|
secret: randomBytes(32),
|
|
139
|
-
createdAt:
|
|
140
|
-
expiresAt:
|
|
142
|
+
createdAt: now,
|
|
143
|
+
expiresAt: now + 15 * 60_000,
|
|
141
144
|
}
|
|
145
|
+
await this.registerInvite(invite)
|
|
146
|
+
if (!this.ready) throw new Error('Relay disconnected. Try pairing again once connected.')
|
|
142
147
|
this.invites.set(invite.pairID, invite)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
await this.registerInvite(invite)
|
|
146
|
-
} catch (err) {
|
|
147
|
-
this.log(`could not register invite with the relay: ${err.message}`)
|
|
148
|
-
}
|
|
148
|
+
for (const [id, previous] of [...this.invites]) {
|
|
149
|
+
if (previous.expiresAt <= now) this.invites.delete(id)
|
|
149
150
|
}
|
|
150
151
|
return invite
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
async waitReady(timeout = 15_000) {
|
|
155
|
+
const deadline = Date.now() + timeout
|
|
156
|
+
while (!this.ready && !this.stopped && Date.now() < deadline) {
|
|
157
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
158
|
+
}
|
|
159
|
+
if (!this.ready) throw new Error('Relay is unavailable. Check your internet connection and try again.')
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
pruneInvites() {
|
|
154
163
|
const now = Date.now()
|
|
155
164
|
for (const [id, invite] of this.invites) {
|
|
@@ -178,6 +187,7 @@ export class RelayClient {
|
|
|
178
187
|
|
|
179
188
|
stop() {
|
|
180
189
|
this.stopped = true
|
|
190
|
+
this.ready = false
|
|
181
191
|
clearTimeout(this.reconnectTimer)
|
|
182
192
|
clearInterval(this.pingTimer)
|
|
183
193
|
for (const session of this.sessions.values()) session.close('relay_stopped')
|
|
@@ -206,6 +216,7 @@ export class RelayClient {
|
|
|
206
216
|
const challenge = await this.api('/v1/challenge', {
|
|
207
217
|
body: { deviceID: this.identity.deviceID() },
|
|
208
218
|
})
|
|
219
|
+
if (this.stopped) return
|
|
209
220
|
const role = 'companion'
|
|
210
221
|
const message = `ocremote-ws-v1|${role}|${this.identity.deviceID()}||${challenge.challenge}`
|
|
211
222
|
const url = `${this.relayUrl}/v1/ws?role=${role}&device=${this.identity.deviceID()}`
|
|
@@ -235,11 +246,13 @@ export class RelayClient {
|
|
|
235
246
|
}, PING_INTERVAL)
|
|
236
247
|
})
|
|
237
248
|
ws.addEventListener('message', (event) => {
|
|
249
|
+
if (this.ws !== ws || this.stopped) return
|
|
238
250
|
this.lastMessageAt = Date.now()
|
|
239
251
|
this.handleMessage(event.data)
|
|
240
252
|
})
|
|
241
253
|
ws.addEventListener('close', () => {
|
|
242
254
|
if (this.ws !== ws) return
|
|
255
|
+
this.ready = false
|
|
243
256
|
clearInterval(this.pingTimer)
|
|
244
257
|
for (const session of this.sessions.values()) session.close('relay_disconnected')
|
|
245
258
|
this.sessions.clear()
|
|
@@ -293,6 +306,7 @@ export class RelayClient {
|
|
|
293
306
|
return
|
|
294
307
|
}
|
|
295
308
|
if (msg.t === 'session_open') {
|
|
309
|
+
this.sessions.get(msg.session)?.close('replaced')
|
|
296
310
|
const session = new RelaySession({
|
|
297
311
|
id: msg.session,
|
|
298
312
|
clientID: msg.clientID,
|
|
@@ -317,6 +331,7 @@ export class RelayClient {
|
|
|
317
331
|
}
|
|
318
332
|
if (msg.t === 'pong') return
|
|
319
333
|
if (msg.t === 'hello') {
|
|
334
|
+
this.ready = true
|
|
320
335
|
this.log(`relay hello: ${JSON.stringify(msg.device || {})}`)
|
|
321
336
|
return
|
|
322
337
|
}
|
|
@@ -353,6 +368,9 @@ export class RelaySession {
|
|
|
353
368
|
if (this.closed) return
|
|
354
369
|
this.closed = true
|
|
355
370
|
this.adapter?.cancelAll(this)
|
|
371
|
+
if (!['relay_stopped', 'relay_disconnected', 'client_disconnected', 'replaced'].includes(reason)) {
|
|
372
|
+
this.relay.send(control({ t: 'session_close', session: this.id }))
|
|
373
|
+
}
|
|
356
374
|
this.log(`session ${this.id} closed (${reason})`)
|
|
357
375
|
}
|
|
358
376
|
|
|
@@ -369,44 +387,55 @@ export class RelaySession {
|
|
|
369
387
|
}
|
|
370
388
|
|
|
371
389
|
handleOpen(payload) {
|
|
390
|
+
if (this.closed || this.mode !== 'awaiting') return
|
|
372
391
|
let msg = null
|
|
373
392
|
try {
|
|
374
393
|
msg = JSON.parse(payload.toString('utf8'))
|
|
375
394
|
} catch {
|
|
376
395
|
return
|
|
377
396
|
}
|
|
378
|
-
if (msg.t !== 'hs1' || msg.v !== PROTOCOL_VERSION) return
|
|
397
|
+
if (!msg || msg.t !== 'hs1' || msg.v !== PROTOCOL_VERSION) return
|
|
379
398
|
this.clientNonce = unb64(msg.nonce)
|
|
380
399
|
if (this.clientNonce.length !== 32) {
|
|
381
400
|
this.close('bad_nonce')
|
|
382
401
|
return
|
|
383
402
|
}
|
|
403
|
+
const clientPK = unb64(msg.clientPK)
|
|
404
|
+
const proof = `ocremote-client-v1|${this.identity.deviceID()}|${msg.pair}|${msg.clientPK}|${msg.nonce}`
|
|
405
|
+
if (clientPK.length !== 32 || !verify(clientPK, unb64(msg.sig), proof)) {
|
|
406
|
+
this.close('client_signature_invalid')
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
this.handshakeClientPK = msg.clientPK
|
|
384
410
|
this.serverNonce = randomBytes(32)
|
|
385
411
|
this.sendCounter = 0
|
|
386
412
|
this.recvCounter = -1
|
|
387
413
|
this.failures = 0
|
|
388
|
-
this.sendOpen({ t: 'hs2', v: PROTOCOL_VERSION, nonce: b64(this.serverNonce) })
|
|
389
414
|
|
|
390
415
|
const pair = this.identity.pair(msg.pair)
|
|
391
416
|
if (pair) {
|
|
417
|
+
if (pair.clientPK !== msg.clientPK || pair.pairKey.length !== 32) {
|
|
418
|
+
this.close('client_key_mismatch')
|
|
419
|
+
return
|
|
420
|
+
}
|
|
392
421
|
this.mode = 'paired'
|
|
393
422
|
this.pairID = pair.pairID
|
|
394
423
|
this.clientID = pair.clientID
|
|
395
424
|
this.key = sessionKey(pair.pairKey, this.clientNonce, this.serverNonce, pair.pairID)
|
|
396
425
|
this.log(`session ${this.id} handshake ok (paired ${pair.clientID})`)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
this.
|
|
404
|
-
|
|
426
|
+
} else {
|
|
427
|
+
const invite = this.relay.invites.get(msg.pair)
|
|
428
|
+
if (!invite || invite.claimed || Date.now() >= invite.expiresAt) {
|
|
429
|
+
this.close('invite_invalid')
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
this.mode = 'pairing'
|
|
433
|
+
this.pairID = invite.pairID
|
|
434
|
+
this.key = inviteSessionKey(invite.secret, this.clientNonce, this.serverNonce)
|
|
405
435
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
this.
|
|
409
|
-
this.log(`session ${this.id} handshake ok (pairing invite ${invite.pairID})`)
|
|
436
|
+
const nonce = b64(this.serverNonce)
|
|
437
|
+
const transcript = `ocremote-channel-v1|${this.identity.deviceID()}|${this.pairID}|${msg.clientPK}|${msg.nonce}|${nonce}`
|
|
438
|
+
this.sendOpen({ t: 'hs2', v: PROTOCOL_VERSION, nonce, sig: b64(this.identity.sign(transcript)) })
|
|
410
439
|
}
|
|
411
440
|
|
|
412
441
|
handleSealed(payload) {
|
|
@@ -432,10 +461,12 @@ export class RelaySession {
|
|
|
432
461
|
}
|
|
433
462
|
|
|
434
463
|
handleMessage(msg) {
|
|
464
|
+
if (!msg || typeof msg !== 'object') return
|
|
435
465
|
if (msg.t === 'pair' && this.mode === 'pairing') {
|
|
436
466
|
this.completePairing(msg)
|
|
437
467
|
return
|
|
438
468
|
}
|
|
469
|
+
if (this.mode !== 'paired') return
|
|
439
470
|
if (msg.t === 'ping') {
|
|
440
471
|
this.sendSealed({ t: 'pong' })
|
|
441
472
|
return
|
|
@@ -451,23 +482,19 @@ export class RelaySession {
|
|
|
451
482
|
|
|
452
483
|
async completePairing(msg) {
|
|
453
484
|
const invite = this.relay.invites.get(this.pairID)
|
|
454
|
-
if (!invite) {
|
|
485
|
+
if (!invite || invite.claimed || Date.now() >= invite.expiresAt) {
|
|
455
486
|
this.close('invite_missing')
|
|
456
487
|
return
|
|
457
488
|
}
|
|
458
|
-
if (typeof msg.clientID !== 'string' ||
|
|
489
|
+
if (typeof msg.clientID !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(msg.clientID) ||
|
|
490
|
+
msg.clientPK !== this.handshakeClientPK || (msg.name !== undefined && (typeof msg.name !== 'string' || Buffer.byteLength(msg.name) > 64))) {
|
|
459
491
|
this.close('bad_pair_request')
|
|
460
492
|
return
|
|
461
493
|
}
|
|
462
494
|
const pairKey = pairKeyFromInvite(invite.secret, this.clientNonce, this.serverNonce)
|
|
495
|
+
invite.claimed = true
|
|
496
|
+
this.mode = 'registering'
|
|
463
497
|
this.relay.invites.delete(this.pairID)
|
|
464
|
-
this.identity.savePairWithKey({
|
|
465
|
-
pairID: this.pairID,
|
|
466
|
-
clientID: msg.clientID,
|
|
467
|
-
clientPK: msg.clientPK,
|
|
468
|
-
name: msg.name,
|
|
469
|
-
pairKey,
|
|
470
|
-
})
|
|
471
498
|
try {
|
|
472
499
|
await this.relay.registerPair({
|
|
473
500
|
pairID: this.pairID,
|
|
@@ -475,8 +502,14 @@ export class RelaySession {
|
|
|
475
502
|
clientPK: msg.clientPK,
|
|
476
503
|
name: msg.name,
|
|
477
504
|
})
|
|
505
|
+
this.identity.savePairWithKey({
|
|
506
|
+
pairID: this.pairID, clientID: msg.clientID, clientPK: msg.clientPK, name: msg.name, pairKey,
|
|
507
|
+
})
|
|
478
508
|
} catch (err) {
|
|
479
509
|
this.log(`pair registration failed: ${err.message}`)
|
|
510
|
+
this.sendSealed({ t: 'pair_error', message: 'Pairing could not be saved. Show a new QR and try again.' })
|
|
511
|
+
this.close('pair_registration_failed')
|
|
512
|
+
return
|
|
480
513
|
}
|
|
481
514
|
this.mode = 'paired'
|
|
482
515
|
this.clientID = msg.clientID
|
package/lib/rpc.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { b64 } from './crypto.mjs'
|
|
|
2
2
|
|
|
3
3
|
const MAX_CONCURRENT = 8
|
|
4
4
|
const MAX_UPLOAD = 24 << 20
|
|
5
|
+
const MAX_RESPONSE = 700 * 1024
|
|
5
6
|
|
|
6
7
|
export class RpcAdapter {
|
|
7
8
|
constructor({ baseUrl, authHeader, logger }) {
|
|
@@ -9,6 +10,7 @@ export class RpcAdapter {
|
|
|
9
10
|
this.authHeader = authHeader
|
|
10
11
|
this.logger = logger
|
|
11
12
|
this.active = new Map()
|
|
13
|
+
this.inFlight = 0
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
log(message) {
|
|
@@ -16,12 +18,12 @@ export class RpcAdapter {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
sessionMap(session) {
|
|
19
|
-
if (!this.active.has(session
|
|
20
|
-
return this.active.get(session
|
|
21
|
+
if (!this.active.has(session)) this.active.set(session, new Map())
|
|
22
|
+
return this.active.get(session)
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
cancel(session, id) {
|
|
24
|
-
const entry = this.
|
|
26
|
+
const entry = this.active.get(session)?.get(id)
|
|
25
27
|
if (entry) {
|
|
26
28
|
entry.controller.abort()
|
|
27
29
|
this.log(`rpc ${id} cancelled`)
|
|
@@ -33,7 +35,7 @@ export class RpcAdapter {
|
|
|
33
35
|
// server applied it. Better to let it finish on the computer and let the
|
|
34
36
|
// client reconcile by inspecting state after reconnecting.
|
|
35
37
|
cancelAll(session) {
|
|
36
|
-
const map = this.active.get(session
|
|
38
|
+
const map = this.active.get(session)
|
|
37
39
|
if (!map) return
|
|
38
40
|
for (const entry of map.values()) {
|
|
39
41
|
if (entry.mutating) continue
|
|
@@ -41,14 +43,14 @@ export class RpcAdapter {
|
|
|
41
43
|
entry.controller.abort()
|
|
42
44
|
} catch {}
|
|
43
45
|
}
|
|
44
|
-
this.active.delete(session
|
|
46
|
+
this.active.delete(session)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
async handle(session, msg) {
|
|
48
50
|
const id = msg.id
|
|
49
|
-
if (
|
|
51
|
+
if (!Number.isSafeInteger(id) || typeof msg.path !== 'string' || session.closed) return
|
|
50
52
|
const map = this.sessionMap(session)
|
|
51
|
-
if (map.size >= MAX_CONCURRENT) {
|
|
53
|
+
if (map.size >= MAX_CONCURRENT || this.inFlight >= 40 || map.has(id)) {
|
|
52
54
|
session.sendSealed({ t: 'res', id, error: 'too_many_requests' })
|
|
53
55
|
return
|
|
54
56
|
}
|
|
@@ -58,10 +60,16 @@ export class RpcAdapter {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
const controller = new AbortController()
|
|
61
|
-
const method =
|
|
63
|
+
const method = typeof msg.method === 'string' ? msg.method.toUpperCase() : 'GET'
|
|
64
|
+
if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].includes(method)) {
|
|
65
|
+
session.sendSealed({ t: 'res', id, error: 'bad_method' })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
62
68
|
const mutating = method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'
|
|
63
69
|
map.set(id, { controller, mutating })
|
|
64
70
|
const stream = msg.stream === true
|
|
71
|
+
this.inFlight++
|
|
72
|
+
const timer = stream ? null : setTimeout(() => controller.abort(), mutating ? 900_000 : 120_000)
|
|
65
73
|
let opened = false
|
|
66
74
|
try {
|
|
67
75
|
const url = new URL(`${this.baseUrl}${msg.path}`)
|
|
@@ -72,7 +80,7 @@ export class RpcAdapter {
|
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
82
|
const headers = { authorization: this.authHeader }
|
|
75
|
-
const init = { method, headers, signal: controller.signal }
|
|
83
|
+
const init = { method, headers, signal: controller.signal, redirect: 'error' }
|
|
76
84
|
if (msg.body) {
|
|
77
85
|
const body = Buffer.from(String(msg.body), 'base64')
|
|
78
86
|
if (body.length > MAX_UPLOAD) {
|
|
@@ -85,7 +93,18 @@ export class RpcAdapter {
|
|
|
85
93
|
|
|
86
94
|
const res = await fetch(url, init)
|
|
87
95
|
if (!stream) {
|
|
88
|
-
const
|
|
96
|
+
const chunks = []
|
|
97
|
+
let size = 0
|
|
98
|
+
if (res.body) for await (const chunk of res.body) {
|
|
99
|
+
size += chunk.length
|
|
100
|
+
if (size > MAX_RESPONSE) {
|
|
101
|
+
controller.abort()
|
|
102
|
+
session.sendSealed({ t: 'res', id, error: 'Response exceeds the relay limit. Narrow the request and try again.' })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
chunks.push(Buffer.from(chunk))
|
|
106
|
+
}
|
|
107
|
+
const buffer = Buffer.concat(chunks)
|
|
89
108
|
session.sendSealed({
|
|
90
109
|
t: 'res',
|
|
91
110
|
id,
|
|
@@ -104,7 +123,9 @@ export class RpcAdapter {
|
|
|
104
123
|
}
|
|
105
124
|
for await (const chunk of res.body) {
|
|
106
125
|
if (controller.signal.aborted) break
|
|
107
|
-
|
|
126
|
+
for (let offset = 0; offset < chunk.length; offset += 64 * 1024) {
|
|
127
|
+
session.sendSealed({ t: 'chunk', id, data: b64(chunk.subarray(offset, offset + 64 * 1024)) })
|
|
128
|
+
}
|
|
108
129
|
}
|
|
109
130
|
session.sendSealed({ t: 'end', id })
|
|
110
131
|
} catch (err) {
|
|
@@ -115,7 +136,10 @@ export class RpcAdapter {
|
|
|
115
136
|
session.sendSealed(opened ? { t: 'end', id, error: 'stream_failed' } : { t: 'res', id, error: 'opencode_unreachable' })
|
|
116
137
|
}
|
|
117
138
|
} finally {
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
this.inFlight--
|
|
118
141
|
map.delete(id)
|
|
142
|
+
if (map.size === 0 && this.active.get(session) === map) this.active.delete(session)
|
|
119
143
|
}
|
|
120
144
|
}
|
|
121
145
|
}
|
package/oc-remote.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { b64 } from './lib/crypto.mjs'
|
|
|
17
17
|
|
|
18
18
|
const require = createRequire(import.meta.url)
|
|
19
19
|
const NAME = 'oc-remote'
|
|
20
|
-
const VERSION = '
|
|
20
|
+
const VERSION = '2.0.0'
|
|
21
21
|
const LAUNCH_LABEL = 'com.raul.ocremote'
|
|
22
22
|
const LOG_PATH =
|
|
23
23
|
process.platform === 'darwin'
|
|
@@ -42,9 +42,9 @@ function augmentPath() {
|
|
|
42
42
|
path.join(os.homedir(), '.local', 'bin'),
|
|
43
43
|
path.join(os.homedir(), '.opencode', 'bin'),
|
|
44
44
|
]
|
|
45
|
-
const current = (process.env.PATH || '').split(
|
|
45
|
+
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
|
|
46
46
|
const missing = extras.filter((dir) => dir && !current.includes(dir) && fs.existsSync(dir))
|
|
47
|
-
if (missing.length) process.env.PATH = [...current, ...missing].join(
|
|
47
|
+
if (missing.length) process.env.PATH = [...current, ...missing].join(path.delimiter)
|
|
48
48
|
}
|
|
49
49
|
augmentPath()
|
|
50
50
|
|
|
@@ -52,7 +52,7 @@ const HELP = `oc-remote v${VERSION} - remote companion for opencode
|
|
|
52
52
|
|
|
53
53
|
Usage:
|
|
54
54
|
oc-remote [options] Start the companion (and the daemon entry point)
|
|
55
|
-
npx ocremote --pair
|
|
55
|
+
npx ocremote [--pair] Start and show the pairing QR (Node 22.13+)
|
|
56
56
|
npx ocremote --daemon Install as a background service (LaunchAgent)
|
|
57
57
|
oc-remote doctor Diagnose every link in the connection chain
|
|
58
58
|
oc-remote status Show config, daemon state and live endpoint
|
|
@@ -69,7 +69,7 @@ Options:
|
|
|
69
69
|
--port <n> Companion HTTP/SSE proxy port (default: 4190)
|
|
70
70
|
--opencode-port <n> Loopback port for the internal opencode serve (default: 4191)
|
|
71
71
|
--utility-port <n> Loopback-only port for pairing/utility pages (default: port+1)
|
|
72
|
-
--relay <url>
|
|
72
|
+
--relay <url> Custom relay URL; --pair uses the hosted relay by default
|
|
73
73
|
--device-name <name> Name shown on the phone (default: hostname)
|
|
74
74
|
--tunnel <mode> Remote access: auto | funnel | cloudflare | tailscale | none
|
|
75
75
|
auto reuse an active funnel, else cloudflared, else Tailscale, else LAN
|
|
@@ -940,6 +940,14 @@ function handleRequest(req, res) {
|
|
|
940
940
|
}
|
|
941
941
|
|
|
942
942
|
function handleUtilityRequest(req, res) {
|
|
943
|
+
const localHost = `127.0.0.1:${opts.utilityPort}`
|
|
944
|
+
const allowedHosts = new Set([localHost, `localhost:${opts.utilityPort}`])
|
|
945
|
+
if (!allowedHosts.has(req.headers.host) ||
|
|
946
|
+
(req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) ||
|
|
947
|
+
req.headers['sec-fetch-site'] === 'cross-site') {
|
|
948
|
+
sendJson(req, res, 403, { error: 'local_origin_required' })
|
|
949
|
+
return
|
|
950
|
+
}
|
|
943
951
|
let url
|
|
944
952
|
try {
|
|
945
953
|
url = new URL(req.url, 'http://127.0.0.1')
|
|
@@ -1002,6 +1010,7 @@ async function createUtilityServer() {
|
|
|
1002
1010
|
})
|
|
1003
1011
|
})
|
|
1004
1012
|
if (!ok) continue
|
|
1013
|
+
saveConfig({ ...loadConfig(), utilityPort: opts.utilityPort })
|
|
1005
1014
|
info(`pairing page (loopback only): http://${LOOPBACK}:${opts.utilityPort}/_ocremote/pair`)
|
|
1006
1015
|
return srv
|
|
1007
1016
|
}
|
|
@@ -1555,6 +1564,10 @@ function noteWatchdogFailure() {
|
|
|
1555
1564
|
}
|
|
1556
1565
|
|
|
1557
1566
|
function printPairing(options = {}) {
|
|
1567
|
+
if (opts.relay) {
|
|
1568
|
+
info('remote access: encrypted relay; no VPN or local-network discovery needed')
|
|
1569
|
+
return
|
|
1570
|
+
}
|
|
1558
1571
|
for (const entry of allUrls) info(`url: ${entry.url} [${entry.label}]`)
|
|
1559
1572
|
info(`pairing token: ${remoteToken}`)
|
|
1560
1573
|
const publicEntry = allUrls.find((entry) => entry.label === 'public' || entry.label === 'tunnel')
|
|
@@ -1568,10 +1581,6 @@ function printPairing(options = {}) {
|
|
|
1568
1581
|
} else {
|
|
1569
1582
|
info('remote access: LAN only - run `oc-remote --tunnel funnel` (stable URL) or --tunnel cloudflare')
|
|
1570
1583
|
}
|
|
1571
|
-
if (opts.relay) {
|
|
1572
|
-
info('pairing: relay mode, the invite QR below is the one to scan')
|
|
1573
|
-
return
|
|
1574
|
-
}
|
|
1575
1584
|
info(`pairing deep link: ${pairLink()}`)
|
|
1576
1585
|
if (options.qr === false || !opts.printQr) return
|
|
1577
1586
|
if (!qrcode) {
|
|
@@ -1620,23 +1629,31 @@ async function startRelay() {
|
|
|
1620
1629
|
await relayClient.registerDevice(VERSION)
|
|
1621
1630
|
info(`relay: registered device ${identity.deviceID()} (${identity.name()})`)
|
|
1622
1631
|
} catch (err) {
|
|
1623
|
-
|
|
1632
|
+
throw new Error(`Could not register with the relay: ${err.message}. Check your connection and run npx ocremote --pair again.`)
|
|
1624
1633
|
}
|
|
1625
1634
|
relayClient.start()
|
|
1635
|
+
info('connecting to the relay…')
|
|
1636
|
+
await relayClient.waitReady()
|
|
1637
|
+
if (!opts.printQr) {
|
|
1638
|
+
info('Ready. To pair your iPhone, run: npx ocremote pair')
|
|
1639
|
+
return
|
|
1640
|
+
}
|
|
1626
1641
|
const payload = await createInvitePayload()
|
|
1627
1642
|
info(`relay: ${opts.relay}`)
|
|
1628
|
-
|
|
1643
|
+
process.stdout.write(`${payload.url}\n`)
|
|
1629
1644
|
if (payload.expiresAt) {
|
|
1630
1645
|
info(`invite expires at ${new Date(payload.expiresAt).toLocaleTimeString()} (15 minutes, single use)`)
|
|
1631
1646
|
}
|
|
1632
1647
|
printInviteQr(payload.url)
|
|
1648
|
+
info('Open the iPhone app → Connect your computer → Scan QR. Keep this terminal open while using the app.')
|
|
1633
1649
|
if (opts.pair) {
|
|
1634
1650
|
clearInterval(inviteRefreshTimer)
|
|
1635
1651
|
inviteRefreshTimer = setInterval(async () => {
|
|
1636
1652
|
if (shuttingDown) return
|
|
1637
1653
|
try {
|
|
1638
1654
|
const fresh = await createInvitePayload()
|
|
1639
|
-
info(
|
|
1655
|
+
info('New pairing QR ready. The previous QR stays valid until its expiry time.')
|
|
1656
|
+
process.stdout.write(`${fresh.url}\n`)
|
|
1640
1657
|
printInviteQr(fresh.url)
|
|
1641
1658
|
} catch (err) {
|
|
1642
1659
|
warn(`could not refresh the invite: ${err.message}`)
|
|
@@ -1973,15 +1990,11 @@ async function runSubcommand(name, argv) {
|
|
|
1973
1990
|
}
|
|
1974
1991
|
|
|
1975
1992
|
if (name === 'pair') {
|
|
1976
|
-
const
|
|
1977
|
-
const
|
|
1978
|
-
if (flags['utility-port'] !== undefined) candidates.push(Number(flags['utility-port']))
|
|
1979
|
-
if (cfg.utilityPort) candidates.push(Number(cfg.utilityPort))
|
|
1980
|
-
for (let offset = 1; offset <= 4; offset++) candidates.push(mainPort + offset)
|
|
1981
|
-
const ports = [...new Set(candidates)].filter((port) => port > 0 && port <= 65535)
|
|
1993
|
+
const utilityPort = Number(flags['utility-port'] ?? cfg.utilityPort)
|
|
1994
|
+
const ports = Number.isInteger(utilityPort) && utilityPort > 0 && utilityPort <= 65535 ? [utilityPort] : []
|
|
1982
1995
|
let payload = null
|
|
1983
1996
|
for (const port of ports) {
|
|
1984
|
-
const res = await postLocal(port, '/_ocremote/invite')
|
|
1997
|
+
const res = await postLocal(port, '/_ocremote/invite', 25_000)
|
|
1985
1998
|
if (!res || res.status !== 200) continue
|
|
1986
1999
|
try {
|
|
1987
2000
|
payload = JSON.parse(res.text)
|
|
@@ -1990,7 +2003,7 @@ async function runSubcommand(name, argv) {
|
|
|
1990
2003
|
}
|
|
1991
2004
|
if (!payload) {
|
|
1992
2005
|
error('could not get an invite from the running companion')
|
|
1993
|
-
out('
|
|
2006
|
+
out('Start it with: npx ocremote --pair')
|
|
1994
2007
|
return 1
|
|
1995
2008
|
}
|
|
1996
2009
|
out(payload.url)
|
|
@@ -2223,6 +2236,8 @@ async function runSubcommand(name, argv) {
|
|
|
2223
2236
|
}
|
|
2224
2237
|
|
|
2225
2238
|
async function main() {
|
|
2239
|
+
const [major, minor] = process.versions.node.split('.').map(Number)
|
|
2240
|
+
if (major < 22 || (major === 22 && minor < 13)) abort('Node 22.13 or newer is required. Update Node, then run npx ocremote --pair again.')
|
|
2226
2241
|
const argv = process.argv.slice(2)
|
|
2227
2242
|
if (argv[0] && SUBCOMMANDS.has(argv[0])) {
|
|
2228
2243
|
const code = await runSubcommand(argv[0], argv.slice(1))
|
|
@@ -2231,6 +2246,7 @@ async function main() {
|
|
|
2231
2246
|
const parsed = parseArgs(argv)
|
|
2232
2247
|
if (parsed.error) abort(parsed.error)
|
|
2233
2248
|
const flags = parsed.flags
|
|
2249
|
+
if (argv.length === 0) flags.pair = true
|
|
2234
2250
|
if (flags.help) {
|
|
2235
2251
|
process.stdout.write(HELP)
|
|
2236
2252
|
return 0
|
|
@@ -2245,7 +2261,11 @@ async function main() {
|
|
|
2245
2261
|
opts.publicUrl = flags['public-url'] !== undefined ? flags['public-url'] : (cfg.publicUrl ?? null)
|
|
2246
2262
|
opts.tunnel = flags.tunnel !== undefined ? String(flags.tunnel) : (cfg.tunnel ?? 'auto')
|
|
2247
2263
|
opts.ntfy = flags.ntfy !== undefined ? flags.ntfy : (cfg.ntfy ?? null)
|
|
2248
|
-
opts.
|
|
2264
|
+
opts.pair = Boolean(flags.pair)
|
|
2265
|
+
opts.relay = flags.relay !== undefined ? String(flags.relay) :
|
|
2266
|
+
(cfg.relay ?? ((flags.pair || flags.daemon) && flags.tunnel === undefined ? 'wss://relay-production-9c9b.up.railway.app' : null))
|
|
2267
|
+
if (opts.relay && flags.tunnel === undefined) opts.tunnel = 'none'
|
|
2268
|
+
if (opts.relay && flags.host === undefined) opts.host = LOOPBACK
|
|
2249
2269
|
opts.deviceName = flags['device-name'] !== undefined ? String(flags['device-name']) : (cfg.deviceName ?? null)
|
|
2250
2270
|
opts.utilityPort =
|
|
2251
2271
|
flags['utility-port'] !== undefined
|
|
@@ -2253,6 +2273,7 @@ async function main() {
|
|
|
2253
2273
|
: Number(cfg.utilityPort ?? 0) || null
|
|
2254
2274
|
opts.noAuth = Boolean(flags['no-auth'])
|
|
2255
2275
|
opts.mdns = flags.mdns === true || (!flags['no-mdns'] && process.platform === 'darwin')
|
|
2276
|
+
if (opts.relay && flags.mdns !== true) opts.mdns = false
|
|
2256
2277
|
opts.mdnsExplicit = flags.mdns === true
|
|
2257
2278
|
opts.printQr = flags['print-qr'] !== false
|
|
2258
2279
|
|
|
@@ -2268,7 +2289,15 @@ async function main() {
|
|
|
2268
2289
|
abort(`invalid --utility-port: ${flags['utility-port'] ?? cfg.utilityPort}`)
|
|
2269
2290
|
if (opts.utilityPort !== null && (opts.utilityPort === opts.port || opts.utilityPort === opts.opencodePort))
|
|
2270
2291
|
abort('--utility-port must differ from --port and --opencode-port')
|
|
2271
|
-
if (opts.relay
|
|
2292
|
+
if (opts.relay) {
|
|
2293
|
+
let relayURL
|
|
2294
|
+
try { relayURL = new URL(opts.relay) } catch { abort('Invalid --relay URL. Use wss://hostname.') }
|
|
2295
|
+
const local = ['127.0.0.1', 'localhost', '[::1]'].includes(relayURL.hostname)
|
|
2296
|
+
if (!(relayURL.protocol === 'wss:' || (relayURL.protocol === 'ws:' && local)) || relayURL.username || relayURL.password || relayURL.search || relayURL.hash || !['', '/'].includes(relayURL.pathname)) {
|
|
2297
|
+
abort('Invalid --relay URL. Use wss://hostname (ws:// is allowed only for localhost).')
|
|
2298
|
+
}
|
|
2299
|
+
opts.relay = relayURL.origin
|
|
2300
|
+
}
|
|
2272
2301
|
|
|
2273
2302
|
const dir = path.resolve(flags.dir !== undefined ? flags.dir : process.cwd())
|
|
2274
2303
|
let stat
|
|
@@ -2300,12 +2329,24 @@ async function main() {
|
|
|
2300
2329
|
return await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0)))
|
|
2301
2330
|
}
|
|
2302
2331
|
|
|
2332
|
+
if (flags.pair && cfg.token && !flags['new-token'] && flags.token === undefined && opts.port === Number(cfg.port)) {
|
|
2333
|
+
const running = await probeLocal('/_ocremote/status', { authorization: `Bearer ${cfg.token}` })
|
|
2334
|
+
if (running.ok) {
|
|
2335
|
+
let status
|
|
2336
|
+
try { status = JSON.parse(running.text) } catch {}
|
|
2337
|
+
if (status?.name === NAME && status.relay === opts.relay && Number.isInteger(status.utilityPort)) {
|
|
2338
|
+
info('Your companion is already running. Preparing a fresh pairing QR…')
|
|
2339
|
+
return runSubcommand('pair', ['--utility-port', String(status.utilityPort)])
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2303
2344
|
opts.opencodeBin = locateBin(resolveOpencodeBin(flags.opencode))
|
|
2304
2345
|
if (!opts.opencodeBin) {
|
|
2305
2346
|
if (flags.pair) {
|
|
2306
2347
|
const say = (line) => process.stdout.write(`${line}\n`)
|
|
2307
2348
|
error('opencode is not installed on this computer.')
|
|
2308
|
-
say(' 1. Install it:
|
|
2349
|
+
say(' 1. Install it: npm install -g opencode-ai')
|
|
2309
2350
|
say(' 2. Sign in: opencode auth login')
|
|
2310
2351
|
say(' 3. Run again: npx ocremote --pair')
|
|
2311
2352
|
return 1
|
|
@@ -2345,6 +2386,12 @@ async function main() {
|
|
|
2345
2386
|
}
|
|
2346
2387
|
remoteToken = token
|
|
2347
2388
|
|
|
2389
|
+
if (!(await portFree(opts.port)) || !(await portFree(opts.opencodePort))) {
|
|
2390
|
+
error('A companion or another service is already running on these ports.')
|
|
2391
|
+
info('To show its QR: npx ocremote pair. For another instance, choose --port and --opencode-port.')
|
|
2392
|
+
return 1
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2348
2395
|
const nextConfig = {
|
|
2349
2396
|
version: 1,
|
|
2350
2397
|
token,
|
|
@@ -2440,6 +2487,8 @@ main()
|
|
|
2440
2487
|
if (code !== 0) process.exit(code)
|
|
2441
2488
|
})
|
|
2442
2489
|
.catch((err) => {
|
|
2443
|
-
error(
|
|
2490
|
+
error(err.message)
|
|
2491
|
+
relayClient?.stop()
|
|
2492
|
+
if (child && child.exitCode === null) child.kill('SIGTERM')
|
|
2444
2493
|
process.exit(1)
|
|
2445
2494
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ocremote",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Control opencode on your computer from the OpenCode Remote iOS app: supervisor, pairing QR, relay and tunnel transports",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"install.sh"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
16
|
+
"node": ">=22.13.0"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"opencode",
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
],
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test test
|
|
28
|
+
"test": "node --test test/*.test.mjs"
|
|
29
29
|
}
|
|
30
30
|
}
|