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.
- package/.env.example +10 -1
- package/README.md +8 -3
- package/bin/lampson.js +1 -1
- package/chat.syn +111 -7
- package/cli.syn +143 -7
- package/edge.syn +26 -0
- package/hub.tpl.syn +127 -6
- package/lampson.ps1 +43 -2
- package/lampson.sh +40 -2
- package/lib/agents.syn +27 -6
- package/lib/approvals.syn +2 -2
- package/lib/auth.syn +274 -0
- package/lib/computer.syn +2000 -0
- package/lib/computer_judge.syn +410 -0
- package/lib/fs.syn +19 -3
- package/lib/git.syn +2 -2
- package/lib/judge.syn +78 -0
- package/lib/line.syn +2 -2
- package/lib/loop.syn +36 -4
- package/lib/lsp.syn +4 -4
- package/lib/mcp.syn +20 -4
- package/lib/md.syn +11 -11
- package/lib/origin.syn +67 -0
- package/lib/permission.syn +21 -2
- package/lib/prompt.syn +9 -1
- package/lib/provider.syn +70 -20
- package/lib/schedule.syn +12 -12
- package/lib/session.syn +5 -5
- package/lib/settings.syn +65 -1
- package/lib/skills.syn +22 -6
- package/lib/tools/bash.syn +1 -1
- package/lib/tools/common.syn +1 -1
- package/lib/tools/fetch.syn +98 -71
- package/lib/tools/grep.syn +1 -1
- package/lib/tools/memo.syn +3 -3
- package/lib/tools/proc.syn +38 -20
- package/lib/tools/qr.js +26 -0
- package/lib/tools/url.syn +5 -5
- package/lib/tools.syn +23 -2
- package/lib/trace.syn +2 -2
- package/lib/tree.syn +29 -13
- package/lib/update.syn +1 -1
- package/lib/workspaces.syn +202 -36
- package/package.json +3 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/badge-96.png +0 -0
- package/public/css/code.css +48 -0
- package/public/css/hub.css +9 -0
- package/public/css/login.css +16 -0
- package/public/css/panel.css +30 -0
- package/public/css/tokens.css +1 -1
- package/public/hub.html +11 -1
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/icons-src/badge.svg +12 -0
- package/public/icons-src/icon-maskable.svg +15 -0
- package/public/icons-src/icon.svg +13 -0
- package/public/index.html +14 -3
- package/public/js/agents.js +1 -1
- package/public/js/chat.js +1 -1
- package/public/js/config.js +145 -2
- package/public/js/core.js +4 -2
- package/public/js/devices.js +80 -0
- package/public/js/editor.js +232 -0
- package/public/js/login.js +78 -0
- package/public/js/procs.js +1 -1
- package/public/js/pwa.js +5 -0
- package/public/js/tree.js +12 -4
- package/public/login.html +43 -0
- package/public/manifest.webmanifest +16 -0
- package/public/offline.html +24 -0
- package/public/sw.js +63 -0
- package/public/vendor/highlight.min.js +1343 -0
- package/public/vendor/qrcode.js +2297 -0
- package/skills/computer-use/SKILL.md +178 -0
- package/skills/lampson/SKILL.md +5 -0
- 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
|