lampson 0.2.8 → 0.2.9

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.
Files changed (78) hide show
  1. package/.env.example +10 -1
  2. package/README.md +8 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +111 -7
  5. package/cli.syn +143 -7
  6. package/edge.syn +26 -0
  7. package/hub.tpl.syn +127 -6
  8. package/lampson.ps1 +43 -2
  9. package/lampson.sh +40 -2
  10. package/lib/agents.syn +27 -6
  11. package/lib/approvals.syn +2 -2
  12. package/lib/auth.syn +274 -0
  13. package/lib/computer.syn +2000 -0
  14. package/lib/computer_judge.syn +410 -0
  15. package/lib/fs.syn +19 -3
  16. package/lib/git.syn +2 -2
  17. package/lib/judge.syn +78 -0
  18. package/lib/line.syn +2 -2
  19. package/lib/loop.syn +36 -4
  20. package/lib/lsp.syn +4 -4
  21. package/lib/mcp.syn +20 -4
  22. package/lib/md.syn +11 -11
  23. package/lib/origin.syn +67 -0
  24. package/lib/permission.syn +21 -2
  25. package/lib/prompt.syn +9 -1
  26. package/lib/provider.syn +70 -20
  27. package/lib/schedule.syn +12 -12
  28. package/lib/session.syn +5 -5
  29. package/lib/settings.syn +65 -1
  30. package/lib/skills.syn +22 -6
  31. package/lib/tools/bash.syn +1 -1
  32. package/lib/tools/common.syn +1 -1
  33. package/lib/tools/fetch.syn +98 -71
  34. package/lib/tools/grep.syn +1 -1
  35. package/lib/tools/memo.syn +3 -3
  36. package/lib/tools/proc.syn +38 -20
  37. package/lib/tools/qr.js +26 -0
  38. package/lib/tools/url.syn +5 -5
  39. package/lib/tools.syn +23 -2
  40. package/lib/trace.syn +2 -2
  41. package/lib/tree.syn +29 -13
  42. package/lib/update.syn +1 -1
  43. package/lib/workspaces.syn +202 -36
  44. package/package.json +3 -2
  45. package/public/apple-touch-icon.png +0 -0
  46. package/public/badge-96.png +0 -0
  47. package/public/css/code.css +48 -0
  48. package/public/css/hub.css +9 -0
  49. package/public/css/login.css +16 -0
  50. package/public/css/panel.css +30 -0
  51. package/public/css/tokens.css +1 -1
  52. package/public/hub.html +11 -1
  53. package/public/icon-192.png +0 -0
  54. package/public/icon-512.png +0 -0
  55. package/public/icon-maskable-512.png +0 -0
  56. package/public/icons-src/badge.svg +12 -0
  57. package/public/icons-src/icon-maskable.svg +15 -0
  58. package/public/icons-src/icon.svg +13 -0
  59. package/public/index.html +14 -3
  60. package/public/js/agents.js +1 -1
  61. package/public/js/chat.js +1 -1
  62. package/public/js/config.js +145 -2
  63. package/public/js/core.js +4 -2
  64. package/public/js/devices.js +80 -0
  65. package/public/js/editor.js +232 -0
  66. package/public/js/login.js +78 -0
  67. package/public/js/procs.js +1 -1
  68. package/public/js/pwa.js +5 -0
  69. package/public/js/tree.js +12 -4
  70. package/public/login.html +43 -0
  71. package/public/manifest.webmanifest +16 -0
  72. package/public/offline.html +24 -0
  73. package/public/sw.js +63 -0
  74. package/public/vendor/highlight.min.js +1343 -0
  75. package/public/vendor/qrcode.js +2297 -0
  76. package/skills/computer-use/SKILL.md +178 -0
  77. package/skills/lampson/SKILL.md +5 -0
  78. package/web.syn +67 -6
package/lib/auth.syn ADDED
@@ -0,0 +1,274 @@
1
+ -- lib/auth.syn — quién puede entrar al hub desde afuera (celular por túnel, VPS): dispositivos con passkey.
2
+ --
3
+ -- Local (esta PC) entra sin nada, como siempre; qué cuenta como local y qué origin vale lo decide lib/origin.syn.
4
+ -- Un remoto necesita la cookie de sesión de un dispositivo emparejado:
5
+ -- 1. emparejar (solo desde local): POST /api/auth/pair → código de un solo uso, 5 min, que viaja en el QR
6
+ -- (<url pública>/pair#<código> — en el fragmento, para que no quede en logs de proxies);
7
+ -- 2. el celular abre /pair, registra una passkey (webauthn_register) con el código → dispositivo + sesión;
8
+ -- 3. después, /login con la passkey (webauthn_verify, contador incluido) → sesión nueva.
9
+ -- Una passkey queda atada al dominio (rp_id): si la URL del túnel cambia, hay que emparejar de nuevo.
10
+ --
11
+ -- Archivos (.lampson/auth/, gitignored por .lampson): devices.json {devices: [...]} con la clave PÚBLICA y el
12
+ -- contador; sessions.json {<sha256(sid)>: {device, created, expires}} — el sid nunca se guarda, solo su hash.
13
+ -- Códigos y desafíos viven en state_* (memoria del hub): un reinicio los invalida, y está bien.
14
+ -- Módulo sin `require`: los declara hub.syn.
15
+
16
+ use "./origin.syn" as origin
17
+
18
+ export let DIR be ".lampson/auth"
19
+ export let COOKIE be "lampson_sid"
20
+ -- sesión renovable: vence tras SESSION_DAYS sin usarla; pase lo que pase, la passkey se pide de nuevo a los
21
+ -- SESSION_MAX_DAYS del último ingreso con passkey (la cookie vive eso mismo: el server decide antes)
22
+ export let SESSION_DAYS be 30
23
+ export let SESSION_MAX_DAYS be 90
24
+ -- renovar escribe sessions.json: como mucho una vez cada RENEW_EVERY segundos por sesión
25
+ let RENEW_EVERY be 3600
26
+ export let PAIR_TTL be 300
27
+ export let CHALLENGE_TTL be 300
28
+
29
+ -- ---- quién es: lib/origin.syn (local, origin, Host) ----
30
+
31
+ -- una cookie Secure en http://localhost la aceptan los navegadores; en otro http no
32
+ export task secure_cookie(headers)
33
+ give starts_with(origin.origin_of(headers), "https://") or origin.public_host(headers) == "localhost"
34
+
35
+ -- ---- archivos ----
36
+
37
+ task read_json(path, fallback)
38
+ try
39
+ when file_exists(path)
40
+ give json_decode(read_file(path))
41
+ recover err
42
+ give fallback
43
+ give fallback
44
+
45
+ task devices_path()
46
+ give DIR + "/devices.json"
47
+
48
+ task sessions_path()
49
+ give DIR + "/sessions.json"
50
+
51
+ export task devices()
52
+ let doc be read_json(devices_path(), {"devices": []})
53
+ give get(doc, "devices", [])
54
+
55
+ task save_devices(list)
56
+ write_file(devices_path(), json_encode({"devices": list}))
57
+
58
+ -- lo que ve la UI: sin clave pública
59
+ export task public_devices(current)
60
+ let out be []
61
+ each d in devices()
62
+ set out to append(out, {"id": d["id"], "name": d["name"], "rp_id": d["rp_id"], "created": d["created"], "last_used": get(d, "last_used", nothing), "current": d["id"] == current})
63
+ give out
64
+
65
+ export task find_device(id)
66
+ each d in devices()
67
+ when d["id"] == id
68
+ give d
69
+ give nothing
70
+
71
+ -- ---- sesiones ----
72
+
73
+ task sid_hash(sid)
74
+ give decode(sha256("lampson-session:" + sid), "hex")
75
+
76
+ task load_sessions()
77
+ give read_json(sessions_path(), {})
78
+
79
+ -- crea una sesión para el dispositivo → el sid (va SOLO en la cookie)
80
+ export task new_session(device_id)
81
+ let sid be token()
82
+ let h be sid_hash(sid)
83
+ let t be now()
84
+ let rec be {"device": device_id, "created": t, "seen": t, "hard": t + SESSION_MAX_DAYS * 86400, "expires": t + SESSION_DAYS * 86400}
85
+ let all be load_sessions()
86
+ -- de paso, barrer las vencidas
87
+ let keep be {}
88
+ each k in keys(all)
89
+ when all[k]["expires"] > t
90
+ set keep[k] to all[k]
91
+ set keep[h] to rec
92
+ write_file(sessions_path(), json_encode(keep))
93
+ state_set("auth:sess:" + h, rec)
94
+ give sid
95
+
96
+ -- usar la sesión la renueva: vence SESSION_DAYS después del último uso, nunca después de `hard`
97
+ export task renewed(rec, t)
98
+ let hard be when contains(rec, "hard") then rec["hard"] otherwise rec["created"] + SESSION_MAX_DAYS * 86400
99
+ let seen be when contains(rec, "seen") then rec["seen"] otherwise rec["created"]
100
+ when t - seen < RENEW_EVERY
101
+ give nothing
102
+ let out be merge(rec, {"seen": t, "hard": hard, "expires": min(t + SESSION_DAYS * 86400, hard)})
103
+ give out
104
+
105
+ -- la sesión de este sid (con su dispositivo todavía emparejado) o nothing. Caché en state_* para no leer el
106
+ -- archivo en cada petición (el hub proxyea todo el API y el SSE); un miss (hub reiniciado) cae al archivo.
107
+ export task session_for(sid)
108
+ when sid == nothing or text(sid) == ""
109
+ give nothing
110
+ let h be sid_hash(text(sid))
111
+ let rec be state_get("auth:sess:" + h, nothing)
112
+ when rec == nothing
113
+ set rec to get(load_sessions(), h, nothing)
114
+ when rec == nothing
115
+ give nothing
116
+ state_set("auth:sess:" + h, rec)
117
+ when rec["expires"] <= now()
118
+ drop_session(sid)
119
+ give nothing
120
+ when state_get("auth:gone:" + rec["device"], false) == true
121
+ give nothing
122
+ let nr be renewed(rec, now())
123
+ when nr != nothing
124
+ let all be load_sessions()
125
+ when contains(all, h)
126
+ set all[h] to nr
127
+ write_file(sessions_path(), json_encode(all))
128
+ state_set("auth:sess:" + h, nr)
129
+ set rec to nr
130
+ give rec
131
+
132
+ export task drop_session(sid)
133
+ let h be sid_hash(text(sid))
134
+ let all be load_sessions()
135
+ when contains(all, h)
136
+ write_file(sessions_path(), json_encode(remove(all, h)))
137
+ state_delete("auth:sess:" + h)
138
+
139
+ -- ---- URL pública (la que va en el QR: el túnel o el dominio del VPS) ----
140
+
141
+ export task public_url()
142
+ let cfg be read_json(DIR + "/config.json", {})
143
+ let u be text(get(cfg, "public_url", ""))
144
+ when u == ""
145
+ set u to env("LAMPSON_PUBLIC_URL", "")
146
+ give u
147
+
148
+ export task set_public_url(u)
149
+ let v be trim(text(u))
150
+ when v != "" and not (starts_with(v, "https://") or starts_with(v, "http://localhost"))
151
+ raise "la URL pública tiene que ser https:// (las passkeys solo andan en https o en localhost)"
152
+ when ends_with(v, "/")
153
+ set v to slice(v, 0, length(v) - 1)
154
+ write_file(DIR + "/config.json", json_encode({"public_url": v}))
155
+ give v
156
+
157
+ -- ---- emparejar ----
158
+
159
+ export task new_pairing()
160
+ let code be token()
161
+ state_set("auth:pair:" + code, {"expires": now() + PAIR_TTL})
162
+ give {"code": code, "expires_in": PAIR_TTL}
163
+
164
+ export task pairing_valid(code)
165
+ when code == nothing or text(code) == ""
166
+ give false
167
+ let p be state_get("auth:pair:" + text(code), nothing)
168
+ give p != nothing and p["expires"] > now()
169
+
170
+ -- ---- desafíos WebAuthn ----
171
+
172
+ -- {id, challenge}: challenge en base64url (32 bytes), el navegador lo decodifica a bytes
173
+ export task new_challenge(kind, extra)
174
+ let id be token()
175
+ let ch be token()
176
+ state_set("auth:chal:" + id, {"kind": kind, "challenge": ch, "expires": now() + CHALLENGE_TTL, "extra": extra})
177
+ give {"id": id, "challenge": ch}
178
+
179
+ -- de un solo uso: se borra al leerlo
180
+ export task take_challenge(id, kind)
181
+ when id == nothing or text(id) == ""
182
+ give nothing
183
+ let c be state_get("auth:chal:" + text(id), nothing)
184
+ state_delete("auth:chal:" + text(id))
185
+ when c == nothing or c["kind"] != kind or c["expires"] <= now()
186
+ give nothing
187
+ give c
188
+
189
+ -- opciones para navigator.credentials.create (los binarios en base64url; pair.js los pasa a bytes)
190
+ export task register_options(headers, device_name)
191
+ let user_id be token()
192
+ let c be new_challenge("register", {"user_id": user_id})
193
+ give {"challenge_id": c["id"], "publicKey": {"challenge": c["challenge"], "rp": {"id": origin.public_host(headers), "name": "Lampson"}, "user": {"id": user_id, "name": device_name, "displayName": "Lampson · " + device_name}, "pubKeyCredParams": [{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -8}, {"type": "public-key", "alg": -257}], "authenticatorSelection": {"residentKey": "preferred", "userVerification": "required"}, "attestation": "none", "timeout": 120000}}
194
+
195
+ -- registra la passkey (consume el código) → {device, sid} | error con el motivo
196
+ export task register(headers, code, challenge_id, name, credential)
197
+ when not pairing_valid(code)
198
+ raise "el código de emparejamiento venció o ya se usó — generá otro desde la PC"
199
+ let c be take_challenge(challenge_id, "register")
200
+ when c == nothing
201
+ raise "el desafío venció — volvé a intentar"
202
+ let reg be webauthn_register(credential, {"rp_id": origin.public_host(headers), "origin": origin.origin_of(headers), "challenge": c["challenge"], "user_verification": "required"})
203
+ when reg == nothing
204
+ raise "el navegador devolvió una passkey que no se pudo verificar"
205
+ state_delete("auth:pair:" + text(code))
206
+ let nm be trim(text(name))
207
+ when nm == ""
208
+ set nm to "dispositivo"
209
+ let dev be {"id": reg["id"], "public_key": reg["public_key"], "alg": reg["alg"], "sign_count": reg["sign_count"], "user_handle": c["extra"]["user_id"], "name": slice(nm, 0, 60), "rp_id": origin.public_host(headers), "created": now(), "last_used": now()}
210
+ let list be where(devices(), (d) => d["id"] != dev["id"])
211
+ save_devices(append(list, dev))
212
+ state_delete("auth:gone:" + dev["id"])
213
+ give {"device": dev["id"], "sid": new_session(dev["id"])}
214
+
215
+ -- opciones para navigator.credentials.get: solo las passkeys de ESTE dominio
216
+ export task login_options(headers)
217
+ let rp be origin.public_host(headers)
218
+ let creds be []
219
+ each d in devices()
220
+ when d["rp_id"] == rp
221
+ set creds to append(creds, {"type": "public-key", "id": d["id"]})
222
+ let c be new_challenge("login", {})
223
+ give {"challenge_id": c["id"], "known": length(creds), "publicKey": {"challenge": c["challenge"], "rpId": rp, "allowCredentials": creds, "userVerification": "required", "timeout": 120000}}
224
+
225
+ -- verifica la firma contra la clave y el contador guardados → {device, sid} | nothing
226
+ export task login(headers, challenge_id, credential)
227
+ let c be take_challenge(challenge_id, "login")
228
+ when c == nothing
229
+ give nothing
230
+ let dev be find_device(text(get(credential, "id", "")))
231
+ when dev == nothing or dev["rp_id"] != origin.public_host(headers)
232
+ give nothing
233
+ let stored be {"id": dev["id"], "public_key": dev["public_key"], "alg": dev["alg"], "sign_count": dev["sign_count"], "user_handle": dev["user_handle"]}
234
+ let v be webauthn_verify(credential, stored, {"rp_id": dev["rp_id"], "origin": origin.origin_of(headers), "challenge": c["challenge"], "user_verification": "required"})
235
+ when v == nothing
236
+ give nothing
237
+ let list be []
238
+ each d in devices()
239
+ when d["id"] == dev["id"]
240
+ set d["sign_count"] to v["sign_count"]
241
+ set d["last_used"] to now()
242
+ set list to append(list, d)
243
+ save_devices(list)
244
+ give {"device": dev["id"], "sid": new_session(dev["id"])}
245
+
246
+ -- quitar un dispositivo corta sus sesiones al instante (la marca en state_* gana a la caché)
247
+ export task remove_device(id)
248
+ let before be devices()
249
+ let list be where(before, (d) => d["id"] != id)
250
+ when length(list) == length(before)
251
+ give false
252
+ save_devices(list)
253
+ let all be load_sessions()
254
+ let keep be {}
255
+ each k in keys(all)
256
+ when all[k]["device"] != id
257
+ set keep[k] to all[k]
258
+ otherwise
259
+ state_delete("auth:sess:" + k)
260
+ write_file(sessions_path(), json_encode(keep))
261
+ state_set("auth:gone:" + id, true)
262
+ give true
263
+
264
+ export task rename_device(id, name)
265
+ let found be false
266
+ let list be []
267
+ each d in devices()
268
+ when d["id"] == id
269
+ set d["name"] to slice(trim(text(name)), 0, 60)
270
+ set found to true
271
+ set list to append(list, d)
272
+ when found
273
+ save_devices(list)
274
+ give found