@agent-flow-board/bridge 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +46 -2
- package/dist/bridge.js +256 -0
- package/dist/checks/audit.js +125 -0
- package/dist/checks/bind.js +99 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/browser.js +170 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +280 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +41 -0
- package/dist/checks/envPolicy.js +163 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/lock.js +183 -0
- package/dist/checks/paths.js +61 -0
- package/dist/checks/preview.js +350 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +232 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/run.js +253 -0
- package/dist/checks/sandbox.js +186 -0
- package/dist/checks/scope.js +294 -0
- package/dist/checks/signin.js +73 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/watch.js +101 -0
- package/dist/cli.js +329 -0
- package/dist/device.js +61 -0
- package/dist/hooks/adapters.js +218 -0
- package/dist/hooks/call.js +79 -0
- package/dist/hooks/gitHook.js +56 -0
- package/dist/hooks/modVersion.js +26 -0
- package/dist/hooks/settings.js +49 -0
- package/dist/hooks/setup.js +124 -0
- package/dist/hooks/statusFile.js +116 -0
- package/dist/mockup.js +145 -0
- package/dist/queue.js +175 -0
- package/dist/tokenStore.js +177 -0
- package/package.json +36 -5
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@agent-flow-board/bridge",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local bridge to an agent-flow-board server: forwards MCP calls over stdio, runs the coding tool hooks, writes the editor status and runs browser checks on this machine. It holds no board 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
|
+
}
|