@hraness/ghostget 0.17.6 → 0.18.2

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 (114) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/README.md +16 -9
  3. package/dist/apple-photos-client.js +2 -1
  4. package/dist/beeper-client.js +2 -1
  5. package/dist/client.js +1 -0
  6. package/dist/imessage-direct-install-2gm3kge7.js +324 -0
  7. package/dist/index-01eeae9e.js +5 -0
  8. package/dist/index-2ymnp8xv.js +145 -0
  9. package/dist/index-en5hycxp.js +175 -0
  10. package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
  11. package/dist/index-n4szk3nw.js +50 -0
  12. package/dist/index-yq6maz71.js +1105 -0
  13. package/dist/index-z1w83f81.js +4 -0
  14. package/dist/index.js +4 -47
  15. package/dist/messaging-automation-api.js +21 -0
  16. package/dist/messaging-automation-j4274hvc.js +609 -0
  17. package/dist/messaging-native-install-8w1j36ah.js +16 -0
  18. package/dist/messaging.js +1 -0
  19. package/dist/omni-client.js +1 -0
  20. package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
  21. package/dist/whatsapp-client.js +1 -0
  22. package/docs/control-panel.md +147 -0
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +66 -7
  26. package/skills/ghostget/SKILL.md +3 -1
  27. package/skills/ghostget/references/control-panel.md +43 -0
  28. package/skills/ghostget/references/install.md +5 -5
  29. package/skills/ghostget/references/linkedin-adapter.md +222 -12
  30. package/skills/ghostget/references/platform-patterns.md +1 -1
  31. package/src/args.ts +18 -5
  32. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  33. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  34. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  35. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  36. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  39. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  40. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  41. package/src/auth.ts +35 -1
  42. package/src/beeper-client-types.ts +1 -1
  43. package/src/cli.ts +18 -0
  44. package/src/confirmed-write-platform.ts +16 -1
  45. package/src/control/account-revision.ts +16 -0
  46. package/src/control/activity.ts +104 -0
  47. package/src/control/approval-broker.ts +59 -0
  48. package/src/control/approval-client.ts +49 -0
  49. package/src/control/bundled-interfaces.ts +20 -0
  50. package/src/control/cli.ts +20 -0
  51. package/src/control/connections.ts +87 -0
  52. package/src/control/credential-helper.ts +152 -0
  53. package/src/control/helper.ts +79 -0
  54. package/src/control/interface-cli.ts +22 -0
  55. package/src/control/interface-json.ts +94 -0
  56. package/src/control/interface-schema.ts +120 -0
  57. package/src/control/interfaces.ts +438 -0
  58. package/src/control/protocol.ts +182 -0
  59. package/src/control/service.ts +104 -0
  60. package/src/control/validation.ts +103 -0
  61. package/src/control/vault.ts +105 -0
  62. package/src/control/web-gateway.ts +62 -0
  63. package/src/control/web-policy.ts +56 -0
  64. package/src/ghostget.ts +18 -4
  65. package/src/messaging-automation-api.ts +12 -0
  66. package/src/messaging-automation-descriptors.ts +29 -0
  67. package/src/messaging-automation-factory.ts +148 -0
  68. package/src/messaging-automation-server.ts +183 -0
  69. package/src/messaging-automation-types.ts +155 -0
  70. package/src/messaging-automation-validation.ts +110 -0
  71. package/src/messaging-automation.ts +356 -0
  72. package/src/messaging-runtime.ts +3 -0
  73. package/src/oauth-google.ts +11 -5
  74. package/src/omni-runtime.ts +18 -3
  75. package/src/operation-permission-store.ts +92 -0
  76. package/src/operation-permission.ts +308 -0
  77. package/src/pinned-https.ts +5 -0
  78. package/src/plugins/imessage-direct/plugin.ts +12 -1
  79. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  80. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  81. package/src/plugins/linkedin-web/plugin.ts +1 -1
  82. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  83. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  84. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  85. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  86. package/src/provider-http.ts +11 -3
  87. package/src/provider-plugin-contract-identity.ts +2 -2
  88. package/src/provider-plugin-import-analysis.ts +52 -0
  89. package/src/provider-plugin-module-analysis.ts +21 -1
  90. package/src/provider-plugin-registry.ts +4 -8
  91. package/src/provider-plugin.ts +18 -8
  92. package/src/providers/imessage-automation.ts +250 -0
  93. package/src/providers/imessage-direct-install.ts +7 -0
  94. package/src/providers/imessage-direct-runtime.ts +32 -2
  95. package/src/providers/imessage-direct.ts +13 -3
  96. package/src/providers/linkedin-contact-failure.ts +21 -0
  97. package/src/providers/linkedin-contact-platform.ts +8 -1
  98. package/src/providers/linkedin-contact-program.ts +34 -5
  99. package/src/providers/linkedin-web-contact.ts +1223 -35
  100. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  101. package/src/providers/linkedin-web-runtime.ts +1 -0
  102. package/src/providers/linkedin-web.ts +46 -2
  103. package/src/providers/messaging-native-artifacts.ts +38 -0
  104. package/src/providers/messaging-native-install.ts +75 -0
  105. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  106. package/src/providers/whatsapp-automation.ts +153 -0
  107. package/src/read-client.ts +12 -2
  108. package/src/runtime.ts +81 -9
  109. package/src/state-helper.ts +2 -0
  110. package/src/storage.ts +71 -1
  111. package/src/usage.ts +9 -1
  112. package/src/version.ts +1 -1
  113. package/src/web-session-contract-definitions.ts +1 -1
  114. package/tsconfig.json +3 -0
package/src/auth.ts CHANGED
@@ -90,6 +90,8 @@ export type GhostgetAuth =
90
90
  readonly scopes: readonly string[];
91
91
  /** The credential lifecycle and token file are owned by Ghostget. */
92
92
  readonly managed?: true;
93
+ /** An immutable local token copy owned by Ghostget; no renewal or vault link. */
94
+ readonly ownedImport?: true;
93
95
  readonly subject?: string;
94
96
  }
95
97
  | {
@@ -119,6 +121,7 @@ export type AuthInput =
119
121
  readonly tokenFile: string;
120
122
  readonly scopes: readonly string[];
121
123
  readonly managed?: true;
124
+ readonly ownedImport?: true;
122
125
  readonly subject?: string;
123
126
  }
124
127
  | {
@@ -268,6 +271,9 @@ export function createAuth(id: string, input: AuthInput): GhostgetAuth {
268
271
  throw new Error("OAuth token-file auth has an invalid provider");
269
272
  }
270
273
  if (!isSafeOAuthTokenPath(input.tokenFile)) throw new Error("OAuth token-file auth has an invalid path");
274
+ if (input.ownedImport === true && (input.managed === true || input.oauthProvider !== "x" || subject === undefined || !/^[0-9]{1,19}$/u.test(subject))) {
275
+ throw new Error("owned token imports require an exact X subject and cannot be renewable");
276
+ }
271
277
  return {
272
278
  schemaVersion: 1,
273
279
  id,
@@ -276,6 +282,7 @@ export function createAuth(id: string, input: AuthInput): GhostgetAuth {
276
282
  path: resolve(input.tokenFile),
277
283
  scopes: normalizeOAuthScopes(input.scopes),
278
284
  ...(input.managed === true ? { managed: true as const } : {}),
285
+ ...(input.ownedImport === true ? { ownedImport: true as const } : {}),
279
286
  ...(subject === undefined ? {} : { subject }),
280
287
  };
281
288
  }
@@ -399,6 +406,7 @@ export function saveAuth(
399
406
  `auth locator ${auth.id} changed concurrently before replacement`,
400
407
  );
401
408
  }
409
+ const priorCredential = replacementCredentialSnapshot(current.auth, auth, environment);
402
410
  rotateReadProjectionAuthIncarnation(auth.id, environment);
403
411
  removePrivateAuthState(auth.id, environment);
404
412
  if (!writePrivateJsonIfUnchanged(
@@ -410,6 +418,7 @@ export function saveAuth(
410
418
  `auth locator ${auth.id} changed concurrently before replacement`,
411
419
  );
412
420
  }
421
+ removeReplacedCredential(priorCredential, environment);
413
422
  return path;
414
423
  }),
415
424
  );
@@ -635,6 +644,7 @@ export function parseAuth(value: unknown): GhostgetAuth {
635
644
  if (record.kind === "oauth-token-file") {
636
645
  const expected = ["schemaVersion", "id", "kind", "provider", "path", "scopes"];
637
646
  if (record.managed !== undefined) expected.push("managed");
647
+ if (record.ownedImport !== undefined) expected.push("ownedImport");
638
648
  if (record.subject !== undefined) expected.push("subject");
639
649
  if (!exactKeys(record, expected)) throw new Error("auth record has unsupported fields");
640
650
  if (!isProviderPluginSurfaceId(record.provider)) {
@@ -646,6 +656,9 @@ export function parseAuth(value: unknown): GhostgetAuth {
646
656
  if (record.managed !== undefined && record.managed !== true) {
647
657
  throw new Error("auth record has an invalid managed OAuth lifecycle marker");
648
658
  }
659
+ if (record.ownedImport !== undefined && (record.ownedImport !== true || record.managed !== undefined || record.provider !== "x" || typeof record.subject !== "string" || !/^[0-9]{1,19}$/u.test(record.subject))) {
660
+ throw new Error("auth record has an invalid owned token import marker");
661
+ }
649
662
  const rawScopes = record.scopes;
650
663
  if (!Array.isArray(rawScopes) || !rawScopes.every((scope) => typeof scope === "string")) {
651
664
  throw new Error("auth record has invalid OAuth scopes");
@@ -666,6 +679,7 @@ export function parseAuth(value: unknown): GhostgetAuth {
666
679
  path: record.path,
667
680
  scopes,
668
681
  ...(record.managed === true ? { managed: true as const } : {}),
682
+ ...(record.ownedImport === true ? { ownedImport: true as const } : {}),
669
683
  ...(subject === undefined ? {} : { subject }),
670
684
  };
671
685
  }
@@ -967,6 +981,7 @@ export function replaceAuthIfUnchanged(
967
981
  observed === null
968
982
  || observed.contentSha256 !== current.contentSha256
969
983
  ) return Object.freeze({ replaced: false as const });
984
+ const priorCredential = replacementCredentialSnapshot(current.auth, replacement.auth, environment);
970
985
  rotateReadProjectionAuthIncarnation(current.auth.id, environment);
971
986
  removePrivateAuthState(current.auth.id, environment);
972
987
  const replaced = writePrivateJsonIfUnchanged(
@@ -975,6 +990,7 @@ export function replaceAuthIfUnchanged(
975
990
  { expectedCurrentContentSha256: current.contentSha256 },
976
991
  );
977
992
  if (!replaced) return Object.freeze({ replaced: false as const });
993
+ removeReplacedCredential(priorCredential, environment);
978
994
  return Object.freeze({ replaced: true as const, snapshot: replacement });
979
995
  });
980
996
  const linkedMutation = current.auth.kind === "linked-device-store"
@@ -1041,7 +1057,7 @@ function managedOAuthCredentialSnapshot(
1041
1057
  auth: GhostgetAuth,
1042
1058
  environment: Readonly<Record<string, string | undefined>>,
1043
1059
  ): Readonly<{ path: string; contentSha256: string }> | null {
1044
- if (auth.kind !== "oauth-token-file" || auth.managed !== true) return null;
1060
+ if (auth.kind !== "oauth-token-file" || (auth.managed !== true && auth.ownedImport !== true)) return null;
1045
1061
  const expectedDirectory = join(ghostgetStateHome(environment), "auth", "oauth-tokens");
1046
1062
  if (
1047
1063
  dirname(auth.path) !== expectedDirectory
@@ -1061,6 +1077,24 @@ function managedOAuthCredentialSnapshot(
1061
1077
  });
1062
1078
  }
1063
1079
 
1080
+ function replacementCredentialSnapshot(
1081
+ previous: GhostgetAuth,
1082
+ replacement: GhostgetAuth,
1083
+ environment: Readonly<Record<string, string | undefined>>,
1084
+ ): Readonly<{ path: string; contentSha256: string }> | null {
1085
+ if (previous.kind === "oauth-token-file" && replacement.kind === "oauth-token-file" && previous.path === replacement.path) return null;
1086
+ return managedOAuthCredentialSnapshot(previous, environment);
1087
+ }
1088
+
1089
+ function removeReplacedCredential(
1090
+ credential: Readonly<{ path: string; contentSha256: string }> | null,
1091
+ environment: Readonly<Record<string, string | undefined>>,
1092
+ ): void {
1093
+ if (credential !== null && !removePrivateStateFileIfUnchanged(credential.path, { expectedCurrentContentSha256: credential.contentSha256 }, environment)) {
1094
+ throw new Error("the account was replaced but its prior owned credential changed before cleanup");
1095
+ }
1096
+ }
1097
+
1064
1098
  export function removeAuth(id: string, environment: Readonly<Record<string, string | undefined>> = process.env): boolean {
1065
1099
  const path = authPath(id, environment);
1066
1100
  let current: AuthSnapshot | null;
@@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{
96
96
  implementation: Readonly<{
97
97
  producer: Readonly<{
98
98
  package: "@hraness/ghostget";
99
- version: "0.17.6";
99
+ version: "0.18.2";
100
100
  }>;
101
101
  officialCli: Readonly<{
102
102
  implementation: "github.com/beeper/cli";
package/src/cli.ts CHANGED
@@ -181,6 +181,24 @@ export async function runGhostgetCliProcess(
181
181
  stdout: output.stdout,
182
182
  stderr: output.stderr ?? defaultOutput.stderr,
183
183
  };
184
+ try {
185
+ const { assertGatewayCommandAllowed } = await import("./control/web-policy");
186
+ assertGatewayCommandAllowed(rawArguments, process.env);
187
+ if (rawArguments[0] === "web") {
188
+ const { runWebCommand } = await import("./control/cli");
189
+ process.exitCode = await runWebCommand(rawArguments, process.env, resolvedOutput);
190
+ return;
191
+ }
192
+ if (rawArguments[0] === "interface") {
193
+ const { runInterfaceCommand } = await import("./control/interface-cli");
194
+ process.exitCode = runInterfaceCommand(rawArguments, process.env, resolvedOutput);
195
+ return;
196
+ }
197
+ } catch {
198
+ resolvedOutput.stderr("Ghostget gateway-only policy blocks this command, or its policy state is unavailable. Review the native app.\n");
199
+ process.exitCode = 1;
200
+ return;
201
+ }
184
202
  if (isPublicGhostgetCommand(rawArguments)) {
185
203
  const knowledge = await loadKnowledgeCli();
186
204
  process.exitCode = await knowledge.main(rawArguments, resolvedOutput);
@@ -7,6 +7,7 @@ import { ConfirmedWriteFailure, confirmedWriteAttempt, type ConfirmedWritePhase
7
7
  import { redactSensitiveText } from "@hraness/kb/clip/persist";
8
8
 
9
9
  import { executeBrowserRecipe, PreservedBrowserArtifactsError, type BrowserDispatchEvent } from "./browser";
10
+ import { assertOperationPermission, checkOperationPermission, readOperationPolicy } from "./operation-permission";
10
11
  import { canonicalJson, DOM_ACTION_TRANSPORT_DISABLED_MESSAGE, expandBrowserRecipe, isLocalCliOperation, isProviderOperation, isReviewedTemplateOperation, isWebSessionOperation, manifestHash, sha256, type FileInputValue, type InputValue, type GhostgetManifest } from "./model";
11
12
 
12
13
  import { localCliContractIdentity } from "./local-cli-contracts";
@@ -154,6 +155,13 @@ export function makeConfirmedWritePlatform(kernel: ConfirmedWriteKernel, origina
154
155
  const registry = options.registry ?? providerPluginRegistry;
155
156
  const invocation = checked.invocation;
156
157
  const operation = checked.operation;
158
+ const dispatchPermissionCheck = (): void | Promise<void> => {
159
+ const permissionOptions = { environment: options.environment, registry, ...(options.signal === undefined ? {} : { signal: options.signal }) };
160
+ // Legacy native execution persists its dispatch prefix before returning a
161
+ // Promise. Managed admission may need an asynchronous lease recheck.
162
+ if (readOperationPolicy(options.environment).managed) return checkOperationPermission(invocation, permissionOptions);
163
+ assertOperationPermission(invocation, permissionOptions);
164
+ };
157
165
  if (operation.risk === "R4") throw new Error("R4 capabilities are blocked by wrench");
158
166
  if (operation.risk !== "R2" && operation.risk !== "R3") throw new Error("only R2 and R3 plans use confirmation");
159
167
  const risk = operation.risk;
@@ -340,7 +348,7 @@ export function makeConfirmedWritePlatform(kernel: ConfirmedWriteKernel, origina
340
348
  error: "execution was prepared but no durable final outcome was recorded",
341
349
  });
342
350
  let duplicateSourceClaimed = false;
343
- const persistDispatchProgress = (
351
+ const persistDispatchProgress = async (
344
352
  event:
345
353
  | BrowserDispatchEvent
346
354
  | ProviderDispatchEvent
@@ -349,6 +357,10 @@ export function makeConfirmedWritePlatform(kernel: ConfirmedWriteKernel, origina
349
357
  | ReviewedTemplateDispatchEvent,
350
358
  phase: "starting" | "verified",
351
359
  ): Promise<void> => {
360
+ if (phase === "starting") {
361
+ const permission = dispatchPermissionCheck();
362
+ if (permission !== undefined) await permission;
363
+ }
352
364
  const expectedDispatch = plannedDispatches[event.index - 1];
353
365
  const prior = durableReceipt.dispatch;
354
366
  const expectedPrior = phase === "starting"
@@ -612,6 +624,8 @@ export function makeConfirmedWritePlatform(kernel: ConfirmedWriteKernel, origina
612
624
  }, options.environment)),
613
625
  outputLimit: attempt("dispatch", () => executionOutputLimit(operation)),
614
626
  dispatch: native("dispatch", async () => {
627
+ const permission = dispatchPermissionCheck();
628
+ if (permission !== undefined) await permission;
615
629
  if (options.preflightFailure !== undefined) {
616
630
  throw options.preflightFailure;
617
631
  }
@@ -1015,6 +1029,7 @@ export function makeConfirmedWritePlatform(kernel: ConfirmedWriteKernel, origina
1015
1029
  admission: (invocation: PreparedInvocation, options: RunPreparedOptions) => attempt("admission", () => {
1016
1030
  const registry = options.registry ?? providerPluginRegistry;
1017
1031
  const checked = revalidatePreparedInvocation(invocation, registry);
1032
+ assertOperationPermission(checked.invocation, { environment: options.environment, registry });
1018
1033
  const portableIdentity = checked.invocation.portablePluginContract ?? null;
1019
1034
  const runId = options.runId ?? crypto.randomUUID();
1020
1035
  const pluginResolution = resolveCodeOwnedPluginOperation(checked.operation, registry);
@@ -0,0 +1,16 @@
1
+ import { loadAuthSnapshotIfPresent, type AuthSnapshot } from "../auth";
2
+ import { canonicalJson, sha256 } from "../canonical-json";
3
+ import { projectionAuthIdentityHash, withSettledReadProjectionAuthAdmission } from "../read-projections";
4
+
5
+ /** An account editor revision binds both metadata and the exact account lifetime. */
6
+ export function connectionAccountRevision(snapshot: AuthSnapshot, environment: Readonly<Record<string, string | undefined>> = process.env): string {
7
+ return withSettledReadProjectionAuthAdmission(snapshot.auth.id, environment, () => {
8
+ const current = loadAuthSnapshotIfPresent(snapshot.auth.id, environment);
9
+ if (current === null || current.contentSha256 !== snapshot.contentSha256) throw new Error("Account changed while its revision was being inspected.");
10
+ return sha256(canonicalJson({
11
+ schemaVersion: 1,
12
+ metadata: snapshot.contentSha256,
13
+ incarnation: projectionAuthIdentityHash(snapshot.auth.id, sha256(canonicalJson(snapshot.auth)), environment),
14
+ }));
15
+ });
16
+ }
@@ -0,0 +1,104 @@
1
+ import { Database, constants as sqlite } from "bun:sqlite";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs";
3
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { ensurePrivateStateDirectory, ghostgetStateHome, snapshotPrivateStateDirectory } from "../storage";
6
+ import { canonicalJson, sha256 } from "../canonical-json";
7
+ import type { ActivityPage, ActivityQuery, ActivityRow } from "./protocol";
8
+ import { ControlError, parseActivityQuery } from "./validation";
9
+ import type { ControlEnvironment } from "./web-policy";
10
+
11
+ type StoredRow={seq:number;id:string;started_at:string;finished_at:string|null;duration_ms:number|null;method:"GET"|"HEAD";origin:string|null;rule_id:string|null;endpoint:string|null;decision:ActivityRow["decision"];outcome:ActivityRow["outcome"];http_status:number|null;response_bytes:number;error_code:string|null};
12
+ const projection=(r:StoredRow):ActivityRow=>({id:r.id,sequence:r.seq,startedAt:r.started_at,finishedAt:r.finished_at,durationMs:r.duration_ms,method:r.method,origin:r.origin,ruleId:r.rule_id,endpoint:r.endpoint,decision:r.decision,outcome:r.outcome,httpStatus:r.http_status,responseBytes:r.response_bytes,errorCode:r.error_code});
13
+ const COLUMNS="seq,id,started_at,finished_at,duration_ms,method,origin,rule_id,endpoint,decision,outcome,http_status,response_bytes,error_code";
14
+
15
+ /** One native helper owns this store. It contains metadata only and grants no authority. */
16
+ export class ActivityStore {
17
+ private readonly db:Database;
18
+ private readonly cursorKey=randomBytes(32);
19
+ private readonly directory:string;
20
+ private readonly identity:ReturnType<typeof ensurePrivateStateDirectory>;
21
+ private readonly fileIdentity:{dev:number;ino:number};
22
+ private closed=false;
23
+ private readonly startedMonotonic=new Map<string,number>();
24
+ constructor(private readonly environment:ControlEnvironment=process.env, private readonly now:()=>number=Date.now,private readonly monotonic:()=>number=()=>performance.now()) {
25
+ this.directory=join(ghostgetStateHome(environment),"control","activity");
26
+ this.identity=ensurePrivateStateDirectory(this.directory,environment);
27
+ const path=join(this.directory,"requests.sqlite");
28
+ const fd=openSync(path,constants.O_CREAT|constants.O_RDWR|constants.O_NOFOLLOW,0o600);
29
+ try { const stat=fstatSync(fd); if(!stat.isFile() || stat.uid!==process.getuid?.() || (stat.mode&0o777)!==0o600 || stat.nlink!==1) throw new Error("unsafe activity file"); this.fileIdentity={dev:stat.dev,ino:stat.ino}; } finally {closeSync(fd);}
30
+ this.checkFiles();
31
+ this.db=new Database(path,sqlite.SQLITE_OPEN_READWRITE|sqlite.SQLITE_OPEN_NOFOLLOW);
32
+ try {
33
+ this.db.exec("PRAGMA busy_timeout=250; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; PRAGMA max_page_count=32768; PRAGMA trusted_schema=OFF;");
34
+ const version=this.db.query<{user_version:number},[]>("PRAGMA user_version").get()?.user_version;
35
+ if(version!==0&&version!==1) throw new Error("unsupported activity schema");
36
+ this.db.exec("CREATE TABLE IF NOT EXISTS requests (seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,finished_at TEXT,duration_ms INTEGER,method TEXT NOT NULL CHECK(method IN ('GET','HEAD')),origin TEXT,rule_id TEXT,endpoint TEXT,decision TEXT NOT NULL CHECK(decision IN ('allow','deny','ask')),outcome TEXT NOT NULL CHECK(outcome IN ('started','succeeded','denied','failed','cancelled','interrupted')),http_status INTEGER,response_bytes INTEGER NOT NULL DEFAULT 0,error_code TEXT); CREATE INDEX IF NOT EXISTS requests_method ON requests(method,seq); CREATE INDEX IF NOT EXISTS requests_outcome ON requests(outcome,seq); CREATE INDEX IF NOT EXISTS requests_origin ON requests(origin,seq); CREATE INDEX IF NOT EXISTS requests_time ON requests(started_at,seq); PRAGMA user_version=1;");
37
+ this.db.query("UPDATE requests SET outcome='interrupted',finished_at=?,error_code='PROCESS_INTERRUPTED' WHERE outcome='started'").run(new Date(now()).toISOString());
38
+ this.prune(); this.checkFiles();
39
+ } catch { this.db.close(); throw new ControlError("ACTIVITY_UNAVAILABLE","Local request history could not be opened safely."); }
40
+ }
41
+ private checkFiles():void {
42
+ snapshotPrivateStateDirectory(this.directory,this.environment,this.identity);
43
+ for(const name of ["requests.sqlite","requests.sqlite-journal","requests.sqlite-wal","requests.sqlite-shm"]) {
44
+ let stat:ReturnType<typeof lstatSync>; try {stat=lstatSync(join(this.directory,name));} catch(error) {if((error as NodeJS.ErrnoException).code==="ENOENT"&&name!=="requests.sqlite") continue; throw error;}
45
+ if(!stat.isFile()||stat.uid!==process.getuid?.()||(stat.mode&0o777)!==0o600||stat.nlink!==1||name.endsWith("-wal")||name.endsWith("-shm")) throw new ControlError("ACTIVITY_UNSAFE","Local request history has unsafe filesystem state.");
46
+ if(name==="requests.sqlite"&&(stat.dev!==this.fileIdentity.dev||stat.ino!==this.fileIdentity.ino)) throw new ControlError("ACTIVITY_REPLACED","Local request history changed. Restart Ghostget.");
47
+ }
48
+ }
49
+ private ready():void { if(this.closed) throw new ControlError("ACTIVITY_CLOSED","Local request history is closed."); this.checkFiles(); }
50
+ private prune():void {
51
+ // Audit retention never authorizes retries; provider dispatch journals are separate.
52
+ this.db.query("DELETE FROM requests WHERE outcome != 'started' AND (started_at < ? OR seq < COALESCE((SELECT seq FROM requests WHERE outcome != 'started' ORDER BY seq DESC LIMIT 1 OFFSET 9999),0))").run(new Date(this.now()-30*86400_000).toISOString());
53
+ }
54
+ start(row:Pick<ActivityRow,"id"|"method"|"origin"|"ruleId"|"endpoint"|"decision">):void {
55
+ this.ready();
56
+ this.db.transaction(()=>{
57
+ this.prune();
58
+ const count=this.db.query<{n:number},[]>("SELECT COUNT(*) AS n FROM requests").get()!.n;
59
+ if(count>=10_128) throw new ControlError("ACTIVITY_FULL","Request history is at capacity. Wait for active requests to finish.");
60
+ this.db.query("INSERT INTO requests(id,started_at,method,origin,rule_id,endpoint,decision,outcome) VALUES(?,?,?,?,?,?,?,'started')").run(row.id,new Date(this.now()).toISOString(),row.method,row.origin,row.ruleId,row.endpoint,row.decision);
61
+ }).immediate();
62
+ this.startedMonotonic.set(row.id,this.monotonic());
63
+ this.checkFiles();
64
+ }
65
+ finish(id:string,result:Pick<ActivityRow,"outcome"|"httpStatus"|"responseBytes"|"errorCode">):void {
66
+ this.ready();
67
+ if(result.outcome==="started") throw new Error("activity finish must be terminal");
68
+ const started=this.startedMonotonic.get(id);
69
+ const duration=started===undefined?null:Math.max(0,Math.round(this.monotonic()-started));
70
+ const r=this.db.query("UPDATE requests SET outcome=?,finished_at=?,duration_ms=?,http_status=?,response_bytes=?,error_code=? WHERE id=? AND outcome='started'").run(result.outcome,new Date(this.now()).toISOString(),duration,result.httpStatus,result.responseBytes,result.errorCode,id);
71
+ if(r.changes!==1) throw new ControlError("ACTIVITY_CONFLICT","Request history could not record this result.");
72
+ this.startedMonotonic.delete(id);
73
+ this.prune();
74
+ this.checkFiles();
75
+ }
76
+ query(raw:ActivityQuery):ActivityPage {
77
+ this.ready(); const q=parseActivityQuery(raw); const fingerprint=sha256(canonicalJson({...q,cursor:null}));
78
+ const maximum=this.db.query<{n:number},[]>("SELECT COALESCE(MAX(seq),0) AS n FROM requests").get()!.n;
79
+ let upper=maximum; let after:number|null=null;
80
+ if(q.cursor!==null) {
81
+ try {
82
+ const parts=q.cursor.split("."); if(parts.length!==2) throw new Error();
83
+ const payload=parts[0]!; const mac=Buffer.from(parts[1]!,"hex"); const expected=createHmac("sha256",this.cursorKey).update(payload).digest();
84
+ if(mac.length!==expected.length||!timingSafeEqual(mac,expected)) throw new Error();
85
+ const v=JSON.parse(Buffer.from(payload,"base64url").toString("utf8")) as {f?:unknown;u?:unknown;a?:unknown};
86
+ if(v.f!==fingerprint||!Number.isSafeInteger(v.u)||!Number.isSafeInteger(v.a)||typeof v.u!=="number"||typeof v.a!=="number"||v.u<0||v.a<1||v.a>v.u||v.u>maximum) throw new Error();
87
+ upper=v.u; after=v.a;
88
+ } catch {throw new ControlError("STALE_CURSOR","The activity view expired. Refresh to continue.");}
89
+ }
90
+ const where=["seq <= ?"]; const args:(number|string)[]=[upper];
91
+ if(q.method!=="all"){where.push("method = ?");args.push(q.method);}
92
+ if(q.outcome!=="all"){where.push("outcome = ?");args.push(q.outcome);}
93
+ if(q.origin!==null){where.push("origin = ?");args.push(q.origin);}
94
+ if(q.since!==null){where.push("started_at >= ?");args.push(q.since);}
95
+ if(q.search.trim()) {where.push("(COALESCE(origin,'') || ' ' || COALESCE(rule_id,'') || ' ' || COALESCE(endpoint,'') || ' ' || method || ' ' || outcome || ' ' || COALESCE(error_code,'')) LIKE ? ESCAPE '\\'"); args.push(`%${q.search.trim().replace(/[\\%_]/gu,"\\$&")}%`);}
96
+ const matchingCount=this.db.query<{n:number},(number|string)[]>(`SELECT COUNT(*) AS n FROM requests WHERE ${where.join(" AND ")}`).get(...args)!.n;
97
+ if(after!==null){where.push(`seq ${q.order==="newest"?"<":">"} ?`);args.push(after);}
98
+ const rows=this.db.query<StoredRow,(number|string)[]>(`SELECT ${COLUMNS} FROM requests WHERE ${where.join(" AND ")} ORDER BY seq ${q.order==="newest"?"DESC":"ASC"} LIMIT ?`).all(...args,q.limit+1);
99
+ const more=rows.length>q.limit; const selected=rows.slice(0,q.limit); let nextCursor:string|null=null;
100
+ if(more){const payload=Buffer.from(JSON.stringify({f:fingerprint,u:upper,a:selected.at(-1)!.seq})).toString("base64url");nextCursor=`${payload}.${createHmac("sha256",this.cursorKey).update(payload).digest("hex")}`;}
101
+ return {rows:selected.map(projection),nextCursor,snapshotSequence:upper,matchingCount,newerCount:this.db.query<{n:number},[number]>("SELECT COUNT(*) AS n FROM requests WHERE seq > ?").get(upper)!.n};
102
+ }
103
+ close():void {if(this.closed)return;this.closed=true;this.db.close();}
104
+ }
@@ -0,0 +1,59 @@
1
+ import type { AgentApprovalResponse, ApprovalTarget, ApprovalView, CheckedApproval } from "./protocol";
2
+ import { ControlError } from "./validation";
3
+
4
+ type Entry={readonly id:string;readonly target:ApprovalTarget;readonly checked:CheckedApproval;readonly viewBytes:number;expiresAt:number;expiresAtWall:number;status:AgentApprovalResponse["status"]};
5
+ const MAX_APPROVAL_VIEW_BYTES=512*1024;
6
+ function view(id:string,checked:CheckedApproval,expiresAtWall:number):ApprovalView{return {id,digest:checked.digest,kind:checked.kind,title:checked.title,account:checked.account,effect:checked.effect,preview:checked.preview,expiresAt:new Date(expiresAtWall).toISOString()};}
7
+ /** Only the private native control channel receives a reference to decide(). */
8
+ export class ApprovalBroker {
9
+ private readonly entries=new Map<string,Entry>();
10
+ private closed=false;
11
+ constructor(private readonly recompute:(target:ApprovalTarget)=>Promise<CheckedApproval>,private readonly now:()=>number=()=>performance.now(),private readonly recheck:(target:ApprovalTarget,checked:CheckedApproval)=>Promise<CheckedApproval>=target=>recompute(target),private readonly wallNow:()=>number=Date.now) {}
12
+ private sweep():void {for(const [id,e] of this.entries) if(this.now()>=e.expiresAt) this.entries.delete(id);}
13
+ async request(id:string,target:ApprovalTarget,expectedDigest:string):Promise<AgentApprovalResponse> {
14
+ if(this.closed)throw new ControlError("CONTROL_CLOSED","The control service is closing.");
15
+ this.sweep();
16
+ if(this.entries.has(id)) throw new ControlError("APPROVAL_REPLAY","An approval request identifier cannot be reused.");
17
+ if(this.entries.size>=128) throw new ControlError("APPROVAL_QUEUE_FULL","The approval queue is full.");
18
+ const checked=await this.recompute(target);
19
+ if(checked.digest!==expectedDigest || checked.decision!=="ask") return {protocol:"ghostget.approval/1",id,digest:expectedDigest,status:"invalid"};
20
+ // Recheck the bound after asynchronous preparation; concurrent requests cannot overfill it.
21
+ if(this.closed)throw new ControlError("CONTROL_CLOSED","The control service is closing.");
22
+ if(this.entries.size>=128 || this.entries.has(id)) throw new ControlError("APPROVAL_QUEUE_FULL","The approval queue is full.");
23
+ const now=this.now();const expiresAtWall=this.wallNow()+120_000;
24
+ const viewBytes=Buffer.byteLength(JSON.stringify(view(id,checked,expiresAtWall)))+1;
25
+ const retainedBytes=[...this.entries.values()].reduce((total,entry)=>total+entry.viewBytes,2);
26
+ if(retainedBytes+viewBytes>MAX_APPROVAL_VIEW_BYTES)throw new ControlError("APPROVAL_QUEUE_FULL","The approval queue has reached its preview-size limit. Finish an existing request before retrying.");
27
+ this.entries.set(id,{id,target,checked,viewBytes,expiresAt:now+120_000,expiresAtWall,status:"pending"});
28
+ return {protocol:"ghostget.approval/1",id,digest:expectedDigest,status:"pending"};
29
+ }
30
+ async check(id:string,digest:string):Promise<AgentApprovalResponse> {
31
+ this.sweep();const e=this.entries.get(id);
32
+ let status:AgentApprovalResponse["status"]="expired";
33
+ if(e!==undefined&&e.checked.digest===digest) {
34
+ if(e.status==="allowed"||e.status==="pending") {
35
+ const checked=e.status==="allowed" ? await this.recheck(e.target,e.checked) : await this.recompute(e.target);
36
+ if(this.closed||this.entries.get(id)!==e||this.now()>=e.expiresAt)return {protocol:"ghostget.approval/1",id,digest,status:"expired"};
37
+ if(checked.digest!==digest||checked.decision!=="ask") e.status="invalid";
38
+ }
39
+ status=e.status;
40
+ }
41
+ return {protocol:"ghostget.approval/1",id,digest,status};
42
+ }
43
+ async decide(id:string,digest:string,decision:"allow-once"|"deny"):Promise<void> {
44
+ this.sweep();const e=this.entries.get(id);
45
+ if(e===undefined||e.checked.digest!==digest||e.status!=="pending") throw new ControlError("APPROVAL_STALE","This request is no longer awaiting approval.");
46
+ if(decision==="deny") {e.status="denied";return;}
47
+ const checked=await this.recompute(e.target);
48
+ if(this.closed||this.entries.get(id)!==e||e.status!=="pending"||this.now()>=e.expiresAt||checked.digest!==digest||checked.decision!=="ask") throw new ControlError("APPROVAL_STALE","The request or its policy changed. The agent must request approval again.");
49
+ e.status="allowed";e.expiresAt=this.now()+600_000;e.expiresAtWall=this.wallNow()+600_000;
50
+ }
51
+ cancel(id:string,digest:string):AgentApprovalResponse {
52
+ const e=this.entries.get(id);if(e?.checked.digest===digest)this.entries.delete(id);
53
+ return {protocol:"ghostget.approval/1",id,digest,status:"cancelled"};
54
+ }
55
+ list():readonly ApprovalView[] {
56
+ this.sweep();return [...this.entries.values()].filter(e=>e.status==="pending").map(e=>view(e.id,e.checked,e.expiresAtWall));
57
+ }
58
+ close():void {this.closed=true;this.entries.clear();}
59
+ }
@@ -0,0 +1,49 @@
1
+ import { connect } from "node:net";
2
+ import { lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { ghostgetStateHome, snapshotPrivateStateDirectory } from "../storage";
6
+ import type { AgentApprovalResponse, ApprovalTarget } from "./protocol";
7
+ import { ControlError, digest, identifier, keys, oneOf, record } from "./validation";
8
+ import type { ControlEnvironment } from "./web-policy";
9
+
10
+ export interface ApprovalLease {readonly id:string;readonly digest:string}
11
+ export function controlSocketPath(environment:ControlEnvironment=process.env):string {return join(ghostgetStateHome(environment),"control","agent.sock");}
12
+ export async function agentRequest(payload:unknown,options:{environment:ControlEnvironment;signal?:AbortSignal;timeoutMs?:number}):Promise<unknown> {
13
+ const path=controlSocketPath(options.environment);
14
+ const data=Buffer.from(`${JSON.stringify(payload)}\n`);if(data.length>262144) throw new ControlError("REQUEST_TOO_LARGE","The agent request is too large.");
15
+ try {
16
+ snapshotPrivateStateDirectory(join(ghostgetStateHome(options.environment),"control"),options.environment);
17
+ const stat=lstatSync(path);if(!stat.isSocket()||stat.uid!==process.getuid?.()||(stat.mode&0o777)!==0o600) throw new Error();
18
+ } catch {throw new ControlError("CONTROL_APP_REQUIRED","Open the Ghostget app to use the gateway or request human approval.");}
19
+ return await new Promise((resolve,reject)=>{
20
+ const socket=connect({path});let received=Buffer.alloc(0);let settled=false;
21
+ const finish=(error:unknown,value?:unknown)=>{if(settled)return;settled=true;clearTimeout(timer);options.signal?.removeEventListener("abort",abort);socket.destroy();if(error)reject(error);else resolve(value);};
22
+ const abort=()=>finish(new ControlError("REQUEST_CANCELLED","The request was cancelled."));
23
+ const timer=setTimeout(()=>finish(new ControlError("CONTROL_TIMEOUT","The app did not respond before the request deadline.")),options.timeoutMs??5000);
24
+ options.signal?.addEventListener("abort",abort,{once:true});if(options.signal?.aborted){abort();return;}
25
+ socket.once("connect",()=>socket.write(data));
26
+ socket.on("data",chunk=>{received=Buffer.concat([received,typeof chunk === "string" ? Buffer.from(chunk) : chunk]);if(received.length>4_194_304){finish(new ControlError("INVALID_RESPONSE","The app response exceeded its limit."));return;}const end=received.indexOf(10);if(end<0)return;try{if(received.subarray(end+1).length)throw new Error();finish(null,JSON.parse(received.subarray(0,end).toString("utf8")));}catch{finish(new ControlError("INVALID_RESPONSE","The app response was invalid."));}});
27
+ socket.once("error",()=>finish(new ControlError("CONTROL_DISCONNECTED","The Ghostget app disconnected.")));
28
+ socket.once("end",()=>finish(new ControlError("CONTROL_DISCONNECTED","The Ghostget app disconnected.")));
29
+ });
30
+ }
31
+ function response(value:unknown,lease:ApprovalLease):AgentApprovalResponse {
32
+ const v=record(value);keys(v,["protocol","status","id","digest"]);
33
+ if(v.protocol!=="ghostget.approval/1"||v.id!==lease.id||v.digest!==lease.digest) throw new ControlError("INVALID_RESPONSE","The approval response did not match this request.");
34
+ return {protocol:"ghostget.approval/1",id:identifier(v.id),digest:digest(v.digest),status:oneOf(v.status,["pending","allowed","denied","expired","cancelled","invalid"])};
35
+ }
36
+ export async function requestApproval(target:ApprovalTarget,expectedDigest:string,options:{environment:ControlEnvironment;signal?:AbortSignal}):Promise<ApprovalLease> {
37
+ const lease={id:randomUUID(),digest:expectedDigest};
38
+ try {
39
+ let current=response(await agentRequest({protocol:"ghostget.approval/1",action:"request",id:lease.id,target,expectedDigest},options),lease);
40
+ const deadline=Date.now()+120_000;
41
+ while(current.status==="pending"&&Date.now()<deadline){await new Promise<void>((resolve,reject)=>{const timer=setTimeout(()=>{options.signal?.removeEventListener("abort",abort);resolve();},400);const abort=()=>{clearTimeout(timer);reject(new ControlError("REQUEST_CANCELLED","The request was cancelled."));};if(options.signal?.aborted){abort();return;}options.signal?.addEventListener("abort",abort,{once:true});});current=response(await agentRequest({protocol:"ghostget.approval/1",action:"check",...lease},options),lease);}
42
+ if(current.status!=="allowed") throw new ControlError("APPROVAL_REQUIRED","This operation was not approved. Review it in Ghostget and retry explicitly.");
43
+ return lease;
44
+ } catch(error) {await releaseApproval(lease,options).catch(()=>undefined);throw error;}
45
+ }
46
+ export async function checkApproval(lease:ApprovalLease,options:{environment:ControlEnvironment;signal?:AbortSignal}):Promise<void> {
47
+ if(response(await agentRequest({protocol:"ghostget.approval/1",action:"check",...lease},options),lease).status!=="allowed") throw new ControlError("APPROVAL_EXPIRED","The exact approval is no longer valid.");
48
+ }
49
+ export async function releaseApproval(lease:ApprovalLease,options:{environment:ControlEnvironment}):Promise<void> {await agentRequest({protocol:"ghostget.approval/1",action:"cancel",...lease},options);}
@@ -0,0 +1,20 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { join } from "node:path";
4
+ import { manifestHash, parseRuntimeManifest } from "../model";
5
+ import type { ProviderPluginRegistry } from "../provider-plugin-registry";
6
+
7
+ /** Display provenance comes from exact current bundled bytes, never absence of a receipt. */
8
+ export function bundledInterfaceDigests(registry:ProviderPluginRegistry):ReadonlyMap<string,string> {
9
+ const directory=fileURLToPath(new URL("../assets/adapters/",import.meta.url));const result=new Map<string,string>();
10
+ for(const folder of readdirSync(directory,{withFileTypes:true})) {
11
+ if(!folder.isDirectory()||! /^[a-z0-9-]+$/u.test(folder.name))continue;
12
+ for(const filename of ["wrench-adapter.json","wrench-web-adapter.json"]) {
13
+ let text:string;try{text=readFileSync(join(directory,folder.name,filename),"utf8");}catch(error){if((error as NodeJS.ErrnoException).code==="ENOENT")continue;throw error;}
14
+ const parsed=parseRuntimeManifest(JSON.parse(text) as unknown,registry);
15
+ if(!parsed.ok)continue;
16
+ result.set(parsed.value.id,manifestHash(parsed.value));
17
+ }
18
+ }
19
+ return result;
20
+ }
@@ -0,0 +1,20 @@
1
+ import { agentRequest } from "./approval-client";
2
+ import { ControlError, integer, keys, oneOf, record, string } from "./validation";
3
+ import type { ControlEnvironment } from "./web-policy";
4
+
5
+ export async function runWebCommand(args:readonly string[],environment:ControlEnvironment,output:{stdout:(text:string)=>unknown;stderr:(text:string)=>unknown},signal?:AbortSignal):Promise<number> {
6
+ try {
7
+ if(args.length===1||args[1]==="--help"){output.stdout("Usage: ghostget web request <https-url> [--method GET|HEAD]\nConfigure rules and approvals in the Ghostget native app. Responses are untrusted text.\n");return 0;}
8
+ if(args[1]!=="request"||(args.length!==3&&args.length!==5)||args.length===5&&args[3]!=="--method")throw new ControlError("INVALID_REQUEST","Use ghostget web request <https-url> [--method GET|HEAD].");
9
+ const method=args.length===5?oneOf(args[4],["GET","HEAD"] as const):"GET";
10
+ const v=record(await agentRequest({protocol:"ghostget.web/1",action:"request",method,url:string(args[2],8192)},{environment,...(signal===undefined?{}:{signal}),timeoutMs:185_000}));
11
+ if(v.ok===false){keys(v,["ok","code","message"]);throw new ControlError(string(v.code,64),string(v.message,1024));}
12
+ keys(v,["protocol","ok","id","status","contentType","bodyBase64","bytes","trusted"]);
13
+ if(v.protocol!=="ghostget.web/1"||v.ok!==true||v.trusted!==false)throw new Error("invalid gateway response");
14
+ const bytes=integer(v.bytes,0,2_000_000);const encoded=string(v.bodyBase64,2_666_668,0);const body=Buffer.from(encoded,"base64");if(body.length!==bytes||body.toString("base64")!==encoded)throw new Error("invalid body");
15
+ const text=new TextDecoder("utf-8",{fatal:true}).decode(body);
16
+ output.stdout(`${JSON.stringify({id:string(v.id,128),status:integer(v.status,200,599),contentType:string(v.contentType,128),bytes,trusted:false,body:text})}\n`);return 0;
17
+ } catch(error) {
18
+ const failure=error instanceof ControlError?{ok:false,code:error.code,message:error.message}:{ok:false,code:"WEB_REQUEST_FAILED",message:"The gateway response was unavailable or invalid."};output.stderr(`${JSON.stringify(failure)}\n`);return 1;
19
+ }
20
+ }
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createAuth, loadAuthSnapshotIfPresent, removeAuth, replaceAuthIfUnchanged, saveAuth, type AuthSnapshot, type GhostgetAuth } from "../auth";
4
+ import type { ProviderPluginRegistry } from "../provider-plugin-registry";
5
+ import { withReadProjectionAuthAdmission } from "../read-projections";
6
+ import { installManifest, loadInstalledManifestSnapshot } from "../storage";
7
+ import { parseRuntimeManifest } from "../model";
8
+ import { connectionAccountRevision } from "./account-revision";
9
+ import type { ControlData, ControlRequest } from "./protocol";
10
+ import { ControlError } from "./validation";
11
+ import type { ControlEnvironment } from "./web-policy";
12
+
13
+ const PROVIDERS=[{id:"x-web",surface:"x",title:"X · browser session",login:"https://x.com/i/flow/login"},{id:"linkedin-web",surface:"linkedin",title:"LinkedIn · browser session",login:"https://www.linkedin.com/login"},{id:"reddit-web",surface:"reddit",title:"Reddit · browser session",login:"https://www.reddit.com/login/"}] as const;
14
+ type Begin=Extract<ControlRequest,{action:"connection.begin"}>;
15
+ type Attempt={readonly id:string;readonly request:Begin;readonly current:AuthSnapshot|null;readonly expiresAt:number;readonly controller:AbortController;auth:GhostgetAuth;subject:string|null;verifying:boolean};
16
+ export const connectionProviders=PROVIDERS.map(({id,title})=>({id,title}));
17
+ export class Connections {
18
+ private readonly attempts=new Map<string,Attempt>();
19
+ constructor(private readonly environment:ControlEnvironment,private readonly registry:()=>ProviderPluginRegistry,private readonly open:(browser:"chrome"|"safari",profile:string|null,url:string)=>Promise<void>=openBrowser,private readonly now:()=>number=()=>performance.now()) {}
20
+ private sweep():void {for(const [id,a] of this.attempts)if(this.now()>=a.expiresAt){a.controller.abort();this.attempts.delete(id);}}
21
+ async begin(request:Begin):Promise<ControlData> {
22
+ this.sweep();if(this.attempts.size>=8)throw new ControlError("CONNECTION_LIMIT","Finish or cancel an existing connection first.");
23
+ const provider=PROVIDERS.find(item=>item.id===request.provider);if(provider===undefined)throw new ControlError("CONNECTION_UNSUPPORTED","Use the provider's agent instructions to connect this account.");
24
+ if(request.profile!==null && (request.browser!=="chrome" || !/^(?:Default|Profile [1-9][0-9]{0,2})$/u.test(request.profile)))throw new ControlError("PROFILE_UNSUPPORTED","Choose Default or a numbered Chrome profile.");
25
+ const current=loadAuthSnapshotIfPresent(request.id,this.environment);
26
+ if((current===null?null:connectionAccountRevision(current,this.environment))!==request.expectedRevision)throw new ControlError("ACCOUNT_CHANGED","The account changed. Refresh before reconnecting.");
27
+ const auth=createAuth(request.id,{source:request.browser,...(request.profile===null?{}:{profile:request.profile})});
28
+ const binding=this.registry().requireSessionRoute(provider.surface);
29
+ if(!binding.authKinds.includes(auth.kind)||binding.subject.probe===undefined)throw new ControlError("CONNECTION_UNSUPPORTED","This provider has no compatible sign-in verifier.");
30
+ const id=randomUUID();const attempt:Attempt={id,request,current,auth,subject:null,expiresAt:this.now()+600_000,controller:new AbortController(),verifying:false};
31
+ this.attempts.set(id,attempt);
32
+ try {await this.open(request.browser,request.profile,provider.login);}catch{this.attempts.delete(id);throw new ControlError("BROWSER_UNAVAILABLE","The selected browser could not be opened.");}
33
+ return {kind:"connection",attemptId:id,status:"awaiting-sign-in",subject:null};
34
+ }
35
+ async verify(id:string):Promise<ControlData> {
36
+ const attempt=this.get(id);if(attempt.verifying)throw new ControlError("CONNECTION_BUSY","Verification is already running.");
37
+ // A failed re-verification cannot leave an older successful proof available to commit.
38
+ attempt.subject=null;
39
+ const {subject:_previousSubject,...unverifiedAuth}=attempt.auth;attempt.auth=unverifiedAuth;
40
+ attempt.verifying=true;const timer=setTimeout(()=>attempt.controller.abort(),60_000);
41
+ try {
42
+ const provider=PROVIDERS.find(item=>item.id===attempt.request.provider)!;const binding=this.registry().requireSessionRoute(provider.surface);
43
+ const subject=await binding.subject.probe!(attempt.auth,{environment:this.environment,signal:attempt.controller.signal});
44
+ if(this.get(id)!==attempt||attempt.controller.signal.aborted||!binding.subject.matches(subject))throw new Error();
45
+ attempt.subject=subject;attempt.auth={...attempt.auth,subject};
46
+ return {kind:"connection",attemptId:id,status:"verified",subject};
47
+ } catch {this.cancel(id);throw new ControlError("SIGN_IN_UNVERIFIED","Sign-in could not be verified. Complete it in the selected browser and profile, then start a fresh connection.");}
48
+ finally {clearTimeout(timer);attempt.verifying=false;}
49
+ }
50
+ commit(id:string,subject:string):void {
51
+ const attempt=this.get(id);if(attempt.verifying||attempt.subject===null||attempt.subject!==subject||attempt.controller.signal.aborted)throw new ControlError("SIGN_IN_UNVERIFIED","Verify the exact account before connecting it.");
52
+ withReadProjectionAuthAdmission(attempt.request.id,this.environment,()=>{
53
+ const current=loadAuthSnapshotIfPresent(attempt.request.id,this.environment);
54
+ if((current===null?null:connectionAccountRevision(current,this.environment))!==attempt.request.expectedRevision)throw new ControlError("ACCOUNT_CHANGED","The account changed while sign-in was open. Start again.");
55
+ this.installBundledAdapter(attempt.request.provider);
56
+ if(attempt.current===null)saveAuth(attempt.auth,this.environment);
57
+ else if(!replaceAuthIfUnchanged(attempt.current,attempt.auth,this.environment).replaced)throw new ControlError("ACCOUNT_CHANGED","The account changed while sign-in was open. Start again.");
58
+ });
59
+ this.cancel(id);
60
+ }
61
+ disconnect(id:string,expectedRevision:string):void {
62
+ withReadProjectionAuthAdmission(id,this.environment,()=>{
63
+ const current=loadAuthSnapshotIfPresent(id,this.environment);if(current===null||connectionAccountRevision(current,this.environment)!==expectedRevision)throw new ControlError("ACCOUNT_CHANGED","The account changed. Refresh before disconnecting.");
64
+ removeAuth(id,this.environment);
65
+ });
66
+ }
67
+ cancel(id:string):void {const attempt=this.attempts.get(id);attempt?.controller.abort();this.attempts.delete(id);}
68
+ private get(id:string):Attempt {this.sweep();const attempt=this.attempts.get(id);if(attempt===undefined)throw new ControlError("CONNECTION_EXPIRED","This sign-in attempt expired. Start again.");return attempt;}
69
+ close():void {for(const id of this.attempts.keys())this.cancel(id);}
70
+ private installBundledAdapter(providerId:string):void {
71
+ const provider=PROVIDERS.find(item=>item.id===providerId)!;
72
+ const registry=this.registry();
73
+ if(registry.resolveOwnedManifest(provider.id)!==undefined)return;
74
+ const current=loadInstalledManifestSnapshot(provider.id,this.environment,registry);
75
+ if(current.availability==="present"&&current.result.ok)return;
76
+ if(current.availability!=="absent")throw new ControlError("ADAPTER_UNAVAILABLE","The existing adapter is invalid. Repair it before connecting; Ghostget will not overwrite it.");
77
+ const parsed=parseRuntimeManifest(JSON.parse(readFileSync(new URL(`../assets/adapters/${provider.surface}/wrench-web-adapter.json`,import.meta.url),"utf8")) as unknown,registry);
78
+ if(!parsed.ok||parsed.value.id!==provider.id)throw new ControlError("ADAPTER_UNAVAILABLE","The bundled provider interface is unavailable or invalid.");
79
+ installManifest(parsed.value,{force:false,environment:this.environment,registry});
80
+ }
81
+ }
82
+ async function openBrowser(browser:"chrome"|"safari",profile:string|null,url:string):Promise<void> {
83
+ if(process.platform!=="darwin")throw new Error("macOS required");
84
+ const args=browser==="safari"?["/usr/bin/open","-a","Safari",url]:profile===null?["/usr/bin/open","-a","Google Chrome",url]:["/usr/bin/open","-a","Google Chrome","--args",`--profile-directory=${profile}`,url];
85
+ const child=Bun.spawn(args,{stdin:"ignore",stdout:"ignore",stderr:"ignore",env:{PATH:"/usr/bin:/bin",HOME:process.env.HOME??""}});
86
+ const timer=setTimeout(()=>child.kill(),5000);try{if(await child.exited!==0)throw new Error("browser launch failed");}finally{clearTimeout(timer);}
87
+ }