@hraness/ghostget 0.18.15 → 0.18.16

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Ghostget: precise web capabilities for AI agents](https://ghostget.com/og.png)](https://ghostget.com)
4
4
 
5
- [![skills.sh](https://skills.sh/b/hraness/ghostget)](https://skills.sh/hraness/ghostget)
5
+ [![skills.sh](https://skills.sh/b/hraness/ghostget)](https://www.skills.sh/hraness/ghostget/ghostget)
6
6
 
7
7
  **Give agents bounded access to pages, media, and connected accounts.**
8
8
 
@@ -79,7 +79,7 @@ The protocol and [Agent Skill](skills/ghostget/SKILL.md) describe the handoff.
79
79
 
80
80
  ## Built-in provider catalog
81
81
 
82
- This v0.18.15 source tree supports executable actions for 20 services: Beeper,
82
+ This v0.18.16 source tree supports executable actions for 20 services: Beeper,
83
83
  Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace,
84
84
  GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack,
85
85
  Threads, TikTok, Twitch, WhatsApp, X, and YouTube.
@@ -158,9 +158,9 @@ The optional npm mirror can follow later without delaying canonical delivery.
158
158
  Install the single Ghostget Agent Skill with either runner:
159
159
 
160
160
  ```sh
161
- npx skills add hraness/ghostget#v0.18.15
161
+ npx skills add hraness/ghostget#v0.18.16
162
162
  # or
163
- bunx skills add hraness/ghostget#v0.18.15
163
+ bunx skills add hraness/ghostget#v0.18.16
164
164
  ```
165
165
 
166
166
  The skill teaches Codex, Claude Code, Cursor, and other compatible coding
@@ -170,7 +170,7 @@ install the CLI if it is missing. Start a new agent session after installation.
170
170
  After the matching immutable Release exists, install this exact canonical archive:
171
171
 
172
172
  ```sh
173
- bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.15/hraness-ghostget-0.18.15.tgz
173
+ bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.16/hraness-ghostget-0.18.16.tgz
174
174
  ghostget adapter sync-bundled --json
175
175
  ghostget doctor
176
176
  ```
@@ -198,7 +198,7 @@ For that same released coordinate, install Ghostget in an agent or application
198
198
  that owns its own model, planning, tool loop, approvals, and interface:
199
199
 
200
200
  ```sh
201
- bun add https://github.com/hraness/ghostget/releases/download/v0.18.15/hraness-ghostget-0.18.15.tgz
201
+ bun add https://github.com/hraness/ghostget/releases/download/v0.18.16/hraness-ghostget-0.18.16.tgz
202
202
  ```
203
203
 
204
204
  ```ts
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  GHOSTGET_VERSION
4
- } from "./index-4tsyncva.js";
4
+ } from "./index-km75a0bm.js";
5
5
  import {
6
6
  canonicalJson,
7
7
  sha256
@@ -4,7 +4,7 @@ import {
4
4
  } from "./index-26yq8q16.js";
5
5
  import {
6
6
  GHOSTGET_VERSION
7
- } from "./index-4tsyncva.js";
7
+ } from "./index-km75a0bm.js";
8
8
  import {
9
9
  canonicalJson,
10
10
  sha256
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  ensureImsgNativeResources,
4
4
  verifyImsgNativeResources
5
- } from "./index-kaav16fd.js";
5
+ } from "./index-k2m73ke6.js";
6
6
  import {
7
7
  ensurePrivateStateDirectory,
8
8
  ghostgetStateHome
9
- } from "./index-yq6maz71.js";
9
+ } from "./index-wwtk6nnt.js";
10
10
  import"./index-gwk7rbyj.js";
11
11
  import"./index-z1w83f81.js";
12
12
 
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  ensurePrivateStateDirectory
4
- } from "./index-yq6maz71.js";
4
+ } from "./index-wwtk6nnt.js";
5
5
  import {
6
6
  __require
7
7
  } from "./index-z1w83f81.js";
@@ -162,11 +162,11 @@ async function installBundledMessagingRuntime(provider, environment = process.en
162
162
  await file.close();
163
163
  }
164
164
  if (provider === "imessage") {
165
- const { installReviewedImsgBinary } = await import("./imessage-direct-install-5nftwc6p.js");
165
+ const { installReviewedImsgBinary } = await import("./imessage-direct-install-akfrgtpc.js");
166
166
  const result = await installReviewedImsgBinary(path, environment);
167
167
  return { version: result.version, sha256: result.executableSha256, alreadyPresent: result.alreadyPresent };
168
168
  }
169
- const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-ke1dx91p.js");
169
+ const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-zp2fx46r.js");
170
170
  return await installReviewedWhatsAppAutomationBinary(path, environment);
171
171
  } finally {
172
172
  await unlink(path);
@@ -1,5 +1,5 @@
1
1
  // @bun
2
2
  // src/version.ts
3
- var GHOSTGET_VERSION = "0.18.15";
3
+ var GHOSTGET_VERSION = "0.18.16";
4
4
 
5
5
  export { GHOSTGET_VERSION };
@@ -12,12 +12,12 @@ import {
12
12
  mkdtempSync,
13
13
  openSync,
14
14
  opendirSync,
15
- readSync,
16
15
  realpathSync,
17
16
  rmSync,
18
17
  writeFileSync
19
18
  } from "fs";
20
19
  import { spawnSync } from "child_process";
20
+ import { readOwnedFileStableSync } from "@hraness/local-custody/private-paths";
21
21
  import { homedir, tmpdir } from "os";
22
22
  import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
23
23
  import { fileURLToPath } from "url";
@@ -368,21 +368,6 @@ function isWithinPath(root, candidate) {
368
368
  function hasCode(error, code) {
369
369
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
370
370
  }
371
- function readDescriptorBounded(descriptor, maximumBytes) {
372
- const chunks = [];
373
- const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1));
374
- let total = 0;
375
- for (;; ) {
376
- const remaining = maximumBytes + 1 - total;
377
- const count = readSync(descriptor, buffer, 0, Math.min(buffer.byteLength, remaining), null);
378
- if (count === 0)
379
- return Buffer.concat(chunks, total);
380
- total += count;
381
- if (total > maximumBytes)
382
- throw new Error("file grew beyond its byte bound");
383
- chunks.push(Buffer.from(buffer.subarray(0, count)));
384
- }
385
- }
386
371
  function pathInside(root, target) {
387
372
  const child = relative(root, target);
388
373
  return child === "" || !isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`);
@@ -825,25 +810,22 @@ function ownedByCurrentUser(stats) {
825
810
  return currentUid === undefined || stats.uid === (typeof stats.uid === "bigint" ? BigInt(currentUid) : currentUid);
826
811
  }
827
812
  function readStateMarker(path) {
828
- const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
829
- const nonBlocking = "O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0;
830
- const descriptor = openSync(path, constants.O_RDONLY | noFollow | nonBlocking);
813
+ let bytes;
831
814
  try {
832
- const stats = fstatSync(descriptor);
833
- if (!stats.isFile() || stats.size > 256 || !ownedByCurrentUser(stats) || (stats.mode & 63) !== 0) {
834
- throw new Error("ghostget state marker must be a private, owned regular file");
835
- }
815
+ bytes = readOwnedFileStableSync(path, 256).bytes;
816
+ } catch (error) {
817
+ throw new Error("ghostget state marker must be a private, owned regular file", { cause: error });
818
+ }
819
+ {
836
820
  const content = new TextDecoder("utf-8", {
837
821
  fatal: true,
838
822
  ignoreBOM: true
839
- }).decode(readDescriptorBounded(descriptor, 256));
823
+ }).decode(bytes);
840
824
  if (content !== stateMarkerText)
841
825
  throw new Error("ghostget state marker is malformed");
842
826
  const value = JSON.parse(content);
843
827
  if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).sort().join(",") !== "kind,schemaVersion" || !("schemaVersion" in value) || value.schemaVersion !== 1 || !("kind" in value) || value.kind !== "io-state")
844
828
  throw new Error("ghostget state marker is malformed");
845
- } finally {
846
- closeSync(descriptor);
847
829
  }
848
830
  }
849
831
  function hasGhostgetPathIdentity(path) {
@@ -8,11 +8,11 @@ import {
8
8
 
9
9
  // src/messaging-automation-api.ts
10
10
  async function createMessagingAutomationHost(providers, environment) {
11
- const { MessagingAutomationHost } = await import("./messaging-automation-58s1366z.js");
11
+ const { MessagingAutomationHost } = await import("./messaging-automation-w7n3j4sf.js");
12
12
  return new MessagingAutomationHost(providers, environment);
13
13
  }
14
14
  async function installBundledMessagingRuntime(provider, environment) {
15
- return (await import("./messaging-native-install-c90nv501.js")).installBundledMessagingRuntime(provider, environment);
15
+ return (await import("./messaging-native-install-wk10f605.js")).installBundledMessagingRuntime(provider, environment);
16
16
  }
17
17
  export {
18
18
  installBundledMessagingRuntime,
@@ -18,7 +18,7 @@ import {
18
18
  ensurePrivateStateDirectory,
19
19
  ghostgetStateHome,
20
20
  snapshotPrivateStateDirectory
21
- } from "./index-yq6maz71.js";
21
+ } from "./index-wwtk6nnt.js";
22
22
  import {
23
23
  canonicalJson,
24
24
  sha256
@@ -4,8 +4,8 @@ import {
4
4
  installBundledMessagingRuntime,
5
5
  readBundledMessagingAsset,
6
6
  verifyImsgNativeResources
7
- } from "./index-kaav16fd.js";
8
- import"./index-yq6maz71.js";
7
+ } from "./index-k2m73ke6.js";
8
+ import"./index-wwtk6nnt.js";
9
9
  import"./index-gwk7rbyj.js";
10
10
  import"./index-z1w83f81.js";
11
11
  export {
@@ -10,7 +10,7 @@ import {
10
10
  ensurePrivateStateDirectory,
11
11
  ghostgetStateHome,
12
12
  processOwnerStatus
13
- } from "./index-yq6maz71.js";
13
+ } from "./index-wwtk6nnt.js";
14
14
  import {
15
15
  startProviderPluginCleanupTrackedOperation
16
16
  } from "./index-n4szk3nw.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/ghostget",
3
- "version": "0.18.15",
3
+ "version": "0.18.16",
4
4
  "description": "Open-source CLI and TypeScript SDK for precise web capabilities for AI agents: page capture, verified media archives, encrypted reads, and typed provider operations.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -91,6 +91,7 @@
91
91
  "bunfig.toml",
92
92
  "docs/imessage-direct-provider.md",
93
93
  "docs/rental-listings.md",
94
+ "src/accounts-auth.ts",
94
95
  "src/args.ts",
95
96
  "src/apple-photos-cli.ts",
96
97
  "src/apple-photos-client-types.ts",
@@ -621,10 +622,13 @@
621
622
  "kb:check": "bunx --bun github:hraness/kb#v0.15.2 check --root kb && bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .",
622
623
  "kb:catalog": "bunx --bun github:hraness/kb#v0.15.2 catalog --root kb",
623
624
  "prepack": "bun run check",
624
- "check:effect": "bun scripts/check-effect-architecture.ts"
625
+ "check:effect": "bun scripts/check-effect-architecture.ts",
626
+ "generate:og": "bun scripts/generate-og.tsx"
625
627
  },
626
628
  "dependencies": {
629
+ "@hraness/accounts-cli": "github:hraness/accounts-cli#v0.1.3",
627
630
  "@hraness/kb": "https://github.com/hraness/kb/releases/download/v0.19.6/hraness-kb-0.19.6.tgz",
631
+ "@hraness/local-custody": "github:hraness/local-custody#d462900810b042a8b5f34245d0d371cfaa6ee9b3",
628
632
  "@hraness/message-like-me": "github:hraness/textbutler#83453cc7c17b49bb53fdfd89ccb69b8b44b30af1",
629
633
  "@hraness/support-foundation": "github:hraness/support-foundation#2d034b357680353574411217d68b02b6755b07ed",
630
634
  "buffer-from": "1.1.2",
@@ -636,19 +640,22 @@
636
640
  "@hraness/desktop-foundation": "https://github.com/hraness/desktop-foundation/releases/download/v0.7.0/hraness-desktop-foundation-0.7.0.tgz"
637
641
  },
638
642
  "devDependencies": {
639
- "@hraness/design-kit": "github:hraness/design-kit#v0.8.0",
640
- "@hraness/site-footer": "github:hraness/site-footer#v0.13.0",
643
+ "@hraness/design-kit": "github:hraness/design-kit#v0.10.0",
644
+ "@hraness/direct": "https://github.com/hraness/direct/releases/download/v0.7.21/hraness-direct-0.7.21.tgz",
645
+ "@hraness/site-footer": "github:hraness/site-footer#v0.15.0",
641
646
  "@hraness/ui": "github:hraness/ui#v0.5.13",
647
+ "@hraness/web-discovery": "github:hraness/web-discovery#v0.6.0",
648
+ "@resvg/resvg-js": "2.6.2",
642
649
  "@steipete/sweet-cookie": "0.4.3",
650
+ "@tanstack/react-virtual": "3.14.11",
643
651
  "@types/bun": "^1.3.14",
644
652
  "@types/react": "19.2.14",
645
653
  "@types/react-dom": "19.2.3",
654
+ "agent-browser": "0.32.3",
646
655
  "fast-check": "^4.8.0",
647
656
  "react": "19.2.3",
648
657
  "react-dom": "19.2.3",
649
- "@tanstack/react-virtual": "3.14.11",
650
- "@hraness/direct": "https://github.com/hraness/direct/releases/download/v0.7.21/hraness-direct-0.7.21.tgz",
651
- "agent-browser": "0.32.3"
658
+ "satori": "0.33.4"
652
659
  },
653
660
  "publishConfig": {
654
661
  "access": "public",
@@ -17,34 +17,34 @@ If Bun is missing, stop and direct the user to the official
17
17
  [Bun installation guide](https://bun.sh/docs/installation). Do not switch
18
18
  package managers or pipe an unreviewed installer into a shell.
19
19
 
20
- This reference is authored for the exact v0.18.15 release coordinate. Use it
20
+ This reference is authored for the exact v0.18.16 release coordinate. Use it
21
21
  only from the matching release-bound Agent Skill after its canonical archive and
22
22
  immutable GitHub Release exist. If the coordinate is not public, stop instead
23
23
  of substituting `main`, another tag, or a different package version. Install
24
24
  that exact release and its reviewed bundled adapter manifests:
25
25
 
26
26
  ```sh
27
- bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.15/hraness-ghostget-0.18.15.tgz
27
+ bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.16/hraness-ghostget-0.18.16.tgz
28
28
  ghostget adapter sync-bundled --json
29
29
  ghostget --help
30
30
  ghostget doctor --json
31
31
  ```
32
32
 
33
- The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.15` is an optional npm
33
+ The package is `@hraness/ghostget`; `@hraness/ghostget@0.18.16` is an optional npm
34
34
  mirror only after verified registry publication. Canonical installation does not
35
35
  wait for registry publication.
36
36
 
37
37
  When upgrading from Wrench, use `ghostget` for new commands and
38
38
  `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in
39
39
  place; do not rename, copy, or delete a state directory as part of the upgrade.
40
- The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.15/docs/ghostget-migration.md)
40
+ The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.16/docs/ghostget-migration.md)
41
41
  explains the retained state aliases and durable protocol names.
42
42
 
43
43
  Do not clone the repository merely to run the CLI. Importing the SDK is a
44
44
  separate project dependency and does not install a global command:
45
45
 
46
46
  ```sh
47
- bun add https://github.com/hraness/ghostget/releases/download/v0.18.15/hraness-ghostget-0.18.15.tgz
47
+ bun add https://github.com/hraness/ghostget/releases/download/v0.18.16/hraness-ghostget-0.18.16.tgz
48
48
  ```
49
49
 
50
50
  `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an
@@ -0,0 +1,188 @@
1
+ import { join } from "node:path";
2
+
3
+ import {
4
+ createCliSession,
5
+ createEncryptedFileTokenStorage,
6
+ createKeychainTokenStorage,
7
+ createMemoryTokenStorage,
8
+ initiateDeviceLogin,
9
+ type CliSession,
10
+ type DeviceLoginResult,
11
+ type TokenStorage,
12
+ } from "@hraness/accounts-cli";
13
+ import { ghostgetStateHome } from "./storage";
14
+
15
+ const GHOSTGET_CLIENT_ID = "hraness:ghostget:production:v1";
16
+ const ACCOUNTS_ORIGIN = "https://account.hraness.com";
17
+ const DEVICE_AUTHORIZATION_ENDPOINT =
18
+ `${ACCOUNTS_ORIGIN}/api/auth/oauth2/device_authorization`;
19
+ const DEVICE_TOKEN_ENDPOINT =
20
+ `${ACCOUNTS_ORIGIN}/api/auth/oauth2/device/token`;
21
+ const TOKEN_ENDPOINT = `${ACCOUNTS_ORIGIN}/api/auth/oauth2/token`;
22
+
23
+ export type AccountsAuthResult =
24
+ | Readonly<{ kind: "success"; message: string }>
25
+ | Readonly<{ kind: "denied" }>
26
+ | Readonly<{ kind: "expired" }>
27
+ | Readonly<{ kind: "error"; message: string }>;
28
+
29
+ export type AccountsAuthHandlers = Readonly<{
30
+ onUserCode?: (userCode: string, verificationUri: string) => void;
31
+ onPending?: () => void;
32
+ onSlowDown?: (intervalMs: number) => void;
33
+ }>;
34
+
35
+ export function createGhostgetTokenStorage(
36
+ environment: Readonly<Record<string, string | undefined>>,
37
+ storage?: TokenStorage,
38
+ ): TokenStorage {
39
+ if (storage !== undefined) return storage;
40
+ const keychain = createKeychainTokenStorage(
41
+ "ghostget",
42
+ "hraness-accounts",
43
+ );
44
+ const stateHome = ghostgetStateHome(environment);
45
+ const file = createEncryptedFileTokenStorage(
46
+ join(stateHome, "accounts", "refresh-token.enc"),
47
+ );
48
+ return {
49
+ backend: "keychain",
50
+ deleteRefreshToken: async () => {
51
+ await keychain.deleteRefreshToken();
52
+ await file.deleteRefreshToken();
53
+ },
54
+ loadRefreshToken: async () => {
55
+ const keychainToken = await keychain.loadRefreshToken();
56
+ if (keychainToken !== null) return keychainToken;
57
+ return file.loadRefreshToken();
58
+ },
59
+ saveRefreshToken: async (token) => {
60
+ try {
61
+ await keychain.saveRefreshToken(token);
62
+ } catch {
63
+ // Keychain may be unavailable in headless/CI; the encrypted file
64
+ // fallback still gives a machine-bound local secret.
65
+ }
66
+ await file.saveRefreshToken(token);
67
+ },
68
+ };
69
+ }
70
+
71
+ export async function runAccountsDeviceLogin(
72
+ environment: Readonly<Record<string, string | undefined>>,
73
+ handlers: AccountsAuthHandlers = {},
74
+ storage?: TokenStorage,
75
+ signal?: AbortSignal,
76
+ ): Promise<AccountsAuthResult> {
77
+ if (signal?.aborted) {
78
+ return { kind: "error", message: "Login was cancelled." };
79
+ }
80
+ const tokenStorage = createGhostgetTokenStorage(environment, storage);
81
+ const session = createCliSession({
82
+ clientId: GHOSTGET_CLIENT_ID,
83
+ storage: tokenStorage,
84
+ tokenEndpoint: TOKEN_ENDPOINT,
85
+ });
86
+
87
+ const deviceHandlers: {
88
+ onUserCode?: (userCode: string, verificationUri: string) => void;
89
+ onPending?: () => void;
90
+ onSlowDown?: (intervalMs: number) => void;
91
+ } = {};
92
+ if (handlers.onUserCode !== undefined) {
93
+ deviceHandlers.onUserCode = handlers.onUserCode;
94
+ }
95
+ if (handlers.onPending !== undefined) {
96
+ deviceHandlers.onPending = handlers.onPending;
97
+ }
98
+ if (handlers.onSlowDown !== undefined) {
99
+ deviceHandlers.onSlowDown = handlers.onSlowDown;
100
+ }
101
+
102
+ const device = await initiateDeviceLogin(
103
+ {
104
+ deviceAuthorizationEndpoint: DEVICE_AUTHORIZATION_ENDPOINT,
105
+ deviceTokenEndpoint: DEVICE_TOKEN_ENDPOINT,
106
+ },
107
+ {
108
+ clientId: GHOSTGET_CLIENT_ID,
109
+ scopes: ["openid", "profile", "email", "offline_access"],
110
+ },
111
+ deviceHandlers,
112
+ );
113
+
114
+ const abortPromise = new Promise<never>((_resolve, reject) => {
115
+ if (signal === undefined) return;
116
+ if (signal.aborted) {
117
+ reject(new Error("Login was cancelled."));
118
+ return;
119
+ }
120
+ signal.addEventListener("abort", () => {
121
+ reject(new Error("Login was cancelled."));
122
+ }, { once: true });
123
+ });
124
+
125
+ try {
126
+ const result = (await Promise.race([device.poll(), abortPromise])) as DeviceLoginResult;
127
+ return handlePollResult(result, session);
128
+ } catch (error) {
129
+ return {
130
+ kind: "error",
131
+ message: error instanceof Error ? error.message : String(error),
132
+ };
133
+ }
134
+ }
135
+
136
+ function handlePollResult(
137
+ result: DeviceLoginResult,
138
+ session: CliSession,
139
+ ): AccountsAuthResult {
140
+ switch (result.kind) {
141
+ case "token":
142
+ void session.saveRefreshToken(result.refreshToken);
143
+ return {
144
+ kind: "success",
145
+ message: "Signed in to Hraness Accounts. You can return to your terminal.",
146
+ };
147
+ case "access_denied":
148
+ return { kind: "denied" };
149
+ case "expired_token":
150
+ return { kind: "expired" };
151
+ case "error":
152
+ return {
153
+ kind: "error",
154
+ message: result.errorDescription ?? result.error,
155
+ };
156
+ default: {
157
+ const _exhaustive: never = result;
158
+ return { kind: "error", message: `unknown login result: ${_exhaustive}` };
159
+ }
160
+ }
161
+ }
162
+
163
+ export function loadAccountsSession(
164
+ environment: Readonly<Record<string, string | undefined>>,
165
+ storage?: TokenStorage,
166
+ ): CliSession {
167
+ const tokenStorage = createGhostgetTokenStorage(environment, storage);
168
+ return createCliSession({
169
+ clientId: GHOSTGET_CLIENT_ID,
170
+ storage: tokenStorage,
171
+ tokenEndpoint: TOKEN_ENDPOINT,
172
+ });
173
+ }
174
+
175
+ export async function runAccountsSignOut(
176
+ environment: Readonly<Record<string, string | undefined>>,
177
+ storage?: TokenStorage,
178
+ ): Promise<void> {
179
+ await loadAccountsSession(environment, storage).signOut();
180
+ }
181
+
182
+ export const accountsAuthInternals = {
183
+ DEVICE_AUTHORIZATION_ENDPOINT,
184
+ DEVICE_TOKEN_ENDPOINT,
185
+ TOKEN_ENDPOINT,
186
+ GHOSTGET_CLIENT_ID,
187
+ createMemoryTokenStorage,
188
+ };
package/src/args.ts CHANGED
@@ -36,6 +36,8 @@ type MessagingArguments = {
36
36
 
37
37
  export type GhostgetArguments =
38
38
  | { readonly command: "help" }
39
+ | { readonly command: "login"; readonly json: boolean }
40
+ | { readonly command: "logout"; readonly json: boolean }
39
41
  | { readonly command: "messaging-automation-serve" }
40
42
  | { readonly command: "whatsapp-automation-install"; readonly binary?: string; readonly json: boolean }
41
43
  | { readonly command: "clip"; readonly arguments: readonly string[] }
@@ -777,6 +779,18 @@ export function parseGhostgetArguments(raw: readonly string[]): ParseGhostgetRes
777
779
  if (raw.length > 1) return { ok: false, message: "help accepts no arguments" };
778
780
  return { ok: true, value: { command: "help" } };
779
781
  }
782
+ if (raw[0] === "login") {
783
+ const json = simpleJsonOptions(raw.slice(1), "login");
784
+ return typeof json === "boolean"
785
+ ? { ok: true, value: { command: "login", json } }
786
+ : json;
787
+ }
788
+ if (raw[0] === "logout") {
789
+ const json = simpleJsonOptions(raw.slice(1), "logout");
790
+ return typeof json === "boolean"
791
+ ? { ok: true, value: { command: "logout", json } }
792
+ : json;
793
+ }
780
794
  const first = raw[0] ?? "";
781
795
  if (/^https?:\/\//iu.test(first)) return { ok: true, value: { command: "clip", arguments: raw } };
782
796
  if (first === "clip" || first === "capture") return { ok: true, value: { command: "clip", arguments: raw.slice(1) } };
@@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{
96
96
  implementation: Readonly<{
97
97
  producer: Readonly<{
98
98
  package: "@hraness/ghostget";
99
- version: "0.18.15";
99
+ version: "0.18.16";
100
100
  }>;
101
101
  officialCli: Readonly<{
102
102
  implementation: "github.com/beeper/cli";
@@ -1,20 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import {
3
- chmodSync,
4
- closeSync,
5
- constants,
6
- fchmodSync,
7
- fstatSync,
8
- fsyncSync,
9
- lstatSync,
10
- mkdirSync,
11
- openSync,
12
- readSync,
13
- unlinkSync,
14
- writeSync,
15
- } from "node:fs";
2
+ import { chmodSync, mkdirSync } from "node:fs";
16
3
  import { tmpdir } from "node:os";
17
- import { join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
5
+
6
+ import {
7
+ assertOwnedPathSync,
8
+ readOwnedFileStableSync,
9
+ } from "@hraness/local-custody/private-paths";
10
+ import { createPrivateFileOnceSync } from "@hraness/local-custody/atomic-publish";
18
11
 
19
12
  import { canonicalJson, sha256 } from "./canonical-json";
20
13
  import type { ProcessOwnerIdentity } from "./process-identity";
@@ -141,11 +134,6 @@ type GuardRuntimeCase = {
141
134
  readonly ruleId: number;
142
135
  };
143
136
 
144
- function currentUserOwns(uid: number | bigint): boolean {
145
- const current = typeof process.getuid === "function" ? process.getuid() : undefined;
146
- return current === undefined || uid === (typeof uid === "bigint" ? BigInt(current) : current);
147
- }
148
-
149
137
  function escapeRegex(value: string): string {
150
138
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
151
139
  }
@@ -307,14 +295,11 @@ function extensionIdFromKey(): string {
307
295
 
308
296
  function inspectDirectory(path: string): GuardDirectoryIdentity {
309
297
  try {
310
- const stats = lstatSync(path, { bigint: true });
311
- if (
312
- !stats.isDirectory()
313
- || stats.isSymbolicLink()
314
- || !currentUserOwns(stats.uid)
315
- || (stats.mode & 0o777n) !== 0o700n
316
- ) throw new Error("unsafe");
317
- return { device: stats.dev.toString(), inode: stats.ino.toString() };
298
+ const identity = assertOwnedPathSync(path, {
299
+ kind: "directory",
300
+ exactMode: 0o700,
301
+ });
302
+ return { device: identity.dev.toString(), inode: identity.ino.toString() };
318
303
  } catch {
319
304
  throw new Error("derivation network guard directory is unavailable or unsafe");
320
305
  }
@@ -325,71 +310,18 @@ function sameIdentity(left: GuardDirectoryIdentity, right: GuardDirectoryIdentit
325
310
  }
326
311
 
327
312
  export function writeGuardPrivateFile(path: string, content: string): GuardPrivateFileEvidence {
328
- let descriptor: number | null = null;
329
- let openedIdentity: GuardDirectoryIdentity | null = null;
330
- let completed = false;
331
313
  try {
332
- descriptor = openSync(
333
- path,
334
- constants.O_WRONLY
335
- | constants.O_CREAT
336
- | constants.O_EXCL
337
- | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
338
- 0o600,
339
- );
340
- const opened = fstatSync(descriptor, { bigint: true });
341
- openedIdentity = { device: opened.dev.toString(), inode: opened.ino.toString() };
342
- fchmodSync(descriptor, 0o600);
343
- const bytes = Buffer.from(content, "utf8");
344
- let offset = 0;
345
- while (offset < bytes.byteLength) {
346
- offset += writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
347
- }
348
- fsyncSync(descriptor);
349
- const stats = fstatSync(descriptor, { bigint: true });
350
- if (
351
- !stats.isFile()
352
- || !currentUserOwns(stats.uid)
353
- || (stats.mode & 0o777n) !== 0o600n
354
- || stats.size !== BigInt(bytes.byteLength)
355
- ) throw new Error("derivation network guard file could not be secured");
356
- const evidence = {
357
- device: stats.dev.toString(),
358
- inode: stats.ino.toString(),
359
- byteLength: bytes.byteLength,
314
+ const outcome = createPrivateFileOnceSync(dirname(path), basename(path), content);
315
+ if (outcome !== "created") throw new Error("exists");
316
+ const identity = assertOwnedPathSync(path, { kind: "file", exactMode: 0o600 });
317
+ return {
318
+ device: identity.dev.toString(),
319
+ inode: identity.ino.toString(),
320
+ byteLength: Buffer.byteLength(content, "utf8"),
360
321
  sha256: sha256(content),
361
322
  };
362
- completed = true;
363
- return evidence;
364
323
  } catch {
365
324
  throw new Error("derivation network guard file could not be secured");
366
- } finally {
367
- if (descriptor !== null) {
368
- if (!completed && openedIdentity === null) {
369
- try {
370
- const stats = fstatSync(descriptor, { bigint: true });
371
- openedIdentity = { device: stats.dev.toString(), inode: stats.ino.toString() };
372
- } catch {
373
- // Without an exact identity, preserve the path instead of unlinking.
374
- }
375
- }
376
- try {
377
- closeSync(descriptor);
378
- } catch {
379
- // The categorical write failure below must not expose a local path.
380
- }
381
- }
382
- if (!completed && openedIdentity !== null) {
383
- try {
384
- const current = lstatSync(path, { bigint: true });
385
- if (
386
- current.dev.toString() === openedIdentity.device
387
- && current.ino.toString() === openedIdentity.inode
388
- ) unlinkSync(path);
389
- } catch {
390
- // A changed path is intentionally preserved.
391
- }
392
- }
393
325
  }
394
326
  }
395
327
 
@@ -397,55 +329,23 @@ export function readGuardPrivateFile(
397
329
  path: string,
398
330
  maximumBytes = 4 * 1024 * 1024,
399
331
  ): { readonly content: string; readonly evidence: GuardPrivateFileEvidence } {
400
- let descriptor: number | null = null;
401
332
  try {
402
- descriptor = openSync(
403
- path,
404
- constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
405
- );
406
- const before = fstatSync(descriptor, { bigint: true });
407
- if (
408
- !before.isFile()
409
- || !currentUserOwns(before.uid)
410
- || (before.mode & 0o777n) !== 0o600n
411
- || before.size < 1n
412
- || before.size > BigInt(maximumBytes)
413
- ) throw new Error("derivation network guard file is unavailable or unsafe");
414
- const bytes = Buffer.alloc(Number(before.size));
415
- let offset = 0;
416
- while (offset < bytes.byteLength) {
417
- const count = readSync(descriptor, bytes, offset, bytes.byteLength - offset, null);
418
- if (count === 0) throw new Error("derivation network guard file changed size");
419
- offset += count;
420
- }
421
- const after = fstatSync(descriptor, { bigint: true });
422
- if (
423
- before.dev !== after.dev
424
- || before.ino !== after.ino
425
- || before.size !== after.size
426
- || before.mtimeNs !== after.mtimeNs
427
- || before.ctimeNs !== after.ctimeNs
428
- ) throw new Error("derivation network guard file changed while reading");
429
- const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
333
+ const read = readOwnedFileStableSync(path, maximumBytes, {
334
+ exactMode: 0o600,
335
+ minimumBytes: 1n,
336
+ });
337
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(read.bytes);
430
338
  return {
431
339
  content,
432
340
  evidence: {
433
- device: before.dev.toString(),
434
- inode: before.ino.toString(),
435
- byteLength: bytes.byteLength,
341
+ device: read.dev.toString(),
342
+ inode: read.ino.toString(),
343
+ byteLength: read.bytes.byteLength,
436
344
  sha256: sha256(content),
437
345
  },
438
346
  };
439
347
  } catch {
440
348
  throw new Error("derivation network guard file is unavailable or unsafe");
441
- } finally {
442
- if (descriptor !== null) {
443
- try {
444
- closeSync(descriptor);
445
- } catch {
446
- // Keep diagnostics categorical and path-free.
447
- }
448
- }
449
349
  }
450
350
  }
451
351
 
@@ -484,48 +384,20 @@ export function verifyGuardPrivateFile(
484
384
  expected: GuardPrivateFileEvidence,
485
385
  expectedContent: string,
486
386
  ): void {
487
- let descriptor: number | null = null;
488
387
  try {
489
- descriptor = openSync(
490
- path,
491
- constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
492
- );
493
- const before = fstatSync(descriptor, { bigint: true });
388
+ const read = readOwnedFileStableSync(path, expected.byteLength, {
389
+ exactMode: 0o600,
390
+ minimumBytes: 1n,
391
+ });
494
392
  if (
495
- !before.isFile()
496
- || !currentUserOwns(before.uid)
497
- || (before.mode & 0o777n) !== 0o600n
498
- || before.dev.toString() !== expected.device
499
- || before.ino.toString() !== expected.inode
500
- || before.size !== BigInt(expected.byteLength)
501
- ) throw new Error("derivation network guard file changed identity");
502
- const bytes = Buffer.alloc(expected.byteLength);
503
- let offset = 0;
504
- while (offset < bytes.byteLength) {
505
- const count = readSync(descriptor, bytes, offset, bytes.byteLength - offset, null);
506
- if (count === 0) throw new Error("derivation network guard file changed size");
507
- offset += count;
508
- }
509
- const after = fstatSync(descriptor, { bigint: true });
510
- if (
511
- before.dev !== after.dev
512
- || before.ino !== after.ino
513
- || before.size !== after.size
514
- || before.mtimeNs !== after.mtimeNs
515
- || before.ctimeNs !== after.ctimeNs
516
- || bytes.toString("utf8") !== expectedContent
393
+ read.dev.toString() !== expected.device
394
+ || read.ino.toString() !== expected.inode
395
+ || read.bytes.byteLength !== expected.byteLength
396
+ || read.bytes.toString("utf8") !== expectedContent
517
397
  || sha256(expectedContent) !== expected.sha256
518
- ) throw new Error("derivation network guard file changed content");
398
+ ) throw new Error("derivation network guard file changed");
519
399
  } catch {
520
400
  throw new Error("derivation network guard file changed or is unavailable");
521
- } finally {
522
- if (descriptor !== null) {
523
- try {
524
- closeSync(descriptor);
525
- } catch {
526
- // Keep diagnostics categorical and path-free.
527
- }
528
- }
529
401
  }
530
402
  }
531
403
 
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  chmodSync,
4
- closeSync,
5
- constants,
6
- fstatSync,
7
4
  lstatSync,
8
- openSync,
9
- readSync,
10
5
  rmdirSync,
11
6
  unlinkSync,
12
7
  } from "node:fs";
13
8
  import { createServer, type Server, type Socket } from "node:net";
14
9
 
10
+ import {
11
+ assertOwnedPathSync,
12
+ readOwnedFileStableSync,
13
+ } from "@hraness/local-custody/private-paths";
14
+
15
15
  import {
16
16
  DERIVATION_GUARD_PROXY_CONFIG,
17
17
  DERIVATION_GUARD_PROXY_READY,
@@ -39,11 +39,6 @@ function injectFailureForTest(stage: HelperFailureStage): void {
39
39
  ) throw new Error("injected derivation proxy helper failure");
40
40
  }
41
41
 
42
- function currentUserOwns(uid: number | bigint): boolean {
43
- const current = typeof process.getuid === "function" ? process.getuid() : undefined;
44
- return current === undefined || uid === (typeof uid === "bigint" ? BigInt(current) : current);
45
- }
46
-
47
42
  function sameIdentity(left: GuardDirectoryIdentity, right: GuardDirectoryIdentity): boolean {
48
43
  return left.device === right.device && left.inode === right.inode;
49
44
  }
@@ -95,25 +90,23 @@ function provisionalReadyFileIsAbsent(): boolean {
95
90
  }
96
91
 
97
92
  function inspectPrivateDirectory(path: string): GuardDirectoryIdentity {
98
- const stats = lstatSync(path, { bigint: true });
99
- if (
100
- !stats.isDirectory()
101
- || stats.isSymbolicLink()
102
- || !currentUserOwns(stats.uid)
103
- || (stats.mode & 0o777n) !== 0o700n
104
- ) throw new Error("derivation proxy directory is unavailable or unsafe");
105
- return { device: stats.dev.toString(), inode: stats.ino.toString() };
93
+ let identity: { readonly dev: number; readonly ino: number };
94
+ try {
95
+ identity = assertOwnedPathSync(path, { kind: "directory", exactMode: 0o700 });
96
+ } catch {
97
+ throw new Error("derivation proxy directory is unavailable or unsafe");
98
+ }
99
+ return { device: identity.dev.toString(), inode: identity.ino.toString() };
106
100
  }
107
101
 
108
102
  function inspectPrivateControlSocket(path: string): GuardDirectoryIdentity {
109
- const stats = lstatSync(path, { bigint: true });
110
- if (
111
- !stats.isSocket()
112
- || stats.isSymbolicLink()
113
- || !currentUserOwns(stats.uid)
114
- || (stats.mode & 0o777n) !== 0o600n
115
- ) throw new Error("derivation proxy control socket is unavailable or unsafe");
116
- return { device: stats.dev.toString(), inode: stats.ino.toString() };
103
+ let identity: { readonly dev: number; readonly ino: number };
104
+ try {
105
+ identity = assertOwnedPathSync(path, { kind: "socket", exactMode: 0o600 });
106
+ } catch {
107
+ throw new Error("derivation proxy control socket is unavailable or unsafe");
108
+ }
109
+ return { device: identity.dev.toString(), inode: identity.ino.toString() };
117
110
  }
118
111
 
119
112
  function unlinkExactControlSocket(
@@ -147,38 +140,16 @@ function waitForSocketClose(socket: Socket): Promise<void> {
147
140
  }
148
141
 
149
142
  function readPrivateConfig(): unknown {
150
- const descriptor = openSync(
151
- DERIVATION_GUARD_PROXY_CONFIG,
152
- constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
153
- );
143
+ let read: ReturnType<typeof readOwnedFileStableSync>;
154
144
  try {
155
- const before = fstatSync(descriptor, { bigint: true });
156
- if (
157
- !before.isFile()
158
- || !currentUserOwns(before.uid)
159
- || (before.mode & 0o777n) !== 0o600n
160
- || before.size < 1n
161
- || before.size > BigInt(MAX_CONFIG_BYTES)
162
- ) throw new Error("derivation proxy config is unavailable or unsafe");
163
- const bytes = Buffer.alloc(Number(before.size));
164
- let offset = 0;
165
- while (offset < bytes.byteLength) {
166
- const count = readSync(descriptor, bytes, offset, bytes.byteLength - offset, null);
167
- if (count === 0) throw new Error("derivation proxy config changed size");
168
- offset += count;
169
- }
170
- const after = fstatSync(descriptor, { bigint: true });
171
- if (
172
- before.dev !== after.dev
173
- || before.ino !== after.ino
174
- || before.size !== after.size
175
- || before.mtimeNs !== after.mtimeNs
176
- || before.ctimeNs !== after.ctimeNs
177
- ) throw new Error("derivation proxy config changed while reading");
178
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
179
- } finally {
180
- closeSync(descriptor);
145
+ read = readOwnedFileStableSync(DERIVATION_GUARD_PROXY_CONFIG, MAX_CONFIG_BYTES, {
146
+ exactMode: 0o600,
147
+ minimumBytes: 1n,
148
+ });
149
+ } catch {
150
+ throw new Error("derivation proxy config is unavailable or unsafe");
181
151
  }
152
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes)) as unknown;
182
153
  }
183
154
 
184
155
  function exactKeys(record: Record<string, unknown>, expected: readonly string[]): boolean {
package/src/derive.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "@hraness/kb/clip/acquire";
23
23
  import { isPrivateAddress, isPrivateHostname } from "@hraness/kb/clip/network";
24
24
  import type { StrictCookie } from "@hraness/kb/clip/cookies";
25
+ import { assertOwnedPathSync } from "@hraness/local-custody/private-paths";
25
26
  import type { GhostgetAuth } from "./auth";
26
27
  import {
27
28
  agentBrowserFailure,
@@ -234,14 +235,14 @@ function ownedByCurrentUser(uid: number | bigint): boolean {
234
235
  }
235
236
 
236
237
  function inspectDirectoryIdentity(path: string): DirectoryIdentity {
237
- const stats = lstatSync(path, { bigint: true });
238
- if (
239
- !stats.isDirectory()
240
- || stats.isSymbolicLink()
241
- || !ownedByCurrentUser(stats.uid)
242
- || (stats.mode & 0o777n) !== 0o700n
243
- ) throw new Error("derivation session directory is unsafe");
244
- return { device: stats.dev.toString(), inode: stats.ino.toString() };
238
+ let identity: { readonly dev: number; readonly ino: number };
239
+ try {
240
+ identity = assertOwnedPathSync(path, { kind: "directory", exactMode: 0o700 });
241
+ } catch (error) {
242
+ if (hasErrorCode(error, "ENOENT")) throw error;
243
+ throw new Error("derivation session directory is unsafe", { cause: error });
244
+ }
245
+ return { device: identity.dev.toString(), inode: identity.ino.toString() };
245
246
  }
246
247
 
247
248
  function parseDirectoryIdentity(value: unknown): DirectoryIdentity {
@@ -1101,25 +1102,21 @@ function inspectPrivateFile(
1101
1102
  maximumBytes: number,
1102
1103
  label: string,
1103
1104
  ): Omit<PrivateFileEvidence, "sha256"> {
1104
- const stats = (() => {
1105
- try {
1106
- return lstatSync(path, { bigint: true });
1107
- } catch (error) {
1108
- throw new Error(`${label} is unavailable or unsafe`, { cause: error });
1109
- }
1110
- })();
1111
- if (
1112
- !stats.isFile()
1113
- || stats.isSymbolicLink()
1114
- || !ownedByCurrentUser(stats.uid)
1115
- || (stats.mode & 0o077n) !== 0n
1116
- || stats.size < 1n
1117
- || stats.size > BigInt(maximumBytes)
1118
- ) throw new Error(`${label} is unavailable or unsafe`);
1105
+ let identity: { readonly dev: number; readonly ino: number; readonly size: number };
1106
+ try {
1107
+ identity = assertOwnedPathSync(path, {
1108
+ kind: "file",
1109
+ ownerOnly: true,
1110
+ minimumBytes: 1n,
1111
+ maximumBytes: BigInt(maximumBytes),
1112
+ });
1113
+ } catch (error) {
1114
+ throw new Error(`${label} is unavailable or unsafe`, { cause: error });
1115
+ }
1119
1116
  return {
1120
- device: stats.dev.toString(),
1121
- inode: stats.ino.toString(),
1122
- byteLength: Number(stats.size),
1117
+ device: identity.dev.toString(),
1118
+ inode: identity.ino.toString(),
1119
+ byteLength: identity.size,
1123
1120
  };
1124
1121
  }
1125
1122
 
@@ -1444,18 +1441,20 @@ function inspectCapturedHar(path: string): {
1444
1441
  readonly inode: string;
1445
1442
  readonly byteLength: number;
1446
1443
  } {
1447
- const stats = lstatSync(path, { bigint: true });
1448
- if (
1449
- !stats.isFile()
1450
- || stats.isSymbolicLink()
1451
- || !ownedByCurrentUser(stats.uid)
1452
- || stats.size < 1n
1453
- || stats.size > BigInt(MAX_HAR_BYTES)
1454
- ) throw new Error("derivation review HAR is unavailable or unsafe");
1444
+ let identity: { readonly dev: number; readonly ino: number; readonly size: number };
1445
+ try {
1446
+ identity = assertOwnedPathSync(path, {
1447
+ kind: "file",
1448
+ minimumBytes: 1n,
1449
+ maximumBytes: BigInt(MAX_HAR_BYTES),
1450
+ });
1451
+ } catch (error) {
1452
+ throw new Error("derivation review HAR is unavailable or unsafe", { cause: error });
1453
+ }
1455
1454
  return {
1456
- device: stats.dev.toString(),
1457
- inode: stats.ino.toString(),
1458
- byteLength: Number(stats.size),
1455
+ device: identity.dev.toString(),
1456
+ inode: identity.ino.toString(),
1457
+ byteLength: identity.size,
1459
1458
  };
1460
1459
  }
1461
1460
 
package/src/ghostget.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  saveAuth,
23
23
  type GhostgetAuth,
24
24
  } from "./auth";
25
+ import { runAccountsDeviceLogin, runAccountsSignOut } from "./accounts-auth";
25
26
  import {
26
27
  isPublicWebSessionInvocationAuthority,
27
28
  type InvocationAuthority,
@@ -1983,6 +1984,48 @@ async function runCommand(
1983
1984
  output.stdout(renderGhostgetUsage());
1984
1985
  return 0;
1985
1986
  }
1987
+ if (arguments_.command === "login") {
1988
+ const result = await runAccountsDeviceLogin(
1989
+ environment,
1990
+ {
1991
+ onUserCode: (userCode, verificationUri) => {
1992
+ output.stderr(
1993
+ `Open ${safe(verificationUri)} in a browser where you are already signed in to Hraness Accounts and enter code: ${safe(userCode)}\n`,
1994
+ );
1995
+ },
1996
+ onPending: () => {
1997
+ output.stderr("Waiting for browser approval...\n");
1998
+ },
1999
+ onSlowDown: () => {
2000
+ output.stderr("Polling more slowly...\n");
2001
+ },
2002
+ },
2003
+ undefined,
2004
+ signal,
2005
+ );
2006
+ if (arguments_.json) {
2007
+ output.stdout(exactTerminalJson({ ok: result.kind === "success", ...result }));
2008
+ } else if (result.kind === "success") {
2009
+ output.stdout(`${safe(result.message)}\n`);
2010
+ } else if (result.kind === "denied") {
2011
+ output.stderr("Login was denied in the browser.\n");
2012
+ } else if (result.kind === "expired") {
2013
+ output.stderr("The sign-in code expired. Run 'ghostget login' again.\n");
2014
+ } else {
2015
+ output.stderr(`Login failed: ${safe(result.message)}\n`);
2016
+ }
2017
+ return result.kind === "success" ? 0 : 3;
2018
+ }
2019
+ if (arguments_.command === "logout") {
2020
+ await runAccountsSignOut(environment);
2021
+ const message = "Signed out of Hraness Accounts.";
2022
+ if (arguments_.json) {
2023
+ output.stdout(exactTerminalJson({ ok: true, message }));
2024
+ } else {
2025
+ output.stdout(`${message}\n`);
2026
+ }
2027
+ return 0;
2028
+ }
1986
2029
  if (arguments_.command === "whatsapp-automation-install") {
1987
2030
  const { installReviewedWhatsAppAutomationBinary } = await import("./providers/whatsapp-automation-runtime");
1988
2031
  const installed = arguments_.binary === undefined
package/src/storage.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  mkdtempSync,
11
11
  openSync,
12
12
  opendirSync,
13
- readSync,
14
13
  realpathSync,
15
14
  rmSync,
16
15
  writeFileSync,
@@ -18,6 +17,7 @@ import {
18
17
  type Dirent,
19
18
  } from "node:fs";
20
19
  import { spawnSync } from "node:child_process";
20
+ import { readOwnedFileStableSync } from "@hraness/local-custody/private-paths";
21
21
  import { homedir, tmpdir } from "node:os";
22
22
  import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
@@ -363,20 +363,6 @@ function hasCode(error: unknown, code: string): boolean {
363
363
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
364
364
  }
365
365
 
366
- function readDescriptorBounded(descriptor: number, maximumBytes: number): Buffer {
367
- const chunks: Buffer[] = [];
368
- const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1));
369
- let total = 0;
370
- for (;;) {
371
- const remaining = maximumBytes + 1 - total;
372
- const count = readSync(descriptor, buffer, 0, Math.min(buffer.byteLength, remaining), null);
373
- if (count === 0) return Buffer.concat(chunks, total);
374
- total += count;
375
- if (total > maximumBytes) throw new Error("file grew beyond its byte bound");
376
- chunks.push(Buffer.from(buffer.subarray(0, count)));
377
- }
378
- }
379
-
380
366
  function pathInside(root: string, target: string): boolean {
381
367
  const child = relative(root, target);
382
368
  return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`));
@@ -1037,18 +1023,17 @@ function ownedByCurrentUser(stats: { readonly uid: number | bigint }): boolean {
1037
1023
  }
1038
1024
 
1039
1025
  function readStateMarker(path: string): void {
1040
- const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
1041
- const nonBlocking = "O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0;
1042
- const descriptor = openSync(path, constants.O_RDONLY | noFollow | nonBlocking);
1026
+ let bytes: Buffer;
1043
1027
  try {
1044
- const stats = fstatSync(descriptor);
1045
- if (!stats.isFile() || stats.size > 256 || !ownedByCurrentUser(stats) || (stats.mode & 0o077) !== 0) {
1046
- throw new Error("ghostget state marker must be a private, owned regular file");
1047
- }
1028
+ bytes = readOwnedFileStableSync(path, 256).bytes;
1029
+ } catch (error) {
1030
+ throw new Error("ghostget state marker must be a private, owned regular file", { cause: error });
1031
+ }
1032
+ {
1048
1033
  const content = new TextDecoder("utf-8", {
1049
1034
  fatal: true,
1050
1035
  ignoreBOM: true,
1051
- }).decode(readDescriptorBounded(descriptor, 256));
1036
+ }).decode(bytes);
1052
1037
  if (content !== stateMarkerText) throw new Error("ghostget state marker is malformed");
1053
1038
  const value = JSON.parse(content) as unknown;
1054
1039
  if (
@@ -1061,8 +1046,6 @@ function readStateMarker(path: string): void {
1061
1046
  || !("kind" in value)
1062
1047
  || value.kind !== "io-state"
1063
1048
  ) throw new Error("ghostget state marker is malformed");
1064
- } finally {
1065
- closeSync(descriptor);
1066
1049
  }
1067
1050
  }
1068
1051
 
package/src/usage.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * optional provider runtime is broken.
7
7
  */
8
8
  export const ghostgetUsage = `Usage:
9
+ ghostget login [--json] Sign in to Hraness Accounts
10
+ ghostget logout [--json] Sign out of Hraness Accounts
9
11
  ghostget --version Print the exact Ghostget release version
10
12
  ghostget support [--json] View optional product updates and support
11
13
  ghostget support protocol --json Read the optional agent closeout protocol
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Canonical immutable Ghostget package release identity. */
2
- export const GHOSTGET_VERSION = "0.18.15" as const;
2
+ export const GHOSTGET_VERSION = "0.18.16" as const;