@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/README.md +25 -50
- package/dist/agents.js +47 -240
- package/dist/commands.js +6 -6
- package/dist/index.js +145 -274
- package/dist/lacode.js +72 -15
- package/dist/pool.js +25 -2
- package/dist/tunnel.js +34 -13
- package/docs/CONTEXT.md +4 -4
- package/docs/TUNNEL.md +68 -108
- package/docs/USER-STORIES.md +17 -16
- package/package.json +1 -1
- package/presets/free-tier.json +1 -1
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
177
|
-
const result =
|
|
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
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 │
|
|
14
|
-
│ │ │
|
|
15
|
-
│ ▼ │
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
│ │
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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`
|
|
51
|
-
|
|
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" "
|
|
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
|
|
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
|
-
|
|
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
|
|
78
|
-
| `
|
|
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
|
-
##
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|