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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # oc-remote
2
2
 
3
- > **Plataformas.** El companion es Node puro (>= 20) y funciona en macOS, Linux y
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. companion
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
- - macOS con Node 22 o superior (`node --version`).
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-v1|${deviceID}|${pair.pairID}|${pair.clientID}|${ts}`
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: Date.now(),
140
- expiresAt: Date.now() + 5 * 60_000,
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
- if (this.ws && this.ws.readyState === 1) {
144
- try {
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
- return
398
- }
399
-
400
- const invite = this.relay.invites.get(msg.pair)
401
- if (!invite || Date.now() > invite.expiresAt) {
402
- this.log(`session ${this.id} rejected: unknown or expired invite`)
403
- this.close('invite_invalid')
404
- return
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
- this.mode = 'pairing'
407
- this.pairID = invite.pairID
408
- this.key = inviteSessionKey(invite.secret, this.clientNonce, this.serverNonce)
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' || typeof msg.clientPK !== '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.id)) this.active.set(session.id, new Map())
20
- return this.active.get(session.id)
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.sessionMap(session).get(id)
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.id)
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.id)
46
+ this.active.delete(session)
45
47
  }
46
48
 
47
49
  async handle(session, msg) {
48
50
  const id = msg.id
49
- if (typeof id !== 'number' || typeof msg.path !== 'string') return
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 = (msg.method || 'GET').toUpperCase()
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 buffer = Buffer.from(await res.arrayBuffer())
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
- session.sendSealed({ t: 'chunk', id, data: b64(chunk) })
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 = '1.3.3'
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(':').filter(Boolean)
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 First run: check opencode, show the pairing QR
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> Relay v2 URL (wss://relay.example) for anywhere access
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
- error(`relay registration failed: ${err.message}`)
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
- info(`pairing v2 link: ${payload.url}`)
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(`invite refreshed (previous one expired or about to): ${fresh.url}`)
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 mainPort = Number(cfg.port ?? 4190)
1977
- const candidates = []
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('hint: start it with `oc-remote --dir <project> --relay <wss://...>` or `bash companion/install.sh ...`')
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.relay = flags.relay !== undefined ? String(flags.relay) : (cfg.relay ?? null)
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 && !/^wss?:\/\//i.test(opts.relay)) abort(`invalid --relay: ${opts.relay} (expected wss://...)`)
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: curl -fsSL https://opencode.ai/install | bash')
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(`fatal: ${err.stack || err.message}`)
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": "1.3.3",
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": ">=20"
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/proxy.test.mjs"
28
+ "test": "node --test test/*.test.mjs"
29
29
  }
30
30
  }