acbridge 0.0.1 → 1.0.0
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/LICENSE +40 -0
- package/README.md +69 -3
- package/dist/acbridge.mjs +317 -0
- package/hub/dist/hub.cjs +189 -0
- package/package.json +32 -4
- package/plugin/.claude-plugin/marketplace.json +17 -0
- package/plugin/README.md +223 -0
- package/plugin/plugins/alexa/.claude-plugin/plugin.json +13 -0
- package/plugin/plugins/alexa/.mcp.json +8 -0
- package/plugin/plugins/alexa/dist/channel.mjs +165 -0
- package/plugin/plugins/alexa/hooks/hooks.json +166 -0
- package/plugin/plugins/alexa/scripts/codex-signal.mjs +406 -0
- package/plugin/plugins/alexa/scripts/gate.mjs +99 -0
- package/plugin/plugins/alexa/scripts/scope.mjs +270 -0
- package/plugin/plugins/alexa/scripts/signal.mjs +503 -0
- package/plugin/plugins/alexa/scripts/statusline-guard.mjs +75 -0
- package/plugin/plugins/alexa/scripts/statusline.mjs +322 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Shared scope helper for the Alexa voice-bridge hook scripts (signal.mjs + statusline.mjs) — AS-T9f.
|
|
2
|
+
//
|
|
3
|
+
// TWO jobs, both born from AS-T9's "one identity authority" move (the relay credential + hub-token go
|
|
4
|
+
// under SECRET_DIR/users/<segment>/ so the active-user pointer, the credential, and the hub's pinned
|
|
5
|
+
// scope can never name different accounts):
|
|
6
|
+
//
|
|
7
|
+
// A. SCOPED HUB-TOKEN FALLBACK — resolveHubTokenFile() extends the hooks' hub-token read chain with a
|
|
8
|
+
// scoped step: env (BRIDGE_HUB_TOKEN_FILE) → handshake.hubTokenFile → users/<seg>/hub-token (from the
|
|
9
|
+
// active-user pointer) → machine-global SECRET_DIR/hub-token. At layout <3 there are no users/<seg>/
|
|
10
|
+
// dirs, so the scoped step finds nothing and falls to machine-global — byte-identical to today.
|
|
11
|
+
//
|
|
12
|
+
// B. ACCOUNT-STAMPED PLAIN-SESSION HANDSHAKE (the owner-approved restore) — a plain `claude` (no
|
|
13
|
+
// --channels) has NO handshake file, so AS-T8b's adoption gate classifies its session-state POSTs as
|
|
14
|
+
// "absent" under a pinned hub and DROPS them → auto-profiles stopped firing. writePlainSessionHandshake
|
|
15
|
+
// IfAbsent() re-attributes such a session to the SIGNED-IN account by writing a stamped handshake the
|
|
16
|
+
// hub adopts (sub === pinned scope). No signed-in account ⇒ nothing is written (an unattributable
|
|
17
|
+
// session must never be adopted). It NEVER overwrites an `acbridge run` / channel file (those own their
|
|
18
|
+
// own identity + cleanup); its `selfWritten:true` marker also lets gate.mjs keep dev-freedom (a plain
|
|
19
|
+
// session is not voice-reachable — no Alexa `reply` egress).
|
|
20
|
+
//
|
|
21
|
+
// LEAF DISCIPLINE: like the scripts that import it, this file is shipped in the plugin and runs OUTSIDE the
|
|
22
|
+
// monorepo, so it imports ONLY node builtins — never @bridge/* (which won't resolve in Claude's plugin
|
|
23
|
+
// cache). userScopeSegment REPLICATES @bridge/node-core's (credential-paths.ts); the node-core drift
|
|
24
|
+
// cross-check test pins the two in agreement so this copy can't silently diverge.
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
// ── sub → segment — a VERBATIM replica of @bridge/node-core credential-paths.ts userScopeSegment ────────
|
|
32
|
+
// A WorkOS `sub` is normally short + URL-safe (`user_01H...`) but it's still external input: verbatim only
|
|
33
|
+
// when it matches this safe, bounded charset (and has no interior `..`, which the path guard forbids), else
|
|
34
|
+
// a deterministic content hash so an anomalous sub can never escape users/ or collide across accounts.
|
|
35
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
|
|
36
|
+
|
|
37
|
+
export function userScopeSegment(sub) {
|
|
38
|
+
if (SAFE_SEGMENT.test(sub) && !sub.includes("..")) return sub;
|
|
39
|
+
return `h_${createHash("sha256").update(sub, "utf8").digest("hex").slice(0, 24)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── active-user pointer (sub only) ─────────────────────────────────────────────────────────────────────
|
|
43
|
+
/** The active-user pointer's `sub`, or null — absent/corrupt/sub-less all degrade to null, never a throw. */
|
|
44
|
+
export function readActiveUserSub(secretDir) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(path.join(secretDir, "active-user.json"), "utf8"));
|
|
47
|
+
return parsed && typeof parsed.sub === "string" && parsed.sub ? parsed.sub : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Part A: the scoped hub-token step + the full fallback resolver ──────────────────────────────────────
|
|
54
|
+
/** The account's scoped hub-token file IF the pointer selects one AND it exists on disk, else null. Its
|
|
55
|
+
* mere-existence gate is what makes layout <3 (no users/<seg>/ dir) fall straight through to machine-global,
|
|
56
|
+
* matching cli/node-process.ts resolveHubTokenPath's layout<3 → HUB_TOKEN_FILE behavior. */
|
|
57
|
+
export function scopedHubTokenFile(secretDir) {
|
|
58
|
+
const sub = readActiveUserSub(secretDir);
|
|
59
|
+
if (!sub) return null;
|
|
60
|
+
const p = path.join(secretDir, "users", userScopeSegment(sub), "hub-token");
|
|
61
|
+
return existsSync(p) ? p : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The hub-token path a hook should read. `explicit` (the env var or the handshake file's hubTokenFile
|
|
65
|
+
* field) is authoritative when present; otherwise scoped-first, then the machine-global fallback. */
|
|
66
|
+
export function resolveHubTokenFile(secretDir, explicit) {
|
|
67
|
+
if (explicit) return explicit;
|
|
68
|
+
return scopedHubTokenFile(secretDir) ?? path.join(secretDir, "hub-token");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Part B: the account-stamped plain-session handshake self-write + its clean-exit cleanup ─────────────
|
|
72
|
+
/**
|
|
73
|
+
* Atomically write `sessions/<claudeSessionId>.json` stamped for the signed-in account, so the hub's
|
|
74
|
+
* adoption gate (resolveHandshakeSub → classifyHandshakeAdoption) accepts a plain session's signals when
|
|
75
|
+
* its `sub` matches the pinned scope. Returns the written path, or null when it wrote nothing:
|
|
76
|
+
* - no `claudeSessionId` → no adoption key (the hub joins the POST's claudeSessionId to this file's name);
|
|
77
|
+
* - no `sub` → an unattributable session must NEVER be adopted (the standing account-isolation rule);
|
|
78
|
+
* - an EXISTING file → an `acbridge run` / channel voice writer already owns this session's identity +
|
|
79
|
+
* its cleanup; never clobber it (that would also strip the file's voice-ness from gate.mjs's view).
|
|
80
|
+
* The shape matches the canonical writers (cli run.ts buildHandshakeRecord / channel handshake.ts) PLUS a
|
|
81
|
+
* `selfWritten:true` marker. Best-effort — an unwritable sessions dir degrades to "no handshake" (dormant,
|
|
82
|
+
* exactly the pre-restore behavior), never a throw (a hook must never block Claude).
|
|
83
|
+
*/
|
|
84
|
+
export function writePlainSessionHandshakeIfAbsent({ secretDir, id, hubPort, hubTokenFile, cwd, claudeSessionId, sub }) {
|
|
85
|
+
if (!claudeSessionId || !sub) return null;
|
|
86
|
+
const file = path.join(secretDir, "sessions", `${claudeSessionId}.json`);
|
|
87
|
+
if (existsSync(file)) return null;
|
|
88
|
+
const record = {
|
|
89
|
+
id,
|
|
90
|
+
sub,
|
|
91
|
+
hubTokenFile,
|
|
92
|
+
hubPort,
|
|
93
|
+
...(cwd ? { cwd } : {}),
|
|
94
|
+
claudeSessionId,
|
|
95
|
+
selfWritten: true,
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
99
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
100
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
101
|
+
renameSync(tmp, file);
|
|
102
|
+
return file;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Clean-exit cleanup: remove a plain-session handshake THIS hook wrote (selfWritten only — a voice
|
|
109
|
+
* writer's file is left for the channel / `acbridge run` to clean). Best-effort; a crash that skips
|
|
110
|
+
* SessionEnd leaves a harmless stale file (its claudeSessionId — a UUID — never recurs, so it can't be
|
|
111
|
+
* re-adopted; a future sessions-dir sweep can reap the accumulation). */
|
|
112
|
+
export function cleanupPlainSessionHandshake(secretDir, claudeSessionId) {
|
|
113
|
+
if (!claudeSessionId) return false;
|
|
114
|
+
const file = path.join(secretDir, "sessions", `${claudeSessionId}.json`);
|
|
115
|
+
try {
|
|
116
|
+
const h = JSON.parse(readFileSync(file, "utf8"));
|
|
117
|
+
if (!h || h.selfWritten !== true) return false;
|
|
118
|
+
rmSync(file, { force: true });
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Part C: WHICH `.claude-bridge` home holds the live hub ───────────────────────────────────────────
|
|
126
|
+
// A VERBATIM behavioural replica of @bridge/node-core's bridge-home.ts (leaf discipline again: a shipped
|
|
127
|
+
// plugin script cannot import the workspace). node-core/test/bridge-home-drift.test.ts is the ONE place
|
|
128
|
+
// both run under a single runtime, and it pins them scenario-for-scenario.
|
|
129
|
+
//
|
|
130
|
+
// Why any of this exists: with WSL mirrored networking the hooks' POST to 127.0.0.1:8790 reaches a hub
|
|
131
|
+
// running on WINDOWS, while every piece of state they resolve (hub-token, active-user.json, sessions/)
|
|
132
|
+
// comes from the WSL home the Windows hub has never heard of. Live on 2026-07-24 that produced a 401 on
|
|
133
|
+
// every signal, and — once the token was right — an account-scope "absent" drop, because the handshake
|
|
134
|
+
// was written on the wrong side of the boundary. Both failures are silent (hooks exit 0 by contract), so
|
|
135
|
+
// "my lights stopped working" had no evidence anywhere.
|
|
136
|
+
|
|
137
|
+
/** The hub's liveness beacon, written in its own home at boot and removed on a clean exit. */
|
|
138
|
+
export const HUB_BEACON_BASENAME = "hub-endpoint.json";
|
|
139
|
+
/** Bound on peer probing: a Windows box with hundreds of profiles must never turn a hook into a 9P crawl. */
|
|
140
|
+
const PEER_PROBE_CAP = 24;
|
|
141
|
+
const SYSTEM_PROFILES = new Set(["public", "default", "default user", "all users", "defaultapppool", "administrator"]);
|
|
142
|
+
|
|
143
|
+
function parseBeacon(raw) {
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
try {
|
|
146
|
+
const j = JSON.parse(raw);
|
|
147
|
+
if (typeof j?.pid !== "number" || typeof j?.port !== "number") return null;
|
|
148
|
+
return {
|
|
149
|
+
pid: j.pid,
|
|
150
|
+
port: j.port,
|
|
151
|
+
startedAt: typeof j.startedAt === "number" ? j.startedAt : 0,
|
|
152
|
+
...(typeof j.sub === "string" && j.sub ? { sub: j.sub } : {}),
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return null; // corrupt/partial write → no beacon, never a throw
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `/etc/wsl.conf`'s `[automount] root` (default `/mnt`) — a WSL user can move the Windows drives. */
|
|
160
|
+
function automountRoot(deps) {
|
|
161
|
+
const conf = deps.readFile("/etc/wsl.conf");
|
|
162
|
+
const m = conf?.match(/^\s*root\s*=\s*(\S+)\s*$/m);
|
|
163
|
+
const root = m?.[1] ?? "/mnt";
|
|
164
|
+
return root.length > 1 && root.endsWith("/") ? root.slice(0, -1) : root;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function peerCandidates(deps) {
|
|
168
|
+
const out = [];
|
|
169
|
+
const push = (p) => {
|
|
170
|
+
if (out.length < PEER_PROBE_CAP && !out.includes(p)) out.push(p);
|
|
171
|
+
};
|
|
172
|
+
const usable = (name) => !SYSTEM_PROFILES.has(name.toLowerCase());
|
|
173
|
+
|
|
174
|
+
if (deps.isWsl) {
|
|
175
|
+
const root = automountRoot(deps);
|
|
176
|
+
for (const drive of deps.listDir(root).filter((d) => /^[a-zA-Z]$/.test(d))) {
|
|
177
|
+
const users = `${root}/${drive}/Users`;
|
|
178
|
+
if (usable(deps.userName)) push(`${users}/${deps.userName}/.claude-bridge`);
|
|
179
|
+
for (const name of deps.listDir(users)) if (usable(name)) push(`${users}/${name}/.claude-bridge`);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (deps.platform === "win32") {
|
|
185
|
+
for (const root of ["\\\\wsl.localhost", "\\\\wsl$"]) {
|
|
186
|
+
for (const distro of deps.listDir(`${root}\\`)) {
|
|
187
|
+
const homes = `${root}\\${distro}\\home`;
|
|
188
|
+
push(`${homes}\\${deps.userName}\\.claude-bridge`);
|
|
189
|
+
for (const name of deps.listDir(homes)) push(`${homes}\\${name}\\.claude-bridge`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The pure resolver — same contract as node-core's: env → live local → live peer → local fallback. */
|
|
197
|
+
export function resolveBridgeHomeWith(deps) {
|
|
198
|
+
const beaconIn = (dir) =>
|
|
199
|
+
parseBeacon(
|
|
200
|
+
deps.readFile(
|
|
201
|
+
dir.endsWith("\\") || dir.endsWith("/")
|
|
202
|
+
? `${dir}${HUB_BEACON_BASENAME}`
|
|
203
|
+
: `${dir}${deps.platform === "win32" ? "\\" : "/"}${HUB_BEACON_BASENAME}`,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const explicit = deps.env["BRIDGE_SECRET_DIR"];
|
|
208
|
+
if (explicit) return { dir: explicit, source: "env", beacon: beaconIn(explicit) };
|
|
209
|
+
|
|
210
|
+
const local = beaconIn(deps.secretDir);
|
|
211
|
+
if (local && deps.pidAlive(local.pid)) return { dir: deps.secretDir, source: "local", beacon: local };
|
|
212
|
+
|
|
213
|
+
let best = null;
|
|
214
|
+
for (const dir of peerCandidates(deps)) {
|
|
215
|
+
const b = beaconIn(dir);
|
|
216
|
+
if (b && (!best || b.startedAt > best.beacon.startedAt)) best = { dir, beacon: b };
|
|
217
|
+
}
|
|
218
|
+
if (best) return { dir: best.dir, source: deps.isWsl ? "peer-wsl" : "peer-windows", beacon: best.beacon };
|
|
219
|
+
|
|
220
|
+
return { dir: deps.secretDir, source: "local", beacon: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Are we inside WSL? Env markers first (set by every modern WSL), /proc/version as the fallback. */
|
|
224
|
+
function detectWslSync(env, platform, readFile) {
|
|
225
|
+
if (platform !== "linux") return false;
|
|
226
|
+
if (env["WSL_DISTRO_NAME"] || env["WSL_INTEROP"]) return true;
|
|
227
|
+
return /microsoft/i.test(readFile("/proc/version") ?? "");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The real resolution for this hook run. Cheap by construction: a peer is probed ONLY when no hub is
|
|
231
|
+
* alive in the local home, and then at most PEER_PROBE_CAP small reads. */
|
|
232
|
+
export function resolveBridgeHome() {
|
|
233
|
+
const readFile = (f) => {
|
|
234
|
+
try {
|
|
235
|
+
return readFileSync(f, "utf8").trim() || null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const listDir = (dir) => {
|
|
241
|
+
try {
|
|
242
|
+
return readdirSync(dir);
|
|
243
|
+
} catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
let userName = "";
|
|
248
|
+
try {
|
|
249
|
+
userName = os.userInfo().username;
|
|
250
|
+
} catch {
|
|
251
|
+
userName = process.env.USER || process.env.USERNAME || "";
|
|
252
|
+
}
|
|
253
|
+
return resolveBridgeHomeWith({
|
|
254
|
+
secretDir: path.join(os.homedir(), ".claude-bridge"),
|
|
255
|
+
env: process.env,
|
|
256
|
+
platform: process.platform,
|
|
257
|
+
userName,
|
|
258
|
+
isWsl: detectWslSync(process.env, process.platform, readFile),
|
|
259
|
+
pidAlive: (pid) => {
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, 0);
|
|
262
|
+
return true;
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return e?.code === "EPERM"; // alive, just not ours to signal
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
readFile,
|
|
268
|
+
listDir,
|
|
269
|
+
});
|
|
270
|
+
}
|