@lawaty/lacode 0.1.1 → 0.1.3

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/dist/lacode.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // bin/lacode
4
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { spawnSync as spawnSync2 } from "node:child_process";
5
7
 
6
8
  // src/tunnel/index.ts
7
9
  import { spawnSync } from "node:child_process";
@@ -19,6 +21,7 @@ function resolveTunnel(env = process.env) {
19
21
  const port = Number(env.OC_TUNNEL_PORT);
20
22
  return {
21
23
  user: env.OC_LOCAL_USER ?? "",
24
+ host: env.OC_LOCAL_HOST ?? "",
22
25
  port: Number.isFinite(port) && port > 0 ? port : 10022,
23
26
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
24
27
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -41,6 +44,15 @@ var safeText = (raw, fallback, max) => {
41
44
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
42
45
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
43
46
  var FIELD_SEPARATOR = "\n";
47
+ function parsePayload(raw) {
48
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
49
+ return {
50
+ title: safeTitle(title),
51
+ message: safeMessage(message),
52
+ urgency: normalizeUrgency(urgency),
53
+ icon: normalizeIcon(icon)
54
+ };
55
+ }
44
56
  var encodePayload = (request) => [
45
57
  safeTitle(request.title),
46
58
  safeMessage(request.message),
@@ -59,6 +71,14 @@ var SSH_OPTS = [
59
71
  "-o",
60
72
  "StrictHostKeyChecking=accept-new"
61
73
  ];
74
+ var CONTROL_OPTS = [
75
+ "-o",
76
+ "ControlMaster=auto",
77
+ "-o",
78
+ "ControlPersist=300",
79
+ "-o",
80
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
81
+ ];
62
82
  var run = (command2, args, timeout) => {
63
83
  try {
64
84
  const result = spawnSync(command2, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -120,19 +140,20 @@ function sendLocal(request, platform = process.platform) {
120
140
  }
121
141
  function sendRemote(request, tunnel) {
122
142
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
123
- const args = [
124
- ...SSH_OPTS,
125
- "-o",
126
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
127
- "-i",
128
- tunnel.key,
129
- "-p",
130
- String(tunnel.port),
131
- `${tunnel.user}@localhost`,
132
- "--",
133
- encodePayload(request)
134
- ];
135
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
143
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
144
+ if (tunnel.host) {
145
+ const result2 = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
150
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
151
+ }
152
+ const result = run(
153
+ "ssh",
154
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
155
+ tunnel.connectTimeoutMs + 2e3
156
+ );
136
157
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
137
158
  }
138
159
  function notify(request, env = process.env, platform = process.platform) {
@@ -158,6 +179,7 @@ function describe(env = process.env, platform = process.platform) {
158
179
  return {
159
180
  running: isRemote(env) ? "remote (over ssh)" : "local",
160
181
  user: tunnel.user || "(unset)",
182
+ host: tunnel.host || "(unset)",
161
183
  port: tunnel.port,
162
184
  key: tunnel.key,
163
185
  channel: localChannel(platform),
@@ -173,8 +195,8 @@ if (!process.features?.typescript) {
173
195
  console.error("lacode: this node cannot load TypeScript sources; node >= 22.18 is required.");
174
196
  process.exit(1);
175
197
  }
176
- const { spawnSync: spawnSync2 } = await import("node:child_process");
177
- const result = spawnSync2(
198
+ const { spawnSync: spawnSync3 } = await import("node:child_process");
199
+ const result = spawnSync3(
178
200
  process.execPath,
179
201
  ["--experimental-strip-types", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)],
180
202
  { stdio: "inherit", env: { ...process.env, LACODE_STRIP_TYPECHECKED: "1" } }
@@ -247,6 +269,41 @@ switch (command) {
247
269
  process.exit(0);
248
270
  break;
249
271
  }
272
+ case "receive": {
273
+ const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
274
+ const result = sendLocal(request);
275
+ if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
276
+ process.exit(result.ok ? 0 : 1);
277
+ break;
278
+ }
279
+ case "tunnel": {
280
+ const sub = argv.shift();
281
+ if (sub !== "setup") {
282
+ console.error("lacode tunnel: usage: lacode tunnel setup");
283
+ process.exit(2);
284
+ }
285
+ const { user, host, key } = resolveTunnel();
286
+ const pub = `${key}.pub`;
287
+ if (!existsSync(key)) {
288
+ const gen = spawnSync2("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "lacode-notify", "-f", key], { stdio: ["ignore", "ignore", "inherit"] });
289
+ if (gen.status !== 0) {
290
+ console.error("lacode tunnel setup: ssh-keygen failed; create the key by hand and re-run.");
291
+ process.exit(1);
292
+ }
293
+ console.log(`Generated ${key}`);
294
+ }
295
+ const pubkey = existsSync(pub) ? readFileSync(pub, "utf8").trim() : "(public key missing)";
296
+ const self = process.argv[1] ?? "lacode";
297
+ console.log("\nOn the REMOTE host (where opencode runs), export:");
298
+ console.log(` OC_LOCAL_USER=${user || "<desktop-user>"}`);
299
+ console.log(` OC_LOCAL_HOST=${host || "<desktop-host>"}`);
300
+ console.log(` OC_NOTIFY_KEY=${key}`);
301
+ console.log("\nAdd ONE line to the DESKTOP's ~/.ssh/authorized_keys (this is the trust boundary):");
302
+ console.log(` restrict,command="${self} receive" ${pubkey}`);
303
+ console.log("\nDirect ssh + ControlPersist: no reverse forward, no systemd unit, no port, nothing to sync.");
304
+ process.exit(0);
305
+ break;
306
+ }
250
307
  default:
251
308
  console.error(`lacode: unknown command ${JSON.stringify(safeTitle(command))}`);
252
309
  console.error(USAGE);
package/dist/pool.js CHANGED
@@ -10,12 +10,13 @@ var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
10
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
11
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
12
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
+ var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
13
14
  var MAX_WEIGHT = 100;
14
15
  var MAX_SLOTS = 4;
15
16
  var COOLDOWN_BASE_MS = 60 * 1e3;
16
17
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
18
  var STRIKE_DECAY_MS = 30 * 60 * 1e3;
18
- var BASES = ["explore-fast", "implement-fast", "context-manager"];
19
+ var BASES = ["explore", "implement", "context"];
19
20
  var findPackageRoot = (start) => {
20
21
  let dir = start;
21
22
  for (let up = 0; up < 6; up++) {
@@ -141,6 +142,7 @@ function readWeight(entry, at, model, problems) {
141
142
  }
142
143
  return weight;
143
144
  }
145
+ var UNAVAILABLE_PATTERN = /endpoint\s+is\s+unavailable/i;
144
146
  var LIMIT_PATTERNS = [
145
147
  /rate[\s_-]?limit/i,
146
148
  /usage limit/i,
@@ -148,9 +150,11 @@ var LIMIT_PATTERNS = [
148
150
  /\b429\b/,
149
151
  /quota/i,
150
152
  /freelimiterror/i,
151
- /overloaded/i
153
+ /overloaded/i,
154
+ UNAVAILABLE_PATTERN
152
155
  ];
153
156
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
157
+ var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
154
158
  var statedCooldown = (text) => {
155
159
  const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
156
160
  if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
@@ -332,6 +336,22 @@ function recordStrike(s, model, reason, stated) {
332
336
  }
333
337
  return strike;
334
338
  }
339
+ function coolUnavailable(s, model) {
340
+ if (!s.dirReady) return;
341
+ const now = Date.now();
342
+ const prior = s.cooling.get(model);
343
+ const until = now + UNAVAILABLE_COOLDOWN_MS;
344
+ if (prior && prior.until > until) return prior;
345
+ const strike = { model, until, strikes: 1, reason: "endpoint-unavailable", last: now };
346
+ s.cooling.set(model, strike);
347
+ const path = join(s.dir, limitFile(model));
348
+ try {
349
+ writeFileSync(`${path}.tmp`, JSON.stringify(strike));
350
+ renameSync(`${path}.tmp`, path);
351
+ } catch {
352
+ }
353
+ return strike;
354
+ }
335
355
  function refreshCatalog(s, serverUrl) {
336
356
  if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
337
357
  s.catalogAt = Date.now();
@@ -448,6 +468,7 @@ export {
448
468
  PRESETS_DIR,
449
469
  STUCK_IDLE_MS,
450
470
  STUCK_MIN_AGE_MS,
471
+ UNAVAILABLE_COOLDOWN_MS,
451
472
  USER_CONFIG_DIR,
452
473
  USER_MODELS_PATH,
453
474
  acquire,
@@ -455,6 +476,7 @@ export {
455
476
  bindChild,
456
477
  claimKey,
457
478
  coolProvider,
479
+ coolUnavailable,
458
480
  ensureSlots,
459
481
  ensureUserConfigDir,
460
482
  findStuck,
@@ -462,6 +484,7 @@ export {
462
484
  init,
463
485
  isAvailable,
464
486
  isLimitText,
487
+ isUnavailableText,
465
488
  noteActivity,
466
489
  parseSlots,
467
490
  pickSlot,
package/dist/tunnel.js CHANGED
@@ -14,6 +14,7 @@ function resolveTunnel(env = process.env) {
14
14
  const port = Number(env.OC_TUNNEL_PORT);
15
15
  return {
16
16
  user: env.OC_LOCAL_USER ?? "",
17
+ host: env.OC_LOCAL_HOST ?? "",
17
18
  port: Number.isFinite(port) && port > 0 ? port : 10022,
18
19
  key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
19
20
  connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
@@ -36,6 +37,15 @@ var safeText = (raw, fallback, max) => {
36
37
  var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
37
38
  var safeMessage = (raw) => safeText(raw ?? "", "", 400);
38
39
  var FIELD_SEPARATOR = "\n";
40
+ function parsePayload(raw) {
41
+ const [title, message, urgency, icon] = raw.split(FIELD_SEPARATOR);
42
+ return {
43
+ title: safeTitle(title),
44
+ message: safeMessage(message),
45
+ urgency: normalizeUrgency(urgency),
46
+ icon: normalizeIcon(icon)
47
+ };
48
+ }
39
49
  var encodePayload = (request) => [
40
50
  safeTitle(request.title),
41
51
  safeMessage(request.message),
@@ -54,6 +64,14 @@ var SSH_OPTS = [
54
64
  "-o",
55
65
  "StrictHostKeyChecking=accept-new"
56
66
  ];
67
+ var CONTROL_OPTS = [
68
+ "-o",
69
+ "ControlMaster=auto",
70
+ "-o",
71
+ "ControlPersist=300",
72
+ "-o",
73
+ `ControlPath=${join(homedir(), ".ssh", "lacode-cm-%C")}`
74
+ ];
57
75
  var run = (command, args, timeout) => {
58
76
  try {
59
77
  const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
@@ -115,19 +133,20 @@ function sendLocal(request, platform = process.platform) {
115
133
  }
116
134
  function sendRemote(request, tunnel) {
117
135
  if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
118
- const args = [
119
- ...SSH_OPTS,
120
- "-o",
121
- `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
122
- "-i",
123
- tunnel.key,
124
- "-p",
125
- String(tunnel.port),
126
- `${tunnel.user}@localhost`,
127
- "--",
128
- encodePayload(request)
129
- ];
130
- const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
136
+ const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
137
+ if (tunnel.host) {
138
+ const result2 = run(
139
+ "ssh",
140
+ [...SSH_OPTS, ...CONTROL_OPTS, ...connect, "-i", tunnel.key, `${tunnel.user}@${tunnel.host}`, "--", encodePayload(request)],
141
+ tunnel.connectTimeoutMs + 2e3
142
+ );
143
+ return { ok: result2.ok, detail: result2.ok ? void 0 : result2.stderr || result2.detail, transport: "remote" };
144
+ }
145
+ const result = run(
146
+ "ssh",
147
+ [...SSH_OPTS, ...connect, "-i", tunnel.key, "-p", String(tunnel.port), `${tunnel.user}@localhost`, "--", encodePayload(request)],
148
+ tunnel.connectTimeoutMs + 2e3
149
+ );
131
150
  return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
132
151
  }
133
152
  function notify(request, env = process.env, platform = process.platform) {
@@ -153,6 +172,7 @@ function describe(env = process.env, platform = process.platform) {
153
172
  return {
154
173
  running: isRemote(env) ? "remote (over ssh)" : "local",
155
174
  user: tunnel.user || "(unset)",
175
+ host: tunnel.host || "(unset)",
156
176
  port: tunnel.port,
157
177
  key: tunnel.key,
158
178
  channel: localChannel(platform),
@@ -172,6 +192,7 @@ export {
172
192
  normalizeIcon,
173
193
  normalizeUrgency,
174
194
  notify,
195
+ parsePayload,
175
196
  resolveTunnel,
176
197
  safeMessage,
177
198
  safeTitle,
package/docs/CONTEXT.md CHANGED
@@ -69,14 +69,14 @@ something has gone wrong.
69
69
 
70
70
  ## How it stays current
71
71
 
72
- The `context-manager` agent maintains the map. It runs in two ways: on its own,
72
+ The `context` agent maintains the map. It runs in two ways: on its own,
73
73
  after a session that changed something, and on demand through the three
74
74
  commands — both of which go through the same agent, so they behave the same way.
75
75
 
76
76
  ### Automatically
77
77
 
78
78
  After a **root** session goes idle, and only if that session edited a file
79
- inside the project, LaCode starts a `context-manager` run:
79
+ inside the project, LaCode starts a `context` run:
80
80
 
81
81
  1. It collects the paths the session touched, dropping anything generated,
82
82
  vendored or outside the project (`node_modules`, `dist`, `coverage`,
@@ -123,7 +123,7 @@ as you wrote it — the same merge-without-clobber rule the agents follow.
123
123
 
124
124
  ## One writer
125
125
 
126
- Only `context-manager` writes the map. Every other agent is told never to edit
126
+ Only `context` writes the map. Every other agent is told never to edit
127
127
  it, and to say in its final report if its work revealed something that belongs
128
128
  in there. The cartographer, in turn, is denied permission to write anything
129
129
  outside the map — not by convention, but by an `edit` rule that allows
@@ -199,7 +199,7 @@ thing to check is that permission rule, not the map's content.
199
199
  | `auto update finished without changing the map` | Nothing landed. Either nothing needed saying, or the cartographer could not write. |
200
200
  | `auto update failed: …` | The run errored. The paths it was given are queued again for the next window, so the work is not lost. |
201
201
  | `context map is not writable: …` | Your config's one-writer rule does not match the path opencode evaluates. Nothing will update until you fix it. |
202
- | `context map has more than one writer: …` | An agent other than `context-manager` would be granted the map. The offending agent and the winning rule are named. |
202
+ | `context map has more than one writer: …` | An agent other than `context` would be granted the map. The offending agent and the winning rule are named. |
203
203
 
204
204
  ## Reference
205
205
 
package/docs/TUNNEL.md CHANGED
@@ -9,23 +9,23 @@ gets it back.
9
9
 
10
10
  ```
11
11
  remote host your desktop
12
- ┌──────────────────┐ ┌────────────────────────┐
13
- │ opencode session │ │ oc-notify-receiver │
14
- │ │ │ ssh (reverse forward) │ (forced command, │
15
- │ ▼ │ ───────────────────────► │ restrict) │
16
- │ bin/lacode │ ONE argument: │ │ │
17
- │ notify │ title\nmessage\n │ ▼ │
18
- │ │ urgency\nicon │ notify-send │
12
+ ┌──────────────────┐ direct ssh ┌────────────────────────┐
13
+ │ opencode session │ (ControlPersist) │ lacode receive │
14
+ │ │ │ ───────────────────────► │ (authorized_keys │
15
+ │ ▼ │ ONE argument: │ forced command) │
16
+ │ lacode notify │ title\nmessage\n │ │ │
17
+ │ │ urgency\nicon │ ▼ │
18
+ │ │ │ notify-send / │
19
+ │ │ │ osascript │
19
20
  └──────────────────┘ └────────────────────────┘
20
21
  ```
21
22
 
22
- Two things are load-bearing here.
23
-
24
- **The reverse forward.** The remote opens an SSH connection to *your* machine
25
- and asks sshd on the desktop to listen on a port. A notification is then an ssh
26
- call to `localhost:<port>` with a dedicated key that is authorized to run
27
- exactly one command. It is not a login, it is not a shell, and it does not
28
- depend on which terminal connected first.
23
+ **Direct ssh, one warm connection.** The hop is a plain `ssh` to the desktop with
24
+ `ControlMaster=auto` + `ControlPersist`, so the first notification opens the
25
+ connection and every later one rides it. There is no reverse forward, no
26
+ listening port, no systemd unit and nothing to sync: the plugin owns the
27
+ transport, and it exists only while opencode runs — which is the only time a
28
+ notification matters.
29
29
 
30
30
  **One argument, not four.** ssh concatenates everything after the host into a
31
31
  single command string, and a forced command starts with *no* positional
@@ -43,17 +43,46 @@ the free-text fields before assembling or reading it, and `urgency` and `icon`
43
43
  come from whitelists. No field can contain the delimiter, so a message full of
44
44
  spaces, quotes or `*` survives intact.
45
45
 
46
+ ## Setting it up
47
+
48
+ Once, on the **remote host** (where opencode runs):
49
+
50
+ ```bash
51
+ lacode tunnel setup
52
+ ```
53
+
54
+ It generates the dedicated key (`~/.ssh/lacode_notify` by default) and prints the
55
+ environment to export there, and the single `authorized_keys` line to add on the
56
+ desktop:
57
+
58
+ ```
59
+ OC_LOCAL_USER=<desktop user>
60
+ OC_LOCAL_HOST=<desktop hostname>
61
+ OC_NOTIFY_KEY=~/.ssh/lacode_notify
62
+ ```
63
+
64
+ On the **desktop**, add the printed line to `~/.ssh/authorized_keys`:
65
+
66
+ ```
67
+ restrict,command="lacode receive" ssh-ed25519 AAAA… lacode-notify
68
+ ```
69
+
70
+ `restrict` does the security work: no shell, no PTY, no agent forwarding, no port
71
+ forwarding. The key can do exactly one thing — hand one notification to
72
+ `lacode receive` — and nothing else. Use the absolute path to `lacode` if it is
73
+ not on the forced command's `PATH`.
74
+
46
75
  ## Sending
47
76
 
48
77
  The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
49
78
  means remote, so it forwards; otherwise it delivers locally through whichever
50
- desktop notifier this platform has: `notify-send` everywhere else, `osascript` on
51
- macOS, with `terminal-notifier` as the macOS fallback when `osascript` is missing.
79
+ desktop notifier this platform has: `notify-send` on Linux, `osascript` on macOS,
80
+ with `terminal-notifier` as the macOS fallback when `osascript` is missing.
52
81
 
53
82
  ```bash
54
- lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
83
+ lacode notify "build finished" "4 files changed" normal dialog-information
55
84
  lacode notify "question" "which database should I use?" critical dialog-warning
56
- lacode forward web-01 -- systemctl --user status oc-tunnel
85
+ lacode forward web-01 -- systemctl --user status something
57
86
  lacode doctor
58
87
  ```
59
88
 
@@ -68,107 +97,38 @@ handler, never spliced into the script source — a session title is not trusted
68
97
  input. `lacode doctor` prints the channel that would actually run, so it is
69
98
  useful on a Mac rather than reporting only whether a Linux binary is installed.
70
99
 
71
- `lacode doctor` prints what the environment resolves to and never gates on it.
100
+ ## The desktop side: `lacode receive`
101
+
102
+ `lacode receive` is the forced command. It reads the newline payload from
103
+ `$SSH_ORIGINAL_COMMAND`, sanitizes each field again at the trust boundary, and
104
+ delivers locally through the platform channel — `notify-send` on Linux,
105
+ `osascript`/`terminal-notifier` on macOS. Because it delegates to the same local
106
+ channel as a local send, **the desktop half is not Linux-only**: a Mac desktop
107
+ receives notifications too.
108
+
109
+ It never forwards: `SSH_CLIENT` is set for a forced command as well, so it calls
110
+ the local channel directly rather than `notify()` (which would loop back).
72
111
 
73
112
  ## Configuration
74
113
 
75
114
  | Variable | Default | Meaning |
76
115
  | --- | --- | --- |
77
- | `OC_LOCAL_USER` | *(unset)* | The desktop user the tunnel authenticates as. **Required for the remote hop.** |
78
- | `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
116
+ | `OC_LOCAL_USER` | *(unset)* | The desktop user the hop authenticates as. **Required for the remote hop.** |
117
+ | `OC_LOCAL_HOST` | *(unset)* | Desktop hostname for the direct ssh hop. The default; set it and no reverse forward or port is used. |
79
118
  | `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
80
119
  | `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
81
120
  | `OC_SSH_TIMEOUT_MS` | `30000` | Ceiling on `forward`. |
121
+ | `OC_TUNNEL_PORT` | `10022` | **Legacy** reverse-forward listener port. Used only when `OC_LOCAL_HOST` is unset. |
82
122
 
83
123
  `OC_LOCAL_USER` is read rather than probed on purpose. `id -un` on a remote
84
124
  account frequently returns `root`, and a dbus path built from that reaches
85
125
  nobody's desktop. Guessing the desktop user is worse than failing.
86
126
 
87
- ## Setting the tunnel up on the desktop
88
-
89
- The receiver runs on the machine with the desktop, as an `authorized_keys`
90
- forced command restricted to that one script. `$HOME` must be the expanded
91
- absolute path — a `~` is not expanded there, and an unexpanded one produces an
92
- `authorized_keys` entry that names a file that does not exist:
93
-
94
- ```
95
- restrict,command="/home/yourname/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
96
- ```
97
-
98
- `restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
99
- port forwarding. The key can do exactly one thing and nothing else.
100
-
101
- The receiver must satisfy this contract:
102
-
103
- 1. read the payload from **`$SSH_ORIGINAL_COMMAND`**, never from `$1`..`$4`;
104
- 2. **never** `eval` it, never hand it to `sh -c`, never splice it into an
105
- argument list — it is attacker-controlled, because it is whatever the remote
106
- user typed;
107
- 3. split it on newlines into title / message / urgency / icon, each with a
108
- default — **using only constructs bash 3.2 has**, not just bash 4+;
109
- 4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
110
- supported names, both of which become command-line flags;
111
- 5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
112
- in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
113
-
114
- Point 3 is not a detail. `mapfile`/`readarray` are bash 4.0+, and where they are
115
- missing — macOS ships bash 3.2, as do some minimal Linux images — the array is
116
- simply never built, every field falls back to its default, and the desktop gets
117
- a notification titled "LaCode" with an empty body. No error is printed anywhere
118
- that anyone will read, because the receiver's stderr goes to whoever happened to
119
- open the ssh connection. A `while IFS= read -r` loop is equivalent and works
120
- everywhere; `bin/oc-notify-receiver` uses one.
121
-
122
- ### This half of the hop is Linux
123
-
124
- The receiver delivers through `notify-send`, and the forward that reaches it is
125
- a systemd `--user` unit (below). Both are Linux, so **the desktop half of this
126
- tunnel does not exist on macOS**, and that is by design rather than a gap.
127
-
128
- On a Mac you do not need it: `lacode notify` run locally picks `osascript` (or
129
- `terminal-notifier`) and shows the notification itself, with no tunnel, no
130
- `authorized_keys` entry and no systemd unit involved. The tunnel exists for the
131
- case opencode runs *somewhere else*, and a Mac is not going to be running it.
132
-
133
- If you point the receiver at a machine with no `notify-send` anyway, it exits
134
- non-zero and says so, naming `lacode notify` as the path that does work there.
135
- `bin/oc-sync` refuses the same machine for the same reason, and names each
136
- missing prerequisite instead of failing somewhere less obvious.
137
-
138
- > **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
139
- > as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
140
- > `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the
141
- > desktop-side half and is yours to install — keep it out of your shell's
142
- > `$PATH` reach and pinned in `authorized_keys`.
143
-
144
- If the two halves disagree you get a blank popup, which points nowhere. A
145
- receiver that sees a payload with no newline in it should say so on stderr
146
- rather than silently defaulting everything.
147
-
148
- ## Holding the forward open
149
-
150
- The tunnel has to exist whenever a notification is sent. Anything that survives
151
- your logout works: a `--user` systemd unit is the usual choice, one per remote
152
- host, each with its own port.
153
-
154
- ```ini
155
- # ~/.config/systemd/user/oc-tunnel@<host>.service
156
- [Service]
157
- ExecStart=/usr/bin/ssh -NT -R 10022:localhost -o ClearAllForwardings=yes -o ExitOnForwardFailure=yes \
158
- -i %h/.ssh/lacode_notify you@localhost
159
- ```
160
-
161
- Then `systemctl --user enable --now oc-tunnel@web-01`.
162
-
163
- Two details that matter:
164
-
165
- - **`-o ExitOnForwardFailure=yes`** — without it, ssh stays up with no forward
166
- and the notifications fail silently.
167
- - **`-o ClearAllForwardings=yes`** on every *other* ssh call that touches that
168
- host, including rsync's `-e`. A `RemoteForward` in `~/.ssh/config` competes
169
- for the same port, and a half-claimed listener starves the one session that
170
- needed it.
127
+ ## Legacy: the reverse forward
171
128
 
172
- Do not hold the forward open with a `RemoteForward` line in `~/.ssh/config`. It
173
- is per-connection: the port exists only while some terminal is open, and it is
174
- claimed by whichever terminal connects first.
129
+ The original design opened a reverse forward (`ssh -R`) held by a `systemd --user`
130
+ unit on the desktop, and delivered to a `notify-send`-only bash receiver
131
+ (`bin/oc-notify-receiver`). It is kept only for hosts that still set
132
+ `OC_TUNNEL_PORT` and leave `OC_LOCAL_HOST` unset: `sendRemote` falls back to an
133
+ ssh call to `localhost:<port>`. New setups should use the direct ssh above. See
134
+ the git history for the old systemd unit shape.