ocremote 1.3.4 → 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 +60 -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
|
@@ -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,6 +134,7 @@ export class RelayClient {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
async createInvite() {
|
|
137
|
+
await this.waitReady()
|
|
136
138
|
const now = Date.now()
|
|
137
139
|
const invite = {
|
|
138
140
|
pairID: `p_${randomBytes(6).toString('hex')}`,
|
|
@@ -140,22 +142,23 @@ export class RelayClient {
|
|
|
140
142
|
createdAt: now,
|
|
141
143
|
expiresAt: now + 15 * 60_000,
|
|
142
144
|
}
|
|
145
|
+
await this.registerInvite(invite)
|
|
146
|
+
if (!this.ready) throw new Error('Relay disconnected. Try pairing again once connected.')
|
|
143
147
|
this.invites.set(invite.pairID, invite)
|
|
144
|
-
// Keep the previous invite until it really expires: a QR already on screen
|
|
145
|
-
// must stay valid even if a refresh just happened.
|
|
146
148
|
for (const [id, previous] of [...this.invites]) {
|
|
147
149
|
if (previous.expiresAt <= now) this.invites.delete(id)
|
|
148
150
|
}
|
|
149
|
-
if (this.ws && this.ws.readyState === 1) {
|
|
150
|
-
try {
|
|
151
|
-
await this.registerInvite(invite)
|
|
152
|
-
} catch (err) {
|
|
153
|
-
this.log(`could not register invite with the relay: ${err.message}`)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
151
|
return invite
|
|
157
152
|
}
|
|
158
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
|
+
|
|
159
162
|
pruneInvites() {
|
|
160
163
|
const now = Date.now()
|
|
161
164
|
for (const [id, invite] of this.invites) {
|
|
@@ -184,6 +187,7 @@ export class RelayClient {
|
|
|
184
187
|
|
|
185
188
|
stop() {
|
|
186
189
|
this.stopped = true
|
|
190
|
+
this.ready = false
|
|
187
191
|
clearTimeout(this.reconnectTimer)
|
|
188
192
|
clearInterval(this.pingTimer)
|
|
189
193
|
for (const session of this.sessions.values()) session.close('relay_stopped')
|
|
@@ -212,6 +216,7 @@ export class RelayClient {
|
|
|
212
216
|
const challenge = await this.api('/v1/challenge', {
|
|
213
217
|
body: { deviceID: this.identity.deviceID() },
|
|
214
218
|
})
|
|
219
|
+
if (this.stopped) return
|
|
215
220
|
const role = 'companion'
|
|
216
221
|
const message = `ocremote-ws-v1|${role}|${this.identity.deviceID()}||${challenge.challenge}`
|
|
217
222
|
const url = `${this.relayUrl}/v1/ws?role=${role}&device=${this.identity.deviceID()}`
|
|
@@ -241,11 +246,13 @@ export class RelayClient {
|
|
|
241
246
|
}, PING_INTERVAL)
|
|
242
247
|
})
|
|
243
248
|
ws.addEventListener('message', (event) => {
|
|
249
|
+
if (this.ws !== ws || this.stopped) return
|
|
244
250
|
this.lastMessageAt = Date.now()
|
|
245
251
|
this.handleMessage(event.data)
|
|
246
252
|
})
|
|
247
253
|
ws.addEventListener('close', () => {
|
|
248
254
|
if (this.ws !== ws) return
|
|
255
|
+
this.ready = false
|
|
249
256
|
clearInterval(this.pingTimer)
|
|
250
257
|
for (const session of this.sessions.values()) session.close('relay_disconnected')
|
|
251
258
|
this.sessions.clear()
|
|
@@ -299,6 +306,7 @@ export class RelayClient {
|
|
|
299
306
|
return
|
|
300
307
|
}
|
|
301
308
|
if (msg.t === 'session_open') {
|
|
309
|
+
this.sessions.get(msg.session)?.close('replaced')
|
|
302
310
|
const session = new RelaySession({
|
|
303
311
|
id: msg.session,
|
|
304
312
|
clientID: msg.clientID,
|
|
@@ -323,6 +331,7 @@ export class RelayClient {
|
|
|
323
331
|
}
|
|
324
332
|
if (msg.t === 'pong') return
|
|
325
333
|
if (msg.t === 'hello') {
|
|
334
|
+
this.ready = true
|
|
326
335
|
this.log(`relay hello: ${JSON.stringify(msg.device || {})}`)
|
|
327
336
|
return
|
|
328
337
|
}
|
|
@@ -359,6 +368,9 @@ export class RelaySession {
|
|
|
359
368
|
if (this.closed) return
|
|
360
369
|
this.closed = true
|
|
361
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
|
+
}
|
|
362
374
|
this.log(`session ${this.id} closed (${reason})`)
|
|
363
375
|
}
|
|
364
376
|
|
|
@@ -375,44 +387,55 @@ export class RelaySession {
|
|
|
375
387
|
}
|
|
376
388
|
|
|
377
389
|
handleOpen(payload) {
|
|
390
|
+
if (this.closed || this.mode !== 'awaiting') return
|
|
378
391
|
let msg = null
|
|
379
392
|
try {
|
|
380
393
|
msg = JSON.parse(payload.toString('utf8'))
|
|
381
394
|
} catch {
|
|
382
395
|
return
|
|
383
396
|
}
|
|
384
|
-
if (msg.t !== 'hs1' || msg.v !== PROTOCOL_VERSION) return
|
|
397
|
+
if (!msg || msg.t !== 'hs1' || msg.v !== PROTOCOL_VERSION) return
|
|
385
398
|
this.clientNonce = unb64(msg.nonce)
|
|
386
399
|
if (this.clientNonce.length !== 32) {
|
|
387
400
|
this.close('bad_nonce')
|
|
388
401
|
return
|
|
389
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
|
|
390
410
|
this.serverNonce = randomBytes(32)
|
|
391
411
|
this.sendCounter = 0
|
|
392
412
|
this.recvCounter = -1
|
|
393
413
|
this.failures = 0
|
|
394
|
-
this.sendOpen({ t: 'hs2', v: PROTOCOL_VERSION, nonce: b64(this.serverNonce) })
|
|
395
414
|
|
|
396
415
|
const pair = this.identity.pair(msg.pair)
|
|
397
416
|
if (pair) {
|
|
417
|
+
if (pair.clientPK !== msg.clientPK || pair.pairKey.length !== 32) {
|
|
418
|
+
this.close('client_key_mismatch')
|
|
419
|
+
return
|
|
420
|
+
}
|
|
398
421
|
this.mode = 'paired'
|
|
399
422
|
this.pairID = pair.pairID
|
|
400
423
|
this.clientID = pair.clientID
|
|
401
424
|
this.key = sessionKey(pair.pairKey, this.clientNonce, this.serverNonce, pair.pairID)
|
|
402
425
|
this.log(`session ${this.id} handshake ok (paired ${pair.clientID})`)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
this.
|
|
410
|
-
|
|
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)
|
|
411
435
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
this.
|
|
415
|
-
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)) })
|
|
416
439
|
}
|
|
417
440
|
|
|
418
441
|
handleSealed(payload) {
|
|
@@ -438,10 +461,12 @@ export class RelaySession {
|
|
|
438
461
|
}
|
|
439
462
|
|
|
440
463
|
handleMessage(msg) {
|
|
464
|
+
if (!msg || typeof msg !== 'object') return
|
|
441
465
|
if (msg.t === 'pair' && this.mode === 'pairing') {
|
|
442
466
|
this.completePairing(msg)
|
|
443
467
|
return
|
|
444
468
|
}
|
|
469
|
+
if (this.mode !== 'paired') return
|
|
445
470
|
if (msg.t === 'ping') {
|
|
446
471
|
this.sendSealed({ t: 'pong' })
|
|
447
472
|
return
|
|
@@ -457,23 +482,19 @@ export class RelaySession {
|
|
|
457
482
|
|
|
458
483
|
async completePairing(msg) {
|
|
459
484
|
const invite = this.relay.invites.get(this.pairID)
|
|
460
|
-
if (!invite) {
|
|
485
|
+
if (!invite || invite.claimed || Date.now() >= invite.expiresAt) {
|
|
461
486
|
this.close('invite_missing')
|
|
462
487
|
return
|
|
463
488
|
}
|
|
464
|
-
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))) {
|
|
465
491
|
this.close('bad_pair_request')
|
|
466
492
|
return
|
|
467
493
|
}
|
|
468
494
|
const pairKey = pairKeyFromInvite(invite.secret, this.clientNonce, this.serverNonce)
|
|
495
|
+
invite.claimed = true
|
|
496
|
+
this.mode = 'registering'
|
|
469
497
|
this.relay.invites.delete(this.pairID)
|
|
470
|
-
this.identity.savePairWithKey({
|
|
471
|
-
pairID: this.pairID,
|
|
472
|
-
clientID: msg.clientID,
|
|
473
|
-
clientPK: msg.clientPK,
|
|
474
|
-
name: msg.name,
|
|
475
|
-
pairKey,
|
|
476
|
-
})
|
|
477
498
|
try {
|
|
478
499
|
await this.relay.registerPair({
|
|
479
500
|
pairID: this.pairID,
|
|
@@ -481,8 +502,14 @@ export class RelaySession {
|
|
|
481
502
|
clientPK: msg.clientPK,
|
|
482
503
|
name: msg.name,
|
|
483
504
|
})
|
|
505
|
+
this.identity.savePairWithKey({
|
|
506
|
+
pairID: this.pairID, clientID: msg.clientID, clientPK: msg.clientPK, name: msg.name, pairKey,
|
|
507
|
+
})
|
|
484
508
|
} catch (err) {
|
|
485
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
|
|
486
513
|
}
|
|
487
514
|
this.mode = 'paired'
|
|
488
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
|
}
|