@agent-flow-board/bridge 0.0.0-stage → 0.1.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.
@@ -0,0 +1,116 @@
1
+ // THE STATUS FILE OF A CONVERSATION: WHAT THE BOARD SAYS IT WORKS ON, KEPT BESIDE THE BRIDGE FOR AN EDITOR STATUS LINE.
2
+ // The hook answer carries the status when the bridge asks for it. The bridge writes it to <config dir>/status/<conversation id>.json
3
+ // so the editor mod reads a local file and needs no token. The file holds the status fields only, never a token or a verifier.
4
+ // A hook must never fail or slow down because of this file, so every function here swallows its errors.
5
+ // -- DEP-MODULES -- //
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ /** Files older than this are removed when a conversation starts. */
10
+ export const PURGE_AFTER_MS = 2 * 24 * 60 * 60 * 1000;
11
+ const ENDED = '.ended';
12
+ const TEMP = '.tmp';
13
+ /** A rename over a file another process holds can fail for a moment on Windows (antivirus, a parallel hook). */
14
+ const RENAME_TRIES = 4;
15
+ // -- FUNCTIONS -- //
16
+ /**
17
+ * The file name of a conversation, or null when its id is not plain. An id comes from a tool's payload, so it is never used as a
18
+ * path unless it is made only of letters, digits, dots, dashes and underscores and does not start with a dot.
19
+ */
20
+ export function statusName(externalId) {
21
+ return /^[A-Za-z0-9_-][A-Za-z0-9._-]{7,99}$/.test(externalId) ? externalId : null;
22
+ }
23
+ /** Waits a few milliseconds without spinning, for the retry of a rename. */
24
+ function pause(ms) {
25
+ try {
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ catch {
29
+ /* No wait available: the retry runs at once */
30
+ }
31
+ }
32
+ function writeAtomic(dir, name, body) {
33
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ const temp = join(dir, `${name}.${process.pid}${TEMP}`);
35
+ writeFileSync(temp, body, { mode: 0o600 });
36
+ for (let i = 0; i < RENAME_TRIES; i++) {
37
+ try {
38
+ renameSync(temp, join(dir, `${name}.json`));
39
+ return;
40
+ }
41
+ catch (error) {
42
+ if (i === RENAME_TRIES - 1) {
43
+ rmSync(temp, { force: true });
44
+ throw error;
45
+ }
46
+ pause(15 * (i + 1));
47
+ }
48
+ }
49
+ }
50
+ /** The time the file in place was asked for, or 0 when there is none or it cannot be read. */
51
+ function askedAtOf(dir, name) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(join(dir, `${name}.json`), 'utf8'));
54
+ return typeof parsed.asOf === 'number' ? parsed.asOf : 0;
55
+ }
56
+ catch {
57
+ return 0;
58
+ }
59
+ }
60
+ /** Removes the files nobody asked for in two days: finished conversations, crashed ones and leftovers of a failed write. */
61
+ export function purgeStatus(dir, now = Date.now()) {
62
+ try {
63
+ for (const file of readdirSync(dir)) {
64
+ const path = join(dir, file);
65
+ try {
66
+ if (now - statSync(path).mtimeMs > PURGE_AFTER_MS)
67
+ rmSync(path, { force: true });
68
+ }
69
+ catch {
70
+ /* A file that vanished meanwhile is gone, which is the point */
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ /* No folder yet */
76
+ }
77
+ }
78
+ /**
79
+ * Applies one hook event to the files. `asked` is the time the request left, so two answers that come back in the wrong order never
80
+ * replace a newer file with an older one. It never throws and never prints.
81
+ */
82
+ export function applyStatusFile(dir, event, answer, asked, now = Date.now()) {
83
+ try {
84
+ const name = statusName(event.externalId);
85
+ if (!name)
86
+ return;
87
+ if (event.event === 'end') {
88
+ // A last hook of the same conversation may still be on its way: the marker keeps it from writing the file again
89
+ rmSync(join(dir, `${name}.json`), { force: true });
90
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
91
+ writeFileSync(join(dir, `${name}${ENDED}`), String(now), { mode: 0o600 });
92
+ return;
93
+ }
94
+ if (event.event === 'start') {
95
+ // A resumed conversation starts again under the same id
96
+ rmSync(join(dir, `${name}${ENDED}`), { force: true });
97
+ purgeStatus(dir, now);
98
+ }
99
+ if (!event.status)
100
+ return;
101
+ if (existsSync(join(dir, `${name}${ENDED}`)))
102
+ return;
103
+ if (!answer?.status) {
104
+ // The board did not answer a call that may have changed the claim: an old file would show a wrong item
105
+ if (event.event === 'tool')
106
+ rmSync(join(dir, `${name}.json`), { force: true });
107
+ return;
108
+ }
109
+ if (askedAtOf(dir, name) > asked)
110
+ return;
111
+ writeAtomic(dir, name, JSON.stringify({ v: 1, asOf: asked, writtenAt: now, status: answer.status }));
112
+ }
113
+ catch {
114
+ /* A failed write is never a failed hook */
115
+ }
116
+ }
package/dist/mockup.js ADDED
@@ -0,0 +1,145 @@
1
+ // agent-flow-board mockup push <file.html>: UPLOADS ONE MOCKUP FILE TO THE BOARD OVER /api/v1 WITH THE BRIDGE'S OWN SIGN IN
2
+ // It reads the file by path from the agent's own folder, so the HTML never passes through a model's output. It is never queued, never
3
+ // goes through the MCP forwarder and changes nothing in the bridge protocol: a failed push says so and the agent runs it again.
4
+ // -- DEP-MODULES -- //
5
+ import { randomBytes } from 'node:crypto';
6
+ import { lstatSync, readFileSync } from 'node:fs';
7
+ import { extname } from 'node:path';
8
+ // -- CONSTANTS -- //
9
+ /** The server's own cap for one version (50 MB). The server checks it again, this only stops a hopeless upload early. */
10
+ export const MAX_MOCKUP_BYTES = 50 * 1024 * 1024;
11
+ export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
12
+ const TRIES = 3;
13
+ /** A kit's summary travels in the address, so it is kept short here. */
14
+ const MAX_KIT_SUMMARY = 4000;
15
+ // -- FUNCTIONS -- //
16
+ /** The message of a failed call: the server's own words when it sent them. */
17
+ async function failure(res) {
18
+ try {
19
+ const j = (await res.json());
20
+ if (j.error?.message)
21
+ return `${j.error.message} (${res.status})`;
22
+ }
23
+ catch {
24
+ /* Not JSON */
25
+ }
26
+ return `The server answered ${res.status}.`;
27
+ }
28
+ /** Only a plain HTML or PNG file of a sane size is read: never a link, a folder or some other file an agent was talked into naming. */
29
+ function readFileChecked(path, kinds, max, what) {
30
+ const st = lstatSync(path, { throwIfNoEntry: false });
31
+ if (!st)
32
+ throw new Error(`The ${what} file ${path} does not exist.`);
33
+ if (!st.isFile())
34
+ throw new Error(`The ${what} path ${path} is not a plain file.`);
35
+ if (!kinds.includes(extname(path).toLowerCase()))
36
+ throw new Error(`The ${what} must be a ${kinds.join(' or ')} file.`);
37
+ if (st.size === 0)
38
+ throw new Error(`The ${what} file is empty.`);
39
+ if (st.size > max)
40
+ throw new Error(`The ${what} is ${Math.ceil(st.size / 1024 / 1024)} MB, more than the ${Math.floor(max / 1024 / 1024)} MB limit.`);
41
+ return new Uint8Array(readFileSync(path));
42
+ }
43
+ /** One call, tried again on a network failure or a 5xx with the same request id, so a repeat can never publish twice. */
44
+ async function call(o, method, path, body, headers, requestId) {
45
+ let last = 'The server could not be reached.';
46
+ for (let i = 0; i < TRIES; i++) {
47
+ try {
48
+ const res = await o.fetch(`${o.server}/api/v1${path}`, {
49
+ method,
50
+ body,
51
+ headers: {
52
+ authorization: `Bearer ${o.token}`,
53
+ 'idempotency-key': requestId,
54
+ ...headers
55
+ }
56
+ });
57
+ if (res.status < 500)
58
+ return res;
59
+ last = await failure(res);
60
+ }
61
+ catch (e) {
62
+ last = e instanceof Error ? e.message : String(e);
63
+ }
64
+ if (i < TRIES - 1)
65
+ await (o.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(500 * (i + 1));
66
+ }
67
+ throw new Error(last);
68
+ }
69
+ export async function pushMockup(o) {
70
+ if (!o.token)
71
+ throw new Error('Not signed in. Run: agent-flow-board login --server <url> --org <slug>');
72
+ if (!o.item && !o.project && !o.mockup)
73
+ throw new Error('Name the plan (--item), a project (--project) or the mockup (--mockup).');
74
+ const html = readFileChecked(o.file, ['.html', '.htm'], MAX_MOCKUP_BYTES, 'mockup');
75
+ const shot = o.screenshot
76
+ ? readFileChecked(o.screenshot, ['.png'], MAX_SCREENSHOT_BYTES, 'screenshot')
77
+ : null;
78
+ const id = randomBytes(8).toString('hex');
79
+ const base = `/orgs/${encodeURIComponent(o.org)}`;
80
+ const q = new URLSearchParams();
81
+ for (const [k, v] of [
82
+ ['item', o.item],
83
+ ['project', o.project],
84
+ ['mockup', o.mockup],
85
+ ['title', o.title]
86
+ ])
87
+ if (v)
88
+ q.set(k, v);
89
+ if (o.another)
90
+ q.set('another', 'true');
91
+ const res = await call(o, 'POST', `${base}/mockups?${q}`, html, { 'content-type': 'text/html' }, `mockup-push-${id}`);
92
+ if (!res.ok)
93
+ throw new Error(await failure(res));
94
+ const made = (await res.json());
95
+ const at = `${base}/mockups/${made.mockup.id}/versions/${made.version.number}`;
96
+ let notes = false;
97
+ if (o.keepNotes || o.freeNotes) {
98
+ const n = await call(o, 'PUT', `${at}/notes`, JSON.stringify({ keepNotes: o.keepNotes ?? '', freeNotes: o.freeNotes ?? '' }), { 'content-type': 'application/json' }, `mockup-notes-${id}`);
99
+ if (!n.ok)
100
+ o.log(`The mockup is published, but its notes were not saved: ${await failure(n)}`);
101
+ else
102
+ notes = true;
103
+ }
104
+ let screenshot = false;
105
+ if (shot) {
106
+ const s = await call(o, 'PUT', `${at}/screenshot`, shot, { 'content-type': 'image/png' }, `mockup-shot-${id}`);
107
+ if (!s.ok)
108
+ o.log(`The mockup is published, but its screenshot was not saved: ${await failure(s)}`);
109
+ else
110
+ screenshot = true;
111
+ }
112
+ const warnings = made.warnings ?? [];
113
+ for (const w of warnings)
114
+ o.log(`Warning: ${w}`);
115
+ return {
116
+ mockupId: made.mockup.id,
117
+ version: made.version.number,
118
+ url: `${o.server}/${o.org}/${made.projectKey}/mockups/${made.mockup.id}`,
119
+ notes,
120
+ screenshot,
121
+ warnings
122
+ };
123
+ }
124
+ /** Uploads the project's kit file (colours, fonts, icons) by path, the same way. The warnings are printed and never change the exit code. */
125
+ export async function pushKit(o) {
126
+ if (!o.token)
127
+ throw new Error('Not signed in. Run: agent-flow-board login --server <url> --org <slug>');
128
+ const html = readFileChecked(o.file, ['.html', '.htm'], MAX_MOCKUP_BYTES, 'kit');
129
+ const q = new URLSearchParams();
130
+ if (o.commit)
131
+ q.set('builtFromCommit', o.commit.slice(0, 80));
132
+ if (o.summary) {
133
+ if (o.summary.length > MAX_KIT_SUMMARY)
134
+ throw new Error(`The summary is ${o.summary.length.toLocaleString('en-US')} characters; the bridge sends at most ${MAX_KIT_SUMMARY.toLocaleString('en-US')}. Shorten it.`);
135
+ q.set('summary', o.summary);
136
+ }
137
+ const res = await call(o, 'PUT', `/orgs/${encodeURIComponent(o.org)}/projects/${encodeURIComponent(o.project)}/mockup-kit?${q}`, html, { 'content-type': 'text/html' }, `mockup-kit-${randomBytes(8).toString('hex')}`);
138
+ if (!res.ok)
139
+ throw new Error(await failure(res));
140
+ const made = (await res.json());
141
+ const warnings = made.warnings ?? [];
142
+ for (const w of warnings)
143
+ o.log(`Warning: ${w}`);
144
+ return { number: made.number, warnings };
145
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,175 @@
1
+ // THE OFFLINE WRITE QUEUE (SPEC 6.3). A WRITE WHILE THE BOARD IS UNREACHABLE IS QUEUED AND SAID TO BE QUEUED, NEVER ANSWERED WITH A MADE UP RESULT.
2
+ // Replay keeps the original order. A write that fails on replay holds the later writes for the same item; other items continue.
3
+ // -- DEP-MODULES -- //
4
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+ // -- CONSTANTS -- //
7
+ /** A lock file older than this belongs to a process that died inside the lock, because the work inside it takes milliseconds. */
8
+ export const LOCK_STALE_MS = 10_000;
9
+ /** The longest a caller waits for the lock before it takes the lock over. */
10
+ export const LOCK_WAIT_MS = 5_000;
11
+ // -- FUNCTIONS -- //
12
+ /** Waits without spinning, for the sync methods of the queue. */
13
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
+ export class Queue {
15
+ file;
16
+ lockTimes;
17
+ constructor(file, lockTimes = {
18
+ staleMs: LOCK_STALE_MS,
19
+ waitMs: LOCK_WAIT_MS
20
+ }) {
21
+ this.file = file;
22
+ this.lockTimes = lockTimes;
23
+ }
24
+ /**
25
+ * Runs fn while this process holds the lock file next to the queue (an exclusive create). Two bridges of two tools share one
26
+ * queue file, and without the lock their read, change, write steps can overwrite each other. A lock left by a dead process
27
+ * is taken over once it is stale.
28
+ */
29
+ withLock(fn) {
30
+ const lock = `${this.file}.lock`;
31
+ mkdirSync(dirname(this.file), { recursive: true });
32
+ const giveUp = Date.now() + this.lockTimes.waitMs;
33
+ for (;;) {
34
+ try {
35
+ const fd = openSync(lock, 'wx', 0o600);
36
+ writeSync(fd, String(process.pid));
37
+ closeSync(fd);
38
+ break;
39
+ }
40
+ catch (e) {
41
+ if (e.code !== 'EEXIST')
42
+ throw e;
43
+ let stale = Date.now() > giveUp;
44
+ try {
45
+ stale ||= Date.now() - statSync(lock).mtimeMs > this.lockTimes.staleMs;
46
+ }
47
+ catch {
48
+ continue; // The holder just let go
49
+ }
50
+ if (stale) {
51
+ // Renaming is atomic, so when two callers find the same stale lock only one of them removes it
52
+ const aside = `${lock}.stale-${process.pid}-${Date.now()}`;
53
+ try {
54
+ renameSync(lock, aside);
55
+ rmSync(aside, { force: true });
56
+ }
57
+ catch {
58
+ /* Another caller took it over first */
59
+ }
60
+ continue;
61
+ }
62
+ sleepSync(20);
63
+ }
64
+ }
65
+ try {
66
+ return fn();
67
+ }
68
+ finally {
69
+ rmSync(lock, { force: true });
70
+ }
71
+ }
72
+ read() {
73
+ if (!existsSync(this.file))
74
+ return [];
75
+ try {
76
+ return JSON.parse(readFileSync(this.file, 'utf8'));
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ write(list) {
83
+ mkdirSync(dirname(this.file), { recursive: true });
84
+ writeFileSync(this.file, JSON.stringify(list, null, 1), { mode: 0o600 });
85
+ }
86
+ list = () => this.read();
87
+ enqueue(name, args) {
88
+ return this.withLock(() => this.enqueueLocked(name, args));
89
+ }
90
+ enqueueLocked(name, args) {
91
+ const list = this.read();
92
+ const id = `q${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
93
+ const key = String(args.item ?? args.id ?? args.sessionId ?? 'none');
94
+ // The id doubles as the request id, so replaying after a half finished send can never write twice
95
+ const entry = {
96
+ id,
97
+ at: new Date().toISOString(),
98
+ name,
99
+ args: {
100
+ ...args,
101
+ requestId: typeof args.requestId === 'string' ? args.requestId : `afb-${id}`
102
+ },
103
+ key,
104
+ state: 'queued'
105
+ };
106
+ list.push(entry);
107
+ this.write(list);
108
+ return entry;
109
+ }
110
+ drop(id) {
111
+ return this.withLock(() => {
112
+ const list = this.read();
113
+ const next = list.filter((e) => e.id !== id);
114
+ if (next.length === list.length)
115
+ return false;
116
+ this.write(next);
117
+ return true;
118
+ });
119
+ }
120
+ /** Puts a held write back in the line so the next replay tries it again. */
121
+ retry(id) {
122
+ return this.withLock(() => {
123
+ const list = this.read();
124
+ const e = list.find((x) => x.id === id);
125
+ if (!e)
126
+ return false;
127
+ e.state = 'queued';
128
+ delete e.reason;
129
+ this.write(list);
130
+ return true;
131
+ });
132
+ }
133
+ /** Sends queued writes in order. Stops at the first sign the board is still unreachable. */
134
+ async replay(send) {
135
+ // The lock is not held while writes are sent, which can take long. What was decided is merged into the file afterwards.
136
+ const list = this.read();
137
+ const gone = new Set();
138
+ const heldNow = new Map();
139
+ const heldKeys = new Set(list.filter((e) => e.state === 'held').map((e) => e.key));
140
+ let sent = 0;
141
+ let held = 0;
142
+ let offline = false;
143
+ for (const e of list) {
144
+ if (offline || e.state === 'held' || heldKeys.has(e.key)) {
145
+ if (!offline && e.state !== 'held')
146
+ held++;
147
+ continue;
148
+ }
149
+ const r = await send(e);
150
+ if (r.kind === 'ok') {
151
+ sent++;
152
+ gone.add(e.id);
153
+ }
154
+ else if (r.kind === 'refused') {
155
+ heldNow.set(e.id, r.reason);
156
+ heldKeys.add(e.key);
157
+ held++;
158
+ }
159
+ else {
160
+ offline = true;
161
+ }
162
+ }
163
+ this.withLock(() => {
164
+ const current = this.read();
165
+ const next = current
166
+ .filter((e) => !gone.has(e.id))
167
+ .map((e) => {
168
+ const reason = heldNow.get(e.id);
169
+ return reason === undefined ? e : { ...e, state: 'held', reason };
170
+ });
171
+ this.write(next);
172
+ });
173
+ return { sent, held, offline };
174
+ }
175
+ }
@@ -0,0 +1,177 @@
1
+ // WHERE THE BRIDGE KEEPS ITS TOKEN. THE OS KEYCHAIN WHERE ONE EXISTS, AN ENCRYPTED FILE WHERE NONE DOES (SSH SESSIONS, CONTAINERS)
2
+ // Backends: macOS Keychain (security), Linux Secret Service (secret-tool), Windows DPAPI (bound to the signed in user),
3
+ // and an AES-256-GCM file with its key in a separate file readable only by its owner.
4
+ // -- DEP-MODULES -- //
5
+ import { execFileSync } from 'node:child_process';
6
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ // -- CONSTANTS -- //
11
+ const SERVICE = 'agent-flow-board';
12
+ // -- FUNCTIONS -- //
13
+ /**
14
+ * The name a sign in is stored under: the server host, plus the organisation when one is chosen, so two organisations on one
15
+ * board keep separate sign ins. Without an organisation it is the plain host, as before.
16
+ */
17
+ export const accountKey = (server, org) => org ? `${new URL(server).host}/${org}` : new URL(server).host;
18
+ export const configDir = () => process.env.AFB_BRIDGE_HOME ?? join(homedir(), '.agent-flow-board');
19
+ function ensureDir(dir) {
20
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ }
22
+ const has = (cmd, args = ['--version']) => {
23
+ try {
24
+ execFileSync(cmd, args, { stdio: 'ignore', timeout: 4000 });
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ /** AES-256-GCM in a file. The key sits in its own file with owner only permissions. Better than plain text, weaker than a keychain. */
32
+ export function fileStore(dir = configDir()) {
33
+ const keyFile = join(dir, 'key');
34
+ const dataFile = (account) => join(dir, `token-${account.replace(/[^A-Za-z0-9._-]/g, '_')}.enc`);
35
+ const key = () => {
36
+ ensureDir(dir);
37
+ if (!existsSync(keyFile)) {
38
+ writeFileSync(keyFile, randomBytes(32), { mode: 0o600 });
39
+ chmodSync(keyFile, 0o600);
40
+ }
41
+ return readFileSync(keyFile);
42
+ };
43
+ return {
44
+ kind: 'file',
45
+ get(account) {
46
+ const f = dataFile(account);
47
+ if (!existsSync(f))
48
+ return null;
49
+ try {
50
+ const raw = readFileSync(f);
51
+ const d = createDecipheriv('aes-256-gcm', key(), raw.subarray(0, 12));
52
+ d.setAuthTag(raw.subarray(12, 28));
53
+ return Buffer.concat([d.update(raw.subarray(28)), d.final()]).toString('utf8');
54
+ }
55
+ catch {
56
+ // A changed key or a damaged file means the token cannot be read, so the person signs in again
57
+ return null;
58
+ }
59
+ },
60
+ set(account, secret) {
61
+ const iv = randomBytes(12);
62
+ const c = createCipheriv('aes-256-gcm', key(), iv);
63
+ const enc = Buffer.concat([c.update(secret, 'utf8'), c.final()]);
64
+ writeFileSync(dataFile(account), Buffer.concat([iv, c.getAuthTag(), enc]), { mode: 0o600 });
65
+ },
66
+ delete(account) {
67
+ rmSync(dataFile(account), { force: true });
68
+ }
69
+ };
70
+ }
71
+ function macStore() {
72
+ return {
73
+ kind: 'macos',
74
+ get(account) {
75
+ try {
76
+ return (execFileSync('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w'], {
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore']
79
+ }).trim() || null);
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ },
85
+ set(account, secret) {
86
+ execFileSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w', secret], { stdio: 'ignore' });
87
+ },
88
+ delete(account) {
89
+ try {
90
+ execFileSync('security', ['delete-generic-password', '-s', SERVICE, '-a', account], {
91
+ stdio: 'ignore'
92
+ });
93
+ }
94
+ catch {
95
+ /* Nothing stored */
96
+ }
97
+ }
98
+ };
99
+ }
100
+ function linuxStore() {
101
+ return {
102
+ kind: 'linux',
103
+ get(account) {
104
+ try {
105
+ return (execFileSync('secret-tool', ['lookup', 'service', SERVICE, 'account', account], {
106
+ encoding: 'utf8',
107
+ stdio: ['ignore', 'pipe', 'ignore']
108
+ }).trim() || null);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ },
114
+ set(account, secret) {
115
+ execFileSync('secret-tool', ['store', '--label', SERVICE, 'service', SERVICE, 'account', account], { input: secret, stdio: ['pipe', 'ignore', 'ignore'] });
116
+ },
117
+ delete(account) {
118
+ try {
119
+ execFileSync('secret-tool', ['clear', 'service', SERVICE, 'account', account], {
120
+ stdio: 'ignore'
121
+ });
122
+ }
123
+ catch {
124
+ /* Nothing stored */
125
+ }
126
+ }
127
+ };
128
+ }
129
+ /** Windows keeps the secret as a DPAPI blob, which only the same user on the same machine can open. */
130
+ function windowsStore(dir = configDir()) {
131
+ const file = (account) => join(dir, `token-${account.replace(/[^A-Za-z0-9._-]/g, '_')}.dpapi`);
132
+ const ps = (script, input) => execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
133
+ encoding: 'utf8',
134
+ input,
135
+ stdio: ['pipe', 'pipe', 'ignore']
136
+ }).trim();
137
+ return {
138
+ kind: 'windows',
139
+ get(account) {
140
+ if (!existsSync(file(account)))
141
+ return null;
142
+ try {
143
+ const out = ps(`$s = ConvertTo-SecureString (Get-Content -Raw -LiteralPath '${file(account)}'); [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))`);
144
+ return out || null;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ },
150
+ set(account, secret) {
151
+ ensureDir(dir);
152
+ const blob = ps('ConvertFrom-SecureString (ConvertTo-SecureString -String ([Console]::In.ReadLine()) -AsPlainText -Force)', secret + '\n');
153
+ writeFileSync(file(account), blob, { mode: 0o600 });
154
+ },
155
+ delete(account) {
156
+ rmSync(file(account), { force: true });
157
+ }
158
+ };
159
+ }
160
+ /** Picks the best store for this machine. AFB_BRIDGE_STORE=file forces the encrypted file (SSH, containers). */
161
+ export function pickStore() {
162
+ const forced = process.env.AFB_BRIDGE_STORE;
163
+ if (forced === 'file')
164
+ return fileStore();
165
+ if (process.platform === 'darwin' && has('security', ['help']))
166
+ return macStore();
167
+ if (process.platform === 'win32' && has('powershell', ['-NoProfile', '-Command', 'exit 0']))
168
+ return windowsStore();
169
+ // No display or session bus (SSH) means the Secret Service is not usable, so the file is used
170
+ if (process.platform === 'linux' &&
171
+ !process.env.SSH_CONNECTION &&
172
+ process.env.DBUS_SESSION_BUS_ADDRESS &&
173
+ has('secret-tool'))
174
+ return linuxStore();
175
+ return fileStore();
176
+ }
177
+ export { windowsStore };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
- "name": "@agent-flow-board/bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@agent-flow-board/bridge",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Local bridge to an agent-flow-board server: forwards MCP calls over stdio, runs the coding tool hooks and writes the editor status. It holds no tool code.",
6
+ "license": "MIT",
7
+ "homepage": "https://afb.arbenger.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ArbenApura/agent-flow-board.git",
11
+ "directory": "packages/bridge"
12
+ },
13
+ "keywords": [
14
+ "agent-flow-board",
15
+ "mcp",
16
+ "claude-code",
17
+ "hooks"
18
+ ],
19
+ "bin": {
20
+ "agent-flow-board": "dist/cli.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "prepublishOnly": "tsc -p tsconfig.json"
36
+ }
37
+ }