viber-channel 0.8.35 → 0.8.36

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/lib/connect.ts CHANGED
@@ -22,6 +22,7 @@ import { dirname, isAbsolute, join, relative } from "node:path";
22
22
  import { connectAuthPath, isQaBackend } from "./auth.js";
23
23
  import { clientFingerprint } from "./fingerprint.js";
24
24
  import { cfAccessHeaders } from "./cfAccess.js";
25
+ import { type RecordOutcome, recordConnectedFolder } from "./machine_projects.js";
25
26
 
26
27
  interface InitResponse {
27
28
  confirm_url: string;
@@ -88,6 +89,13 @@ function sleep(ms: number): Promise<void> {
88
89
  return new Promise((resolve) => setTimeout(resolve, ms));
89
90
  }
90
91
 
92
+ /** #674 D8 (a): one line on where the Viber app will launch this project's agents. Never fails the connect. */
93
+ export function folderLine(o: RecordOutcome): string {
94
+ if (o.kind === "recorded") return `The Viber app will launch this project's agents in ${o.folder}`;
95
+ if (o.kind === "skipped") return `Folder not recorded for the Viber app: ${o.reason}`;
96
+ return `Folder not recorded for the Viber app (${o.reason}) — it will ask for it at the first launch`;
97
+ }
98
+
91
99
  function writeAuthJson(
92
100
  cwd: string,
93
101
  data: ConsumedResponse,
@@ -212,7 +220,8 @@ export async function runConnect(
212
220
  writeAuthJson(cwd, data, fingerprint, authPath);
213
221
  process.stdout.write(
214
222
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
215
- ` Wrote ${authPath}\n\n` +
223
+ ` Wrote ${authPath}\n` +
224
+ ` ${folderLine(recordConnectedFolder(data.project_id, cwd, authPath))}\n\n` +
216
225
  (isQaBackend(baseUrl) ? QA_NEXT_STEPS : DEFAULT_NEXT_STEPS),
217
226
  );
218
227
  return;
@@ -41,7 +41,14 @@ export function getMachineId(): string {
41
41
  }
42
42
 
43
43
  export function clientFingerprint(folderPath: string): string {
44
- const machineId = getMachineId();
44
+ return clientFingerprintWith(getMachineId(), folderPath);
45
+ }
46
+
47
+ /**
48
+ * The same fingerprint with the machine id read ONCE by the caller (#674): on Windows
49
+ * `getMachineId` spawns `reg query`, and the Viber app checks several folders in a row.
50
+ */
51
+ export function clientFingerprintWith(machineId: string, folderPath: string): string {
45
52
  const absPath = normalizePath(realpathSync(folderPath) as string);
46
53
  return createHash("sha256").update(`${machineId}:${absPath}`).digest("hex").slice(0, 32);
47
54
  }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * machine_projects.ts — the Viber app's project -> folder table, and who may write it (#674, D8).
3
+ *
4
+ * The table lives in the app's home (`<home>/projects.json`); the app's runner reads it to know
5
+ * where to launch a project's agents. It is written where the folder is KNOWN: at
6
+ * `viber-channel connect` (which writes `.viber/<auth>` IN that folder), once by the app's
7
+ * catch-up for projects connected before, and by the folder picker. It lives here, not in the
8
+ * gateway, because the channel is published alone on npm and cannot import the gateway; the
9
+ * gateway re-exports it.
10
+ *
11
+ * ⚠ Bound to ONE backend identity: project ids of dev and staging overlap (measured: 4 and 6), so
12
+ * a table read for another identity (a home re-enrolled elsewhere) is EMPTY — never a folder of
13
+ * another backend. ⚠ Several processes write it (connect, the app): every write is serialized by
14
+ * a lock file and lands by rename, so the table is never read half-written.
15
+ */
16
+ import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { isDevBackend, isQaBackend } from "./auth.js";
19
+ import { identityBaseUrl } from "./base_urls.js";
20
+ import type { RunnerAuthLite } from "./runner_exec.js";
21
+ import { execFileSync } from "./hidden_proc.js";
22
+
23
+ /** `%LOCALAPPDATA%\Viber`, or `VIBER_APP_HOME` (tests, a second dev instance). */
24
+ export function appHome(env: NodeJS.ProcessEnv = process.env): string {
25
+ const override = env.VIBER_APP_HOME?.trim();
26
+ if (override) return override;
27
+ const local = env.LOCALAPPDATA?.trim();
28
+ if (!local) throw new Error("LOCALAPPDATA is not set: cannot place the Viber app state");
29
+ return join(local, "Viber");
30
+ }
31
+
32
+ export function runnerJsonPath(home: string): string {
33
+ return join(home, ".viber", "runner.json");
34
+ }
35
+
36
+ /**
37
+ * The identity a table belongs to: the enrolled host, else the DEFAULT — never guessed from the
38
+ * calling process's env: a `connect` run with `VIBER_BASE_URL=dev` must not reinterpret the
39
+ * app's pre-#627 `runner.json` (the app, started from the Start menu, has no such variable).
40
+ */
41
+ export function tableIdentity(auth: Pick<RunnerAuthLite, "identity_base_url">): string {
42
+ return auth.identity_base_url ?? identityBaseUrl({});
43
+ }
44
+
45
+ /** The identity of the app enrolled in `home`, or undefined when it is not enrolled. */
46
+ export function appIdentity(home: string): string | undefined {
47
+ const path = runnerJsonPath(home);
48
+ if (!existsSync(path)) return undefined;
49
+ return tableIdentity(JSON.parse(readFileSync(path, "utf-8")) as Pick<RunnerAuthLite, "identity_base_url">);
50
+ }
51
+
52
+ /**
53
+ * The auth file of a backend, from its identity ALONE. Never `authFilePath()`: it honours
54
+ * `VIBER_AUTH_FILE`, which would make every folder answer through one same file.
55
+ */
56
+ export function authFileNameFor(identity: string): string {
57
+ if (isDevBackend(identity)) return "dev.auth.json";
58
+ if (isQaBackend(identity)) return "qa.auth.json";
59
+ return "auth.json";
60
+ }
61
+
62
+ const LOCK_WAIT_MS = 5000;
63
+ const LOCK_RETRY_MS = 25;
64
+ /** A write holds the lock for milliseconds: no OS query before this. NOT a proof of death. */
65
+ const LOCK_JUDGE_AFTER_MS = 1000;
66
+ /** `performance.timeOrigin` is the runtime start, a little after the OS creation time. */
67
+ const START_TOLERANCE_MS = 5000;
68
+
69
+ function pause(ms: number): void {
70
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
71
+ }
72
+
73
+ /**
74
+ * When the process `pid` was created (epoch ms), `null` when there is no such process, `NaN` when
75
+ * it exists but its creation time cannot be read. A pid ALONE does not identify a process on
76
+ * Windows: pids are recycled (repo sheet, trap 5).
77
+ */
78
+ export function processStart(pid: number): number | null {
79
+ if (process.platform === "win32") {
80
+ try {
81
+ const out = execFileSync(
82
+ "powershell",
83
+ ["-NoProfile", "-Command", `$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue; if ($p) { $p.StartTime.ToUniversalTime().ToString('o') }; exit 0`],
84
+ { encoding: "utf-8" },
85
+ ).trim();
86
+ // `exit 0`: a missing pid makes Get-Process fail even when silenced (measured: exit 1).
87
+ return out === "" ? null : Date.parse(out);
88
+ } catch {
89
+ return Number.NaN;
90
+ }
91
+ }
92
+ try {
93
+ process.kill(pid, 0);
94
+ return Number.NaN;
95
+ } catch (err) {
96
+ return (err as NodeJS.ErrnoException).code === "EPERM" ? Number.NaN : null;
97
+ }
98
+ }
99
+
100
+ export interface TableFs {
101
+ rename: (from: string, to: string) => void;
102
+ processStart: (pid: number) => number | null;
103
+ /** This process: its pid and its creation time, written into the lock. */
104
+ self: { pid: number; start: number };
105
+ }
106
+
107
+ const INTEGER_KEY = /^\d+$/;
108
+
109
+ function integerEntries(o: unknown): Record<string, string> {
110
+ const out: Record<string, string> = {};
111
+ if (typeof o === "object" && o !== null) {
112
+ for (const [k, v] of Object.entries(o)) if (INTEGER_KEY.test(k) && typeof v === "string") out[k] = v;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ interface Table {
118
+ /** Folders of THIS identity, read at launch. */
119
+ folders: Record<string, string>;
120
+ /** Entries of the flat format of before #674: no identity, never read at launch, settled by the catch-up. */
121
+ unverified: Record<string, string>;
122
+ }
123
+
124
+ /** The project -> local folder table: recorded at connect (or picked), then read at launch. */
125
+ export class ProjectFolders {
126
+ private readonly path: string;
127
+ constructor(
128
+ home: string,
129
+ private readonly identity: string,
130
+ private readonly fs: TableFs = { rename: renameSync, processStart, self: { pid: process.pid, start: Math.round(performance.timeOrigin) } },
131
+ ) {
132
+ this.path = join(home, "projects.json");
133
+ }
134
+
135
+ /** A damaged file fails closed: it is neither re-asked nor overwritten. */
136
+ private table(): Table {
137
+ if (!existsSync(this.path)) return { folders: {}, unverified: {} };
138
+ const raw: unknown = JSON.parse(readFileSync(this.path, "utf-8"));
139
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error(`${this.path} is not a JSON object`);
140
+ const r = raw as Record<string, unknown>;
141
+ if (typeof r.folders !== "object" || r.folders === null) {
142
+ // The flat format (an old binary may also have left an `identity` key in it: integer keys only).
143
+ return { folders: {}, unverified: integerEntries(r) };
144
+ }
145
+ if (r.identity !== this.identity) return { folders: {}, unverified: {} }; // another backend's table
146
+ return { folders: integerEntries(r.folders), unverified: integerEntries(r.unverified) };
147
+ }
148
+
149
+ all(): Record<string, string> {
150
+ return this.table().folders;
151
+ }
152
+
153
+ unverified(): Record<string, string> {
154
+ return this.table().unverified;
155
+ }
156
+
157
+ get(projectId: number): string | undefined {
158
+ return this.table().folders[String(projectId)];
159
+ }
160
+
161
+ set(projectId: number, folder: string): void {
162
+ this.locked(() => {
163
+ const t = this.table();
164
+ t.folders[String(projectId)] = folder;
165
+ this.write(t);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * The catch-up's single write (D8 b): adds each found folder whose id has no LIVE entry (re-read
171
+ * under the lock — a connect may have written meanwhile), and settles the unverified entries.
172
+ */
173
+ settleCatchUp(found: Record<string, string>, exists: (folder: string) => boolean): string[] {
174
+ const added: string[] = [];
175
+ this.locked(() => {
176
+ const t = this.table();
177
+ for (const [id, folder] of Object.entries(found)) {
178
+ const current = t.folders[id];
179
+ if (current !== undefined && exists(current)) continue;
180
+ t.folders[id] = folder;
181
+ added.push(id);
182
+ }
183
+ this.write({ folders: t.folders, unverified: {} });
184
+ });
185
+ return added;
186
+ }
187
+
188
+ private write(t: Table): void {
189
+ const body: Record<string, unknown> = { identity: this.identity, folders: t.folders };
190
+ if (Object.keys(t.unverified).length > 0) body.unverified = t.unverified;
191
+ const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
192
+ writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`);
193
+ try {
194
+ this.fs.rename(tmp, this.path);
195
+ } catch (err) {
196
+ try {
197
+ unlinkSync(tmp);
198
+ } catch {
199
+ /* nothing left behind */
200
+ }
201
+ throw err;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The lock is PUBLISHED COMPLETE (review Codex/Opus): its content is written to a unique temp
207
+ * file, then hard-linked to the canonical name, which fails if it exists. A crash can therefore
208
+ * never leave an empty or half-written lock of ours behind.
209
+ */
210
+ private locked(work: () => void): void {
211
+ mkdirSync(dirname(this.path), { recursive: true });
212
+ const lock = `${this.path}.lock`;
213
+ const mine = JSON.stringify({ pid: this.fs.self.pid, start: this.fs.self.start, at: Date.now(), nonce: Math.random() });
214
+ const draft = `${lock}.${this.fs.self.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
215
+ writeFileSync(draft, mine);
216
+ const deadline = Date.now() + LOCK_WAIT_MS;
217
+ try {
218
+ for (;;) {
219
+ try {
220
+ linkSync(draft, lock);
221
+ break;
222
+ } catch (err) {
223
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
224
+ // A takeover may take a while (the OS probe): retry at once, before judging the deadline.
225
+ if (this.takeOverIfDead(lock)) continue;
226
+ if (Date.now() > deadline) {
227
+ throw new Error(`${lock} is held by another writer — if no Viber process is running, delete that file`);
228
+ }
229
+ pause(LOCK_RETRY_MS);
230
+ }
231
+ }
232
+ } finally {
233
+ try {
234
+ unlinkSync(draft);
235
+ } catch {
236
+ /* best effort */
237
+ }
238
+ }
239
+ try {
240
+ work();
241
+ } finally {
242
+ // Only OUR lock: never remove one a taker re-created after judging ours dead.
243
+ try {
244
+ if (readFileSync(lock, "utf-8") === mine) unlinkSync(lock);
245
+ } catch {
246
+ /* already gone */
247
+ }
248
+ }
249
+ }
250
+
251
+
252
+ /**
253
+ * A lock is taken over only when its holder is GONE: its pid no longer exists, or that pid now
254
+ * belongs to a process created at another time (recycled). Never on age alone — a suspended or
255
+ * sleeping writer is alive. By RENAME to a unique name, then checked to be the very lock judged
256
+ * dead: two takers cannot both win, and a lock re-taken meanwhile is put back.
257
+ */
258
+ private takeOverIfDead(lock: string): boolean {
259
+ let judged: string;
260
+ try {
261
+ judged = readFileSync(lock, "utf-8");
262
+ } catch {
263
+ return true; // released meanwhile: retry at once
264
+ }
265
+ let owner: { pid?: unknown; start?: unknown; at?: unknown } | undefined;
266
+ try {
267
+ owner = JSON.parse(judged) as typeof owner;
268
+ } catch {
269
+ owner = undefined;
270
+ }
271
+ if (owner === undefined || typeof owner !== "object" || owner === null || typeof owner.pid !== "number") {
272
+ // Unreadable: never one of OURS (published complete) — a crash of an older writer, or damage.
273
+ // Not "being written": dead once it is older than a write could take (review Opus).
274
+ try {
275
+ if (Date.now() - statSync(lock).mtimeMs < LOCK_JUDGE_AFTER_MS) return false;
276
+ } catch {
277
+ return true; // gone meanwhile
278
+ }
279
+ } else {
280
+ if (typeof owner.at === "number" && Date.now() - owner.at < LOCK_JUDGE_AFTER_MS) return false;
281
+ const now = this.fs.processStart(owner.pid);
282
+ const recycled =
283
+ now !== null && !Number.isNaN(now) && typeof owner.start === "number" && Math.abs(now - owner.start) > START_TOLERANCE_MS;
284
+ if (now !== null && !recycled) return false; // alive, or unknown: alive
285
+ }
286
+ const grave = `${lock}.${process.pid}.${Date.now()}.dead`;
287
+ try {
288
+ this.fs.rename(lock, grave);
289
+ } catch {
290
+ return true; // another taker won: retry at once
291
+ }
292
+ let taken = "";
293
+ try {
294
+ taken = readFileSync(grave, "utf-8");
295
+ } catch {
296
+ /* unreadable: treat as not ours */
297
+ }
298
+ const ours = taken === judged;
299
+ if (!ours) {
300
+ try {
301
+ linkSync(grave, lock); // put the live writer's lock back, only if none was created since
302
+ } catch {
303
+ /* a new lock exists: leave it */
304
+ }
305
+ }
306
+ try {
307
+ unlinkSync(grave);
308
+ } catch {
309
+ /* best effort */
310
+ }
311
+ return ours;
312
+ }
313
+ }
314
+
315
+ export type RecordOutcome =
316
+ | { kind: "recorded"; home: string; folder: string }
317
+ | { kind: "skipped"; reason: string }
318
+ | { kind: "failed"; reason: string };
319
+
320
+ /**
321
+ * D8 (a): after a connect wrote `<folder>/.viber/<authFile>`, record `projectId -> folder` in the
322
+ * app's table — ONLY when the app serves that backend. The connect is the authority: it replaces
323
+ * an older entry. The folder is its realpath, the form the fingerprint hashes. Never throws: a
324
+ * connect must succeed even when this does not.
325
+ */
326
+ export function recordConnectedFolder(
327
+ projectId: number,
328
+ folder: string,
329
+ authPath: string,
330
+ env: NodeJS.ProcessEnv = process.env,
331
+ ): RecordOutcome {
332
+ try {
333
+ const home = appHome(env);
334
+ const identity = appIdentity(home);
335
+ if (identity === undefined) return { kind: "skipped", reason: "the Viber app is not enrolled on this machine" };
336
+ const served = authFileNameFor(identity);
337
+ if (basename(authPath) !== served) return { kind: "skipped", reason: `the Viber app serves another backend (${identity})` };
338
+ const real = realpathSync(folder);
339
+ new ProjectFolders(home, identity).set(projectId, real);
340
+ return { kind: "recorded", home, folder: real };
341
+ } catch (err) {
342
+ return { kind: "failed", reason: (err as Error).message };
343
+ }
344
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {