@saadnvd1/agent-os 0.3.560 → 0.3.564

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 (62) hide show
  1. package/README.md +1 -0
  2. package/app/api/devices/[id]/route.ts +3 -6
  3. package/app/api/devices/me/route.ts +2 -2
  4. package/app/api/presence/device-key/options/route.ts +36 -0
  5. package/app/api/presence/device-key/route.ts +55 -0
  6. package/app/api/presence/enroll/route.ts +7 -15
  7. package/app/api/presence/options/route.ts +20 -0
  8. package/app/api/presence/passkeys/[id]/route.ts +11 -9
  9. package/app/api/shares/[id]/route.ts +23 -0
  10. package/app/api/shares/preview/route.ts +30 -0
  11. package/app/api/shares/route.test.ts +455 -0
  12. package/app/api/shares/route.ts +101 -0
  13. package/app/api/workspaces/[id]/orchestrator/asks/[askId]/route.ts +15 -10
  14. package/app/api/workspaces/[id]/orchestrator/pause/route.ts +12 -9
  15. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +3 -3
  16. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +10 -7
  17. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +5 -5
  18. package/app/page.tsx +5 -0
  19. package/components/Chat/Artifact.tsx +17 -1
  20. package/components/Devices/PasskeysSection.tsx +4 -2
  21. package/components/Pane/MobileTabBar.tsx +19 -0
  22. package/components/Pane/PaneMenu.tsx +16 -0
  23. package/components/SessionList/SessionRowMenu.tsx +16 -0
  24. package/components/Settings/SettingsDialog.test.ts +3 -0
  25. package/components/Settings/SettingsDialog.tsx +9 -0
  26. package/components/Settings/commands.ts +9 -0
  27. package/components/Settings/entry-points.test.ts +1 -0
  28. package/components/Share/ShareDialog.tsx +336 -0
  29. package/components/Share/format.test.ts +20 -0
  30. package/components/Share/format.ts +33 -0
  31. package/components/Shares/SharedLinksPanel.tsx +147 -0
  32. package/data/shares/index.ts +2 -0
  33. package/data/shares/keys.ts +4 -0
  34. package/data/shares/queries.ts +111 -0
  35. package/lib/chat/drivers/claude.ts +5 -4
  36. package/lib/connect/control-api.ts +84 -0
  37. package/lib/db/migrations-legacy-checksums.json +90 -0
  38. package/lib/db/migrations.ts +120 -23
  39. package/lib/orchestrator/passkey-asks.ts +14 -0
  40. package/lib/orchestrator/untrusted.ts +12 -2
  41. package/lib/security/demo.ts +1 -0
  42. package/lib/security/device-key-message.ts +27 -0
  43. package/lib/security/device-keys.ts +75 -0
  44. package/lib/security/passkeys.ts +11 -4
  45. package/lib/security/presence-http.ts +49 -1
  46. package/lib/security/presence.ts +184 -0
  47. package/lib/security/soft-authenticator.ts +21 -0
  48. package/lib/session-audit/normalize.ts +1 -1
  49. package/lib/settings-url.ts +1 -0
  50. package/lib/share/connect.ts +86 -0
  51. package/lib/share/context.ts +50 -0
  52. package/lib/share/redact.ts +190 -0
  53. package/lib/share/render.tsx +161 -0
  54. package/lib/share/route.ts +33 -0
  55. package/lib/share/service.ts +159 -0
  56. package/lib/share/snapshot.ts +261 -0
  57. package/lib/share/static-markup.ts +16 -0
  58. package/lib/share/store.ts +39 -0
  59. package/lib/share/styles.ts +83 -0
  60. package/lib/sidebar/shelves.ts +10 -5
  61. package/package.json +1 -1
  62. package/stores/shareUi.ts +17 -0
@@ -20,8 +20,8 @@ const TOKEN_PATTERNS: RegExp[] = [...SECRET_TOKENS, /\b[a-fA-F0-9]{40,}\b/g];
20
20
 
21
21
  // A long base64-ish run that reads as random rather than as a path or a
22
22
  // slug: mixed case, digits, few separators.
23
- const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
24
- const looksRandom = (s: string) =>
23
+ export const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
24
+ export const looksRandom = (s: string) =>
25
25
  /[A-Z]/.test(s) &&
26
26
  /[a-z]/.test(s) &&
27
27
  /[0-9]/.test(s) &&
@@ -31,6 +31,16 @@ const looksRandom = (s: string) =>
31
31
  export const SECRET_ASSIGNMENT =
32
32
  /^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*\s*[=:]\s*).+$/gm;
33
33
 
34
+ // A URL's userinfo (https://key@host, postgres://user:pw@host) and a query
35
+ // parameter that carries a credential (?token=, &api_key=).
36
+ export const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
37
+ export const SECRET_PARAM =
38
+ /([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
39
+
40
+ // A private key block's opening line.
41
+ export const PRIVATE_KEY_BLOCK =
42
+ /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )?PRIVATE KEY-----|$)/g;
43
+
34
44
  // The password in a URL: postgres://user:secret@host.
35
45
  const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s]+@/gi;
36
46
 
@@ -65,6 +65,7 @@ export const STATIC_SIBLINGS = [
65
65
  "me",
66
66
  "items",
67
67
  "network",
68
+ "preview",
68
69
  "queue",
69
70
  "status",
70
71
  ];
@@ -0,0 +1,27 @@
1
+ // What a paired device signs to prove a person is there. Client-safe (no
2
+ // database, no node modules), so the app builds the same bytes the server
3
+ // checks. The server rebuilds it from its own record of the challenge; what
4
+ // the client says it signed is never used.
5
+
6
+ export const DEVICE_KEY_DOMAIN = "AgentOS device approval v1";
7
+
8
+ export interface DeviceKeyMessage {
9
+ challenge: string;
10
+ purpose: string;
11
+ binding: string;
12
+ deviceId: string;
13
+ }
14
+
15
+ export function deviceKeyMessage(m: DeviceKeyMessage): string {
16
+ return [
17
+ DEVICE_KEY_DOMAIN,
18
+ m.challenge,
19
+ m.purpose,
20
+ m.binding,
21
+ m.deviceId,
22
+ ].join("\n");
23
+ }
24
+
25
+ // A device key's relying-party id in the passkeys table. It can never equal
26
+ // a host name, so the browser flow never sees these keys.
27
+ export const deviceRp = (deviceId: string) => `device:${deviceId}`;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A paired device's approval key: P-256, made in the phone's Secure Enclave
3
+ * and unlocked by Face ID, Touch ID or the passcode. Only the public key is
4
+ * kept, in the passkeys table (type 'device', rp_id device:<id>).
5
+ */
6
+
7
+ import crypto from "crypto";
8
+ import { getDb } from "@/lib/db";
9
+ import type { PasskeyRow } from "./passkeys";
10
+
11
+ // The fixed prefix of a P-256 SubjectPublicKeyInfo; the 65-byte X9.63 point
12
+ // follows it.
13
+ const SPKI_P256_PREFIX = Buffer.from(
14
+ "3059301306072a8648ce3d020106082a8648ce3d030107034200",
15
+ "hex"
16
+ );
17
+
18
+ export const newDeviceKeyId = () =>
19
+ crypto.randomBytes(16).toString("base64url");
20
+
21
+ // The app sends the key as an uncompressed X9.63 point. Throws unless it's a
22
+ // real point on the curve.
23
+ export function spkiFromRawP256(raw: Buffer): Buffer {
24
+ if (raw.length !== 65 || raw[0] !== 0x04)
25
+ throw new Error("Not a P-256 public key");
26
+ const spki = Buffer.concat([SPKI_P256_PREFIX, raw]);
27
+ crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
28
+ return spki;
29
+ }
30
+
31
+ // iOS signs with ecdsaSignatureMessageX962SHA256, which is DER.
32
+ export function verifyDeviceSignature(
33
+ spki: Buffer,
34
+ message: string,
35
+ signature: Buffer
36
+ ): boolean {
37
+ try {
38
+ return crypto.verify(
39
+ "sha256",
40
+ Buffer.from(message),
41
+ { key: spki, format: "der", type: "spki", dsaEncoding: "der" },
42
+ signature
43
+ );
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function activeDeviceKey(deviceId: string): PasskeyRow | null {
50
+ return (
51
+ (getDb()
52
+ .prepare(
53
+ `SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
54
+ )
55
+ .get(deviceId) as PasskeyRow | undefined) ?? null
56
+ );
57
+ }
58
+
59
+ // Revokes every active key of this device; returns them so the caller can
60
+ // raise the "revoked" asks.
61
+ export function revokeDeviceKeys(deviceId: string): PasskeyRow[] {
62
+ const keys = getDb()
63
+ .prepare(
64
+ `SELECT * FROM passkeys WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
65
+ )
66
+ .all(deviceId) as PasskeyRow[];
67
+ if (keys.length)
68
+ getDb()
69
+ .prepare(
70
+ `UPDATE passkeys SET revoked_at = datetime('now')
71
+ WHERE type = 'device' AND device_id = ? AND revoked_at IS NULL`
72
+ )
73
+ .run(deviceId);
74
+ return keys;
75
+ }
@@ -19,12 +19,15 @@ export interface PasskeyRow {
19
19
  created_at: string;
20
20
  last_used_at: string | null;
21
21
  revoked_at: string | null;
22
+ // 'webauthn' (a browser passkey) or 'device' (a paired device's own key).
23
+ type: "webauthn" | "device";
24
+ device_id: string | null;
22
25
  }
23
26
 
24
27
  export type PasskeyView = Omit<PasskeyRow, "public_key" | "counter">;
25
28
 
26
29
  const VIEW = `id, transports, rp_id, name, registered_via, registered_from,
27
- user_agent, created_at, last_used_at, revoked_at`;
30
+ user_agent, created_at, last_used_at, revoked_at, type, device_id`;
28
31
 
29
32
  export function listPasskeys(): PasskeyView[] {
30
33
  return getDb()
@@ -66,12 +69,14 @@ export function addPasskey(p: {
66
69
  via: string;
67
70
  from: string | null;
68
71
  userAgent: string | null;
72
+ type?: "webauthn" | "device";
73
+ deviceId?: string | null;
69
74
  }): PasskeyRow {
70
75
  getDb()
71
76
  .prepare(
72
77
  `INSERT INTO passkeys (id, public_key, counter, transports, rp_id, name,
73
- registered_via, registered_from, user_agent)
74
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
78
+ registered_via, registered_from, user_agent, type, device_id)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
75
80
  )
76
81
  .run(
77
82
  p.id,
@@ -82,7 +87,9 @@ export function addPasskey(p: {
82
87
  p.name.trim().slice(0, 80) || "Passkey",
83
88
  p.via,
84
89
  p.from,
85
- p.userAgent?.slice(0, 300) ?? null
90
+ p.userAgent?.slice(0, 300) ?? null,
91
+ p.type ?? "webauthn",
92
+ p.deviceId ?? null
86
93
  );
87
94
  return getPasskey(p.id)!;
88
95
  }
@@ -5,7 +5,17 @@ import { NextResponse } from "next/server";
5
5
  import { REMOTE_HEADER } from "./auth";
6
6
  import type { Approver } from "./approver";
7
7
  import { getDevice } from "./devices";
8
- import { PresenceError } from "./presence";
8
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
9
+ import type { NextRequest } from "next/server";
10
+ import type { PasskeyRow } from "./passkeys";
11
+ import {
12
+ PresenceError,
13
+ relyingParty,
14
+ verifyDevicePresence,
15
+ verifyPresence,
16
+ type DeviceSignature,
17
+ type PresencePurpose,
18
+ } from "./presence";
9
19
 
10
20
  export function placeOf(approver: Approver, headers: Headers): string {
11
21
  if (approver.via === "loopback") return "this machine";
@@ -14,6 +24,44 @@ export function placeOf(approver: Approver, headers: Headers): string {
14
24
  return getDevice(approver.deviceId ?? "")?.name ?? "a paired device";
15
25
  }
16
26
 
27
+ // What a request sends to prove a person: a browser's passkey assertion, or
28
+ // a paired device's signature from its own key.
29
+ export interface PresenceProof {
30
+ assertion?: AuthenticationResponseJSON;
31
+ deviceSignature?: DeviceSignature;
32
+ }
33
+
34
+ // A device signature counts only from the device whose key it is; anything
35
+ // else goes through the browser passkey check as before.
36
+ export function requirePresence(
37
+ request: NextRequest,
38
+ approver: Approver,
39
+ proof: PresenceProof | null | undefined,
40
+ purpose: PresencePurpose,
41
+ binding: string
42
+ ): Promise<PasskeyRow> {
43
+ if (proof?.deviceSignature) {
44
+ if (approver.via !== "device" || !approver.deviceId)
45
+ return Promise.reject(
46
+ new PresenceError("Only a paired device can sign with its own key")
47
+ );
48
+ return Promise.resolve().then(() =>
49
+ verifyDevicePresence(
50
+ approver.deviceId!,
51
+ proof.deviceSignature,
52
+ purpose,
53
+ binding
54
+ )
55
+ );
56
+ }
57
+ return verifyPresence(
58
+ relyingParty(request.headers),
59
+ proof?.assertion,
60
+ purpose,
61
+ binding
62
+ );
63
+ }
64
+
17
65
  export function refusalResponse(error: unknown): NextResponse {
18
66
  if (error instanceof PresenceError)
19
67
  return NextResponse.json(
@@ -23,6 +23,14 @@ import {
23
23
  type RegistrationResponseJSON,
24
24
  } from "@simplewebauthn/server";
25
25
  import { getDb } from "@/lib/db";
26
+ import { deviceKeyMessage, deviceRp } from "./device-key-message";
27
+ import {
28
+ activeDeviceKey,
29
+ newDeviceKeyId,
30
+ revokeDeviceKeys,
31
+ spkiFromRawP256,
32
+ verifyDeviceSignature,
33
+ } from "./device-keys";
26
34
  import {
27
35
  activePasskeys,
28
36
  markBootstrapped,
@@ -319,3 +327,179 @@ export async function verifyRegistration(
319
327
  });
320
328
  })();
321
329
  }
330
+
331
+ // ---- A paired device's own key (Secure Enclave, Face ID) ----
332
+
333
+ export interface DeviceSignature {
334
+ keyId: string;
335
+ challenge: string;
336
+ signature: string;
337
+ }
338
+
339
+ export interface DeviceOptions {
340
+ challenge: string;
341
+ keyId: string;
342
+ message: string;
343
+ }
344
+
345
+ const newChallenge = () => crypto.randomBytes(32).toString("base64url");
346
+
347
+ // Options for a device-key assertion, or null when the device has no key.
348
+ export function deviceAssertionOptions(
349
+ deviceId: string,
350
+ purpose: PresencePurpose,
351
+ binding: string
352
+ ): DeviceOptions | null {
353
+ const key = activeDeviceKey(deviceId);
354
+ if (!key) return null;
355
+ const challenge = newChallenge();
356
+ storeChallenge(challenge, "assert", purpose, binding, deviceRp(deviceId));
357
+ return {
358
+ challenge,
359
+ keyId: key.id,
360
+ message: deviceKeyMessage({ challenge, purpose, binding, deviceId }),
361
+ };
362
+ }
363
+
364
+ // Checks a device's signature over exactly this challenge, purpose and
365
+ // binding, by that device's own active key. The message is rebuilt here
366
+ // from what the server issued.
367
+ export function verifyDevicePresence(
368
+ deviceId: string,
369
+ proof: DeviceSignature | null | undefined,
370
+ purpose: PresencePurpose,
371
+ binding: string
372
+ ): PasskeyRow {
373
+ if (!proof?.keyId || !proof.challenge || !proof.signature)
374
+ throw new PresenceError("This needs your Face ID or Touch ID");
375
+ const key = getPasskey(proof.keyId);
376
+ if (
377
+ !key ||
378
+ key.type !== "device" ||
379
+ key.revoked_at ||
380
+ key.device_id !== deviceId
381
+ )
382
+ throw new PresenceError("Unknown passkey");
383
+ // Spent before the signature is checked: one try per challenge.
384
+ takeChallenge(
385
+ proof.challenge,
386
+ "assert",
387
+ purpose,
388
+ binding,
389
+ deviceRp(deviceId)
390
+ );
391
+ const message = deviceKeyMessage({
392
+ challenge: proof.challenge,
393
+ purpose,
394
+ binding,
395
+ deviceId,
396
+ });
397
+ if (
398
+ !verifyDeviceSignature(
399
+ key.public_key,
400
+ message,
401
+ Buffer.from(proof.signature, "base64url")
402
+ )
403
+ )
404
+ throw new PresenceError("Passkey check failed");
405
+ touchPasskey(key.id, key.counter);
406
+ return key;
407
+ }
408
+
409
+ // Options for registering this device's key: a challenge the new key signs.
410
+ // Needs the same trust as a browser passkey (the free first one, or a code).
411
+ export function deviceRegistrationOptions(
412
+ deviceId: string,
413
+ enrollCode?: string
414
+ ): { challenge: string; message: string } {
415
+ const binding = registrationBinding(enrollCode);
416
+ const challenge = newChallenge();
417
+ storeChallenge(
418
+ challenge,
419
+ "register",
420
+ "register",
421
+ binding,
422
+ deviceRp(deviceId)
423
+ );
424
+ return {
425
+ challenge,
426
+ message: deviceKeyMessage({
427
+ challenge,
428
+ purpose: "register",
429
+ binding,
430
+ deviceId,
431
+ }),
432
+ };
433
+ }
434
+
435
+ export function verifyDeviceRegistration(
436
+ deviceId: string,
437
+ body: {
438
+ publicKey: string;
439
+ challenge: string;
440
+ signature: string;
441
+ name: string;
442
+ hardware?: string;
443
+ },
444
+ meta: { via: string; from: string | null; userAgent: string | null },
445
+ enrollCode?: string
446
+ ): { key: PasskeyRow; replaced: PasskeyRow[] } {
447
+ const binding = registrationBinding(enrollCode);
448
+ let spki: Buffer;
449
+ try {
450
+ spki = spkiFromRawP256(Buffer.from(body.publicKey, "base64url"));
451
+ } catch {
452
+ throw new PresenceError("Not a usable key");
453
+ }
454
+ const message = deviceKeyMessage({
455
+ challenge: body.challenge,
456
+ purpose: "register",
457
+ binding,
458
+ deviceId,
459
+ });
460
+ if (
461
+ !verifyDeviceSignature(
462
+ spki,
463
+ message,
464
+ Buffer.from(body.signature, "base64url")
465
+ )
466
+ )
467
+ throw new PresenceError("The new key couldn't sign the request");
468
+ return getDb().transaction(() => {
469
+ takeChallenge(
470
+ body.challenge,
471
+ "register",
472
+ "register",
473
+ binding,
474
+ deviceRp(deviceId)
475
+ );
476
+ if (binding === "bootstrap") {
477
+ if (passkeysBootstrapped())
478
+ throw new PresenceError(
479
+ "A passkey was added meanwhile; get a code from it"
480
+ );
481
+ markBootstrapped();
482
+ } else {
483
+ const used = getDb()
484
+ .prepare(
485
+ `UPDATE passkey_enrollments SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`
486
+ )
487
+ .run(now(), hash(enrollCode!)).changes;
488
+ if (used !== 1) throw new PresenceError("That code was already used");
489
+ }
490
+ // A reinstall makes a new key; the old one stops counting.
491
+ const replaced = revokeDeviceKeys(deviceId);
492
+ const hardware = body.hardware === "software" ? " (software key)" : "";
493
+ const key = addPasskey({
494
+ id: newDeviceKeyId(),
495
+ publicKey: spki,
496
+ counter: 0,
497
+ rpId: deviceRp(deviceId),
498
+ name: `${body.name.trim().slice(0, 60) || "Device"}${hardware}`,
499
+ type: "device",
500
+ deviceId,
501
+ ...meta,
502
+ });
503
+ return { key, replaced };
504
+ })();
505
+ }
@@ -81,3 +81,24 @@ export function softAuthenticator(rpId = "localhost") {
81
81
  },
82
82
  };
83
83
  }
84
+
85
+ // Tests only: a software stand-in for the app's Secure Enclave key. The
86
+ // public key is the uncompressed X9.63 point the app sends; sign() gives a
87
+ // DER signature, as iOS does.
88
+ export function softDeviceKey() {
89
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
90
+ namedCurve: "P-256",
91
+ });
92
+ const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
93
+ return {
94
+ publicKey: b64url(
95
+ Buffer.concat([
96
+ Buffer.from([0x04]),
97
+ Buffer.from(jwk.x, "base64url"),
98
+ Buffer.from(jwk.y, "base64url"),
99
+ ])
100
+ ),
101
+ sign: (message: string) =>
102
+ b64url(crypto.sign("sha256", Buffer.from(message), privateKey)),
103
+ };
104
+ }
@@ -9,7 +9,7 @@ import { redact } from "../orchestrator/untrusted";
9
9
  /** Tokens a text costs, by the usual 4 characters a token. An estimate. */
10
10
  export const estTokens = (chars: number) => Math.ceil(chars / 4);
11
11
 
12
- const HOME = /\/(?:Users|home)\/[^/\s"']+/g;
12
+ export const HOME = /\/(?:Users|home)\/[^/\s"']+/g;
13
13
  const WORKTREE = /~\/\.agent-os\/worktrees\/[^/\s"']+/g;
14
14
  const ORCH_DIR = /~\/\.agent-os\/orchestrators\/[^/\s"']+/g;
15
15
  const TMP = /\/(?:private\/)?(?:var\/folders|tmp)\/[^\s"']*/g;
@@ -10,6 +10,7 @@ export const SETTINGS_SECTIONS = [
10
10
  "workspaces",
11
11
  "models",
12
12
  "devices",
13
+ "shares",
13
14
  "notifications",
14
15
  "recipes",
15
16
  "schedules",
@@ -0,0 +1,86 @@
1
+ /** Connect's share API, answered in terms the dialog can act on. */
2
+
3
+ import { loadConnect } from "@/lib/connect/config";
4
+ import {
5
+ ApiError,
6
+ clean,
7
+ listShares,
8
+ revokeShare,
9
+ uploadShare,
10
+ type RemoteShare,
11
+ type ShareExpiry,
12
+ } from "@/lib/connect/control-api";
13
+
14
+ export const NOT_LINKED = "Link this machine to Connect to share a link.";
15
+
16
+ export class ShareError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly status: number
20
+ ) {
21
+ super(message);
22
+ }
23
+ }
24
+
25
+ export const linked = () => loadConnect() !== null;
26
+
27
+ function files() {
28
+ const f = loadConnect();
29
+ if (!f) throw new ShareError(NOT_LINKED, 409);
30
+ return f;
31
+ }
32
+
33
+ /** A failed call as a message that says what to do next. */
34
+ function explain(err: unknown): never {
35
+ if (err instanceof ShareError) throw err;
36
+ if (err instanceof ApiError) {
37
+ if (err.status === 413)
38
+ throw new ShareError(
39
+ `Connect refused it as too big or over your quota: ${clean(err.message, 120)}`,
40
+ 413
41
+ );
42
+ if (err.status === 429)
43
+ throw new ShareError(
44
+ "Too many shares just now. Try again in a few minutes.",
45
+ 429
46
+ );
47
+ if (err.status === 401 || err.status === 403)
48
+ throw new ShareError(
49
+ "Connect doesn't accept this machine. Link it again in Settings → Devices & access.",
50
+ 502
51
+ );
52
+ if (err.status === 404) throw new ShareError("Not found on Connect.", 404);
53
+ throw new ShareError(clean(err.message, 120), 502);
54
+ }
55
+ throw new ShareError("Couldn't reach Connect. Try again in a moment.", 502);
56
+ }
57
+
58
+ export async function createRemote(share: {
59
+ kind: "chat" | "artifact";
60
+ title: string;
61
+ html: string;
62
+ expiresIn: ShareExpiry;
63
+ }) {
64
+ const { config, machineKey } = files();
65
+ try {
66
+ return await uploadShare(config, machineKey, share);
67
+ } catch (err) {
68
+ explain(err);
69
+ }
70
+ }
71
+
72
+ export async function listRemote(): Promise<RemoteShare[]> {
73
+ const { config, machineKey } = files();
74
+ return listShares(config, machineKey);
75
+ }
76
+
77
+ /** Revokes on Connect; a share Connect no longer has counts as revoked. */
78
+ export async function revokeRemote(id: string): Promise<void> {
79
+ const { config, machineKey } = files();
80
+ try {
81
+ await revokeShare(config, machineKey, id);
82
+ } catch (err) {
83
+ if (err instanceof ApiError && err.status === 404) return;
84
+ explain(err);
85
+ }
86
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import type { ShareContext } from "./redact";
5
+
6
+ // Environment variables that hold a credential, by name.
7
+ const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)/;
8
+ const NOT_SECRET_NAME = /(SOCK|_FILE|_PATH|_DIR)$/;
9
+ const USERINFO_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@\s/]+@/i;
10
+
11
+ export function envSecrets(env: NodeJS.ProcessEnv = process.env): string[] {
12
+ const out = new Set<string>();
13
+ for (const [name, value] of Object.entries(env)) {
14
+ if (!value || value.length < 8) continue;
15
+ const named = SECRET_NAME.test(name) && !NOT_SECRET_NAME.test(name);
16
+ if (named || (name.endsWith("_URL") && USERINFO_URL.test(value)))
17
+ out.add(value);
18
+ }
19
+ return [...out];
20
+ }
21
+
22
+ /** Host and HostName entries of an ssh config, wildcards left out. */
23
+ export function sshHosts(config: string): string[] {
24
+ const out = new Set<string>();
25
+ for (const line of config.split("\n")) {
26
+ const m = line.match(/^\s*(?:Host|HostName)\s+(.+?)\s*$/i);
27
+ if (!m) continue;
28
+ for (const h of m[1].split(/\s+/))
29
+ if (!/[*?!]/.test(h) && !/^\d+\.\d+\.\d+\.\d+$/.test(h)) out.add(h);
30
+ }
31
+ return [...out];
32
+ }
33
+
34
+ /** This machine's details, read once per request. Every read fails soft. */
35
+ export function shareContext(): ShareContext {
36
+ const home = os.homedir();
37
+ let user = "";
38
+ try {
39
+ user = os.userInfo().username;
40
+ } catch {
41
+ user = process.env.USER ?? "";
42
+ }
43
+ const hostname = os.hostname();
44
+ let ssh = "";
45
+ try {
46
+ ssh = fs.readFileSync(path.join(home, ".ssh", "config"), "utf8");
47
+ } catch {}
48
+ const hosts = [hostname, hostname.split(".")[0], ...sshHosts(ssh)];
49
+ return { home, user, secrets: envSecrets(), hosts };
50
+ }