viber-channel 0.8.8 → 0.8.9
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/auth.ts +5 -0
- package/lib/base_urls.ts +250 -0
- package/lib/bridge_core.ts +200 -34
- package/lib/bridge_tool_host.ts +24 -8
- package/lib/channel_instructions.ts +87 -15
- package/lib/channel_session.ts +10 -0
- package/lib/claude_tool_defs.ts +124 -0
- package/lib/control_stream.ts +9 -2
- package/lib/conversation.ts +6 -0
- package/lib/dm_stream.ts +7 -2
- package/lib/hash_key.ts +65 -0
- package/lib/heartbeat.ts +13 -4
- package/lib/instance.ts +16 -2
- package/lib/lockfile.ts +9 -0
- package/lib/urls.ts +24 -3
- package/package.json +1 -1
- package/viber-channel.ts +46 -121
- package/viber-codex-bridge.ts +72 -27
- package/viber-gemma-bridge.ts +28 -8
package/lib/auth.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface AuthJson {
|
|
|
41
41
|
* pull dev behavior into staging. Derived from `VIBER_BASE_URL`, which the
|
|
42
42
|
* launcher always sets per env.
|
|
43
43
|
*/
|
|
44
|
+
// #480: this raw `VIBER_BASE_URL` read is DELIBERATE — do not "finish the
|
|
45
|
+
// refactor" by swapping in identityBaseUrl(). This is env CLASSIFICATION, and the
|
|
46
|
+
// `""` default is meaningful: it distinguishes "unset" (⇒ not dev) from a real
|
|
47
|
+
// value, whereas identityBaseUrl() substitutes the production host. It must also
|
|
48
|
+
// never see the API transport host, which carries no "viber-dev" marker.
|
|
44
49
|
export function isDevBackend(
|
|
45
50
|
baseUrl: string = process.env.VIBER_BASE_URL ?? "",
|
|
46
51
|
): boolean {
|
package/lib/base_urls.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base_urls.ts — the TWO base URLs a Viber client deals with (#480).
|
|
3
|
+
*
|
|
4
|
+
* Before #480 there was one value, `VIBER_BASE_URL`, playing two incompatible
|
|
5
|
+
* roles: a network address AND an identity key. #480 points agent REST traffic
|
|
6
|
+
* at a different host (`viber-api-staging.dgypx.dev`) so it stops going through
|
|
7
|
+
* the billed Pages Function — which is only safe once the two roles are split.
|
|
8
|
+
*
|
|
9
|
+
* identityBaseUrl — the LOGICAL backend identity. Feeds the session-file and
|
|
10
|
+
* lock-file hashes (#269), user-facing messages, and the
|
|
11
|
+
* env classification. Must stay stable across the switch.
|
|
12
|
+
*
|
|
13
|
+
* apiBaseUrl — the host actually contacted over HTTP. May differ from
|
|
14
|
+
* the identity, and may CHANGE at runtime once the server
|
|
15
|
+
* announces `api_base_url`.
|
|
16
|
+
* NOT always normalized: an explicit `VIBER_API_BASE_URL`
|
|
17
|
+
* is, but the FALLBACK returns the identity **verbatim**
|
|
18
|
+
* (normalizing it would break a self-host served under a
|
|
19
|
+
* path prefix, and would make this module's introduction a
|
|
20
|
+
* behaviour change instead of a refactor). So callers must
|
|
21
|
+
* never assume a canonical form — compare ORIGINS, not
|
|
22
|
+
* strings.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ NEVER normalize `identityBaseUrl`.
|
|
25
|
+
* The session/lock hashes are taken over the RAW string (see `lockFilePath`,
|
|
26
|
+
* `sessionFilePath`), not over a parsed origin. Trimming a trailing slash or
|
|
27
|
+
* lower-casing the host would move EVERY hash: live agents would lose their
|
|
28
|
+
* conversation handles (re-minting a fresh conversation) and release their
|
|
29
|
+
* locks (re-opening the #311/#293 duplicate window). Normalization belongs to
|
|
30
|
+
* `apiBaseUrl` alone — hence the two distinct functions below, deliberately not
|
|
31
|
+
* factored into one.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The canonical public backend, used when nothing is configured. */
|
|
35
|
+
export const DEFAULT_BASE_URL = "https://viber.dgypx.dev";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The logical backend identity — returned VERBATIM, never normalized.
|
|
39
|
+
*
|
|
40
|
+
* This is byte-for-byte what the code used before #480
|
|
41
|
+
* (`process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev"`), so every existing
|
|
42
|
+
* session-file and lock-file path keeps hashing to the same value.
|
|
43
|
+
*/
|
|
44
|
+
export function identityBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
45
|
+
return env.VIBER_BASE_URL ?? DEFAULT_BASE_URL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a host meant for HTTP transport: origin only, no trailing slash.
|
|
50
|
+
*
|
|
51
|
+
* Applied to `apiBaseUrl` ONLY. Throws on anything that isn't a plain absolute
|
|
52
|
+
* origin, so a malformed override fails loudly at startup instead of producing
|
|
53
|
+
* `fetch()` calls against a nonsense address.
|
|
54
|
+
*
|
|
55
|
+
* This checks the **form**, and one safety property: `http:` is allowed only on
|
|
56
|
+
* localhost, mirroring `isTrustedViberOrigin`. Plain-text to an arbitrary host
|
|
57
|
+
* would expose the bearer tokens the client sends. WHICH hosts are trusted is a
|
|
58
|
+
* separate decision made by `isTrustedViberOrigin` (step-04) — passing this
|
|
59
|
+
* function is necessary, never sufficient.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeApiBaseUrl(raw: string): string {
|
|
62
|
+
let u: URL;
|
|
63
|
+
try {
|
|
64
|
+
u = new URL(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Invalid API base URL (not an absolute URL): ${raw}`);
|
|
67
|
+
}
|
|
68
|
+
const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
69
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && isLocalhost)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Invalid API base URL (must be https, or http on localhost): ${raw}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (u.username || u.password) {
|
|
75
|
+
throw new Error(`Invalid API base URL (must not carry credentials): ${raw}`);
|
|
76
|
+
}
|
|
77
|
+
if (u.search || u.hash) {
|
|
78
|
+
throw new Error(`Invalid API base URL (must not carry query or fragment): ${raw}`);
|
|
79
|
+
}
|
|
80
|
+
if (u.pathname !== "/" && u.pathname !== "") {
|
|
81
|
+
throw new Error(`Invalid API base URL (must be an origin, no path): ${raw}`);
|
|
82
|
+
}
|
|
83
|
+
return u.origin;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The host to contact over HTTP, at BOOTSTRAP.
|
|
88
|
+
*
|
|
89
|
+
* Precedence: `VIBER_API_BASE_URL` (dev / self-host escape hatch) > the identity.
|
|
90
|
+
* The server-announced value is NOT here — it cannot be: it only arrives with the
|
|
91
|
+
* first authenticated response. See `ApiBaseUrlResolver`.
|
|
92
|
+
*/
|
|
93
|
+
export function apiBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
94
|
+
const override = env.VIBER_API_BASE_URL;
|
|
95
|
+
if (override) return normalizeApiBaseUrl(override);
|
|
96
|
+
return identityBaseUrl(env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Outcome of offering an announced host to the resolver — for logs and tests. */
|
|
100
|
+
export type AdoptionOutcome =
|
|
101
|
+
| { adopted: true; value: string }
|
|
102
|
+
| { adopted: false; reason: string };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The API host as a piece of STATE, not a constant (#480 step-04).
|
|
106
|
+
*
|
|
107
|
+
* `api_base_url` is announced by the server, so it is unknown until the first
|
|
108
|
+
* authenticated response (mint / join / resume) comes back. The first call
|
|
109
|
+
* therefore ALWAYS goes to the bootstrap host; adoption happens afterwards.
|
|
110
|
+
* Modelling this as a resolver object rather than a mutable module global keeps
|
|
111
|
+
* the ownership explicit and makes it testable without touching `process.env`.
|
|
112
|
+
*
|
|
113
|
+
* Security rules, deliberately enforced HERE rather than at each call site —
|
|
114
|
+
* the client sends `project_token`, `instance_token`, `conversation_token` and
|
|
115
|
+
* (for runners) `runner_token` to whatever host this returns:
|
|
116
|
+
*
|
|
117
|
+
* 1. An announced host must pass `isTrustedViberOrigin` BEFORE adoption. That
|
|
118
|
+
* allowlist is STATIC — hard-coded hosts plus the LOCAL env overrides. It is
|
|
119
|
+
* never fed the announced value: doing so would be circular and the
|
|
120
|
+
* allowlist would protect nothing.
|
|
121
|
+
* 2. A rejected announcement is IGNORED with a warning — never a hard failure.
|
|
122
|
+
* The bootstrap host works, so refusing to start would turn a server-side
|
|
123
|
+
* misconfiguration into a dead agent (mirror of the server's own choice in
|
|
124
|
+
* step-03).
|
|
125
|
+
* 3. An explicit `VIBER_API_BASE_URL` WINS over any announcement: an operator
|
|
126
|
+
* debugging against a specific host must not be silently redirected.
|
|
127
|
+
*/
|
|
128
|
+
export class ApiBaseUrlResolver {
|
|
129
|
+
private readonly bootstrap: string;
|
|
130
|
+
private readonly pinnedByEnv: boolean;
|
|
131
|
+
private adopted: string | null = null;
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly isTrusted: (origin: string) => boolean,
|
|
135
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
136
|
+
private readonly warn: (msg: string) => void = (msg) => process.stderr.write(msg),
|
|
137
|
+
) {
|
|
138
|
+
this.bootstrap = apiBaseUrl(env);
|
|
139
|
+
this.pinnedByEnv = (env.VIBER_API_BASE_URL ?? "") !== "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The host to use right now. */
|
|
143
|
+
current(): string {
|
|
144
|
+
return this.adopted ?? this.bootstrap;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* True once the agent has actually MOVED off its bootstrap host.
|
|
149
|
+
*
|
|
150
|
+
* Deliberately not "an announcement was accepted": an announcement equal to the
|
|
151
|
+
* bootstrap host leaves this false, so the flag can be used as-is to measure
|
|
152
|
+
* real adoption across the fleet (step-07).
|
|
153
|
+
*/
|
|
154
|
+
hasAdopted(): boolean {
|
|
155
|
+
return this.adopted !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Offer a server-announced host. Safe to call repeatedly — every response
|
|
160
|
+
* carries the field, so this runs on a hot-ish path and must be idempotent.
|
|
161
|
+
*/
|
|
162
|
+
offer(announced: string | undefined | null): AdoptionOutcome {
|
|
163
|
+
if (announced === undefined || announced === null || announced.trim() === "") {
|
|
164
|
+
return { adopted: false, reason: "not announced" };
|
|
165
|
+
}
|
|
166
|
+
if (this.pinnedByEnv) {
|
|
167
|
+
return { adopted: false, reason: "pinned by VIBER_API_BASE_URL" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let normalized: string;
|
|
171
|
+
try {
|
|
172
|
+
normalized = normalizeApiBaseUrl(announced);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return this.refuse(announced, err instanceof Error ? err.message : String(err));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Rule 1. Note this is the ONLY gate that decides trust; the form check above
|
|
178
|
+
// is necessary but never sufficient.
|
|
179
|
+
if (!this.isTrusted(normalized)) {
|
|
180
|
+
return this.refuse(announced, "origin is not in the static allowlist");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// An announcement EQUAL to the host we already use is a no-op, and must not
|
|
184
|
+
// count as an adoption: step-07 measures the share of the fleet that actually
|
|
185
|
+
// moved off the web host, and a dev server announcing its own bootstrap host
|
|
186
|
+
// would otherwise inflate that number with agents that never moved.
|
|
187
|
+
//
|
|
188
|
+
// Compare ORIGINS, not strings: the bootstrap value may be un-normalized (see
|
|
189
|
+
// the module header), so `https://h` vs `https://h/` is the SAME host and a
|
|
190
|
+
// string comparison would report a spurious move.
|
|
191
|
+
if (sameOrigin(normalized, this.bootstrap)) {
|
|
192
|
+
return { adopted: false, reason: "same origin as the bootstrap host" };
|
|
193
|
+
}
|
|
194
|
+
// Already on this host: the state is right either way, but re-reporting an
|
|
195
|
+
// adoption would inflate any COUNT of adoptions — and step-07 counts.
|
|
196
|
+
if (this.adopted !== null && sameOrigin(normalized, this.adopted)) {
|
|
197
|
+
return { adopted: false, reason: "already adopted" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.adopted = normalized;
|
|
201
|
+
// Logged, not silent: step-06's single-agent test needs an OBSERVABLE proof
|
|
202
|
+
// that this agent moved. Without this line the discriminant was written but
|
|
203
|
+
// not executable — `hasAdopted()` existed only in memory and in tests
|
|
204
|
+
// (found in review). One line per agent per run, at adoption only.
|
|
205
|
+
this.warn(
|
|
206
|
+
`[viber-channel] api host adopted: ${normalized} (was ${this.bootstrap}) — #480
|
|
207
|
+
`,
|
|
208
|
+
);
|
|
209
|
+
return { adopted: true, value: normalized };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private refuse(announced: string, reason: string): AdoptionOutcome {
|
|
213
|
+
// Rule 2: warn, keep working. Logged once per offer — callers only offer on
|
|
214
|
+
// a mint/join, not per request.
|
|
215
|
+
this.warn(
|
|
216
|
+
`[viber-channel] ignoring announced api_base_url (${reason}): ${announced} — staying on ${this.current()}\n`,
|
|
217
|
+
);
|
|
218
|
+
return { adopted: false, reason };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Make a refused announcement safe to log — mirror of the server's `redactForLog`.
|
|
224
|
+
*
|
|
225
|
+
* Keeps the host (so the operator can recognize the offending value) and drops
|
|
226
|
+
* userinfo. An unparseable value is never echoed: it is not a URL, so it could be
|
|
227
|
+
* anything, and a length-based cut would still print a short secret in full.
|
|
228
|
+
*/
|
|
229
|
+
export function redactAnnounced(raw: string): string {
|
|
230
|
+
try {
|
|
231
|
+
const u = new URL(raw);
|
|
232
|
+
if (u.username || u.password) {
|
|
233
|
+
u.username = "";
|
|
234
|
+
u.password = "";
|
|
235
|
+
return `${u.toString()} (credentials redacted)`;
|
|
236
|
+
}
|
|
237
|
+
return u.toString();
|
|
238
|
+
} catch {
|
|
239
|
+
return `<unparseable, ${raw.length} chars>`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** True if both strings denote the same origin. Tolerates un-normalized input. */
|
|
244
|
+
export function sameOrigin(a: string, b: string): boolean {
|
|
245
|
+
try {
|
|
246
|
+
return new URL(a).origin === new URL(b).origin;
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|