@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.
- package/CHANGELOG.md +301 -0
- package/README.md +16 -9
- package/dist/apple-photos-client.js +2 -1
- package/dist/beeper-client.js +2 -1
- package/dist/client.js +1 -0
- package/dist/imessage-direct-install-2gm3kge7.js +324 -0
- package/dist/index-01eeae9e.js +5 -0
- package/dist/index-2ymnp8xv.js +145 -0
- package/dist/index-en5hycxp.js +175 -0
- package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
- package/dist/index-n4szk3nw.js +50 -0
- package/dist/index-yq6maz71.js +1105 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index.js +4 -47
- package/dist/messaging-automation-api.js +21 -0
- package/dist/messaging-automation-j4274hvc.js +609 -0
- package/dist/messaging-native-install-8w1j36ah.js +16 -0
- package/dist/messaging.js +1 -0
- package/dist/omni-client.js +1 -0
- package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
- package/dist/whatsapp-client.js +1 -0
- package/docs/control-panel.md +147 -0
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +66 -7
- package/skills/ghostget/SKILL.md +3 -1
- package/skills/ghostget/references/control-panel.md +43 -0
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +222 -12
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/args.ts +18 -5
- package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
- package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
- package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
- package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
- package/src/auth.ts +35 -1
- package/src/beeper-client-types.ts +1 -1
- package/src/cli.ts +18 -0
- package/src/confirmed-write-platform.ts +16 -1
- package/src/control/account-revision.ts +16 -0
- package/src/control/activity.ts +104 -0
- package/src/control/approval-broker.ts +59 -0
- package/src/control/approval-client.ts +49 -0
- package/src/control/bundled-interfaces.ts +20 -0
- package/src/control/cli.ts +20 -0
- package/src/control/connections.ts +87 -0
- package/src/control/credential-helper.ts +152 -0
- package/src/control/helper.ts +79 -0
- package/src/control/interface-cli.ts +22 -0
- package/src/control/interface-json.ts +94 -0
- package/src/control/interface-schema.ts +120 -0
- package/src/control/interfaces.ts +438 -0
- package/src/control/protocol.ts +182 -0
- package/src/control/service.ts +104 -0
- package/src/control/validation.ts +103 -0
- package/src/control/vault.ts +105 -0
- package/src/control/web-gateway.ts +62 -0
- package/src/control/web-policy.ts +56 -0
- package/src/ghostget.ts +18 -4
- package/src/messaging-automation-api.ts +12 -0
- package/src/messaging-automation-descriptors.ts +29 -0
- package/src/messaging-automation-factory.ts +148 -0
- package/src/messaging-automation-server.ts +183 -0
- package/src/messaging-automation-types.ts +155 -0
- package/src/messaging-automation-validation.ts +110 -0
- package/src/messaging-automation.ts +356 -0
- package/src/messaging-runtime.ts +3 -0
- package/src/oauth-google.ts +11 -5
- package/src/omni-runtime.ts +18 -3
- package/src/operation-permission-store.ts +92 -0
- package/src/operation-permission.ts +308 -0
- package/src/pinned-https.ts +5 -0
- package/src/plugins/imessage-direct/plugin.ts +12 -1
- package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
- package/src/plugins/linkedin-web/plugin.ts +1 -1
- package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
- package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
- package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
- package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
- package/src/provider-http.ts +11 -3
- package/src/provider-plugin-contract-identity.ts +2 -2
- package/src/provider-plugin-import-analysis.ts +52 -0
- package/src/provider-plugin-module-analysis.ts +21 -1
- package/src/provider-plugin-registry.ts +4 -8
- package/src/provider-plugin.ts +18 -8
- package/src/providers/imessage-automation.ts +250 -0
- package/src/providers/imessage-direct-install.ts +7 -0
- package/src/providers/imessage-direct-runtime.ts +32 -2
- package/src/providers/imessage-direct.ts +13 -3
- package/src/providers/linkedin-contact-failure.ts +21 -0
- package/src/providers/linkedin-contact-platform.ts +8 -1
- package/src/providers/linkedin-contact-program.ts +34 -5
- package/src/providers/linkedin-web-contact.ts +1223 -35
- package/src/providers/linkedin-web-profile-browser.ts +659 -45
- package/src/providers/linkedin-web-runtime.ts +1 -0
- package/src/providers/linkedin-web.ts +46 -2
- package/src/providers/messaging-native-artifacts.ts +38 -0
- package/src/providers/messaging-native-install.ts +75 -0
- package/src/providers/whatsapp-automation-runtime.ts +240 -0
- package/src/providers/whatsapp-automation.ts +153 -0
- package/src/read-client.ts +12 -2
- package/src/runtime.ts +81 -9
- package/src/state-helper.ts +2 -0
- package/src/storage.ts +71 -1
- package/src/usage.ts +9 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- package/tsconfig.json +3 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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.
|
|
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"&¤t.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
|
+
}
|