@bill10/agent-007 0.6.2
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 +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
package/server/http.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// HTTP routes — Express static, /api/browse, /api/jobs, /mcp, origin + auth checks
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { isAllowedOrigin, sessions } from './state.js';
|
|
8
|
+
import {
|
|
9
|
+
authEnabled, resolveToken, resolveAgentToken,
|
|
10
|
+
tokenFromRequest, tokenFromAuthHeader, userById,
|
|
11
|
+
} from './auth.js';
|
|
12
|
+
import {
|
|
13
|
+
postJobForAgent, listJobsForAgent, readJobForAgent, editJobForAgent, finishJobForAgent, closeJobForAgent, attachmentPath, allJobs,
|
|
14
|
+
} from './jobs.js';
|
|
15
|
+
import { addRepo } from './git.js';
|
|
16
|
+
import { expandHome } from '../lib/helpers.js';
|
|
17
|
+
import { requestApproval, answerApproval } from './approvals.js';
|
|
18
|
+
import { agentSummaries, sendMessage, flushMessages, pendingMessages } from './messages.js';
|
|
19
|
+
import { handleMcpMessage } from './mcp.js';
|
|
20
|
+
|
|
21
|
+
// --- Origin Check Middleware (B2) ---
|
|
22
|
+
// Rejects cross-origin requests from disallowed origins. localhost is always
|
|
23
|
+
// allowed; add remote hostnames via ALLOWED_ORIGINS (see server/state.js).
|
|
24
|
+
// Requests with no Origin header are allowed through
|
|
25
|
+
// (covers same-origin browser requests and non-browser clients like curl).
|
|
26
|
+
export function checkOrigin(req, res, next) {
|
|
27
|
+
if (isAllowedOrigin(req.headers.origin)) return next();
|
|
28
|
+
return res.status(403).json({ error: 'Forbidden: cross-origin request' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Auth Middleware (phase 1) ---
|
|
32
|
+
//
|
|
33
|
+
// Two credentials reach this server, and they are not equals:
|
|
34
|
+
//
|
|
35
|
+
// req.user a person, from users.json. Can do anything.
|
|
36
|
+
// req.agentSession one live agent terminal this app spawned. Can post a job
|
|
37
|
+
// to the board, read the board back, and edit a To do card
|
|
38
|
+
// its own owner queued — and nothing else.
|
|
39
|
+
//
|
|
40
|
+
// Identity is resolved for every /api request; *authorisation* is then two
|
|
41
|
+
// separate gates, and the ordering in setupRoutes is what makes agent access
|
|
42
|
+
// opt-in. An agent token is deliberately never enough on its own for a route
|
|
43
|
+
// that has not been placed above the requireUser gate on purpose.
|
|
44
|
+
|
|
45
|
+
// Attach whoever is calling. Runs regardless of authEnabled(): with no users
|
|
46
|
+
// configured there is nothing to enforce, but an agent's token still says WHICH
|
|
47
|
+
// agent is calling, which is what defaults the repo and credits the card.
|
|
48
|
+
// Single-player is the default deployment, so identity has to work there too.
|
|
49
|
+
export function resolveIdentity(req, res, next) {
|
|
50
|
+
const user = resolveToken(tokenFromRequest(req));
|
|
51
|
+
if (user) req.user = user;
|
|
52
|
+
// Header-only for the agent token — see tokenFromAuthHeader.
|
|
53
|
+
const session = resolveAgentToken(tokenFromAuthHeader(req));
|
|
54
|
+
if (session) req.agentSession = session;
|
|
55
|
+
return next();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No-op until the first user exists (keeps zero-config localhost working). Once
|
|
59
|
+
// users are configured, either credential gets you past this one.
|
|
60
|
+
export function requireIdentity(req, res, next) {
|
|
61
|
+
if (!authEnabled()) return next();
|
|
62
|
+
if (req.user || req.agentSession) return next();
|
|
63
|
+
return res.status(401).json({ error: 'Unauthorized: valid token required' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The default gate for /api. Everything below it in setupRoutes is people-only,
|
|
67
|
+
// so a route added later without a thought about agents does not quietly become
|
|
68
|
+
// reachable by one.
|
|
69
|
+
//
|
|
70
|
+
// The two failures are different and stay different: no credential at all is
|
|
71
|
+
// 401 ("who are you"), while a valid agent token on a route meant for people is
|
|
72
|
+
// 403 ("I know who you are, and no"). Collapsing them would tell an agent to go
|
|
73
|
+
// and find a token when the one it has is the problem.
|
|
74
|
+
export function requireUser(req, res, next) {
|
|
75
|
+
if (!authEnabled()) return next();
|
|
76
|
+
if (req.user) return next();
|
|
77
|
+
if (req.agentSession) {
|
|
78
|
+
return res.status(403).json({ error: 'Forbidden: this endpoint needs a user token' });
|
|
79
|
+
}
|
|
80
|
+
return res.status(401).json({ error: 'Unauthorized: valid token required' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The MCP endpoint is agents only, and unlike /api it is not relaxed when auth
|
|
84
|
+
// is off: the token is not there to keep strangers out (it is loopback), it is
|
|
85
|
+
// the only thing that says which agent is calling. Without one there is no
|
|
86
|
+
// caller to attribute a card to, so there is nothing sensible to do.
|
|
87
|
+
export function requireAgent(req, res, next) {
|
|
88
|
+
if (req.agentSession) return next();
|
|
89
|
+
return res.status(401).json({ error: 'Unauthorized: this endpoint is for Agent 007 agent sessions' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Routes ---
|
|
93
|
+
export function setupRoutes(app, staticDir, { broadcast, killSession } = {}) {
|
|
94
|
+
app.use(express_static(staticDir));
|
|
95
|
+
|
|
96
|
+
// --- POST /mcp — the board's MCP server ---
|
|
97
|
+
//
|
|
98
|
+
// Mounted outside /api because it is a different audience with a different
|
|
99
|
+
// credential: agents, never browsers. Origin-checked all the same, so a page
|
|
100
|
+
// in the user's browser cannot reach it.
|
|
101
|
+
// Async because finish_job waits on a PR lookup. Express 4 does not catch a
|
|
102
|
+
// rejected handler, so the catch below is what keeps a throw from hanging it.
|
|
103
|
+
app.post('/mcp', checkOrigin, express.json({ limit: '128kb' }), resolveIdentity, requireAgent, async (req, res) => {
|
|
104
|
+
let reply;
|
|
105
|
+
try {
|
|
106
|
+
reply = await handleMcpMessage(req.body, {
|
|
107
|
+
session: req.agentSession,
|
|
108
|
+
postJob: (fields) => postJobForAgent({ ...fields, user: userById(req.agentSession.ownerId) }, broadcast),
|
|
109
|
+
listJobs: listJobsForAgent,
|
|
110
|
+
readJob: readJobForAgent,
|
|
111
|
+
// The session and its owner, like postJob: an edit is refused on another
|
|
112
|
+
// person's card and stamped with the agent's name when it lands.
|
|
113
|
+
editJob: (fields) => editJobForAgent({
|
|
114
|
+
...fields,
|
|
115
|
+
session: req.agentSession,
|
|
116
|
+
user: userById(req.agentSession.ownerId),
|
|
117
|
+
}, broadcast),
|
|
118
|
+
listAgents: () => agentSummaries(req.agentSession, sessions,
|
|
119
|
+
(jobId) => allJobs().find(job => job.id === jobId)?.title),
|
|
120
|
+
sendMessage: ({ to, text }) => sendMessage({ from: req.agentSession, to, text, sessions }),
|
|
121
|
+
finishJob: (fields) => finishJobForAgent({ ...fields, session: req.agentSession }, broadcast),
|
|
122
|
+
// Billion's own tools: toolsFor() lists them only for its session, and
|
|
123
|
+
// each checks again here.
|
|
124
|
+
addRepo: async (path) => {
|
|
125
|
+
if (!req.agentSession.isBillion) return { error: 'Only Billion can add repositories.' };
|
|
126
|
+
return addRepo(expandHome(path), broadcast);
|
|
127
|
+
},
|
|
128
|
+
closeJob: (fields) => closeJobForAgent({ ...fields, session: req.agentSession }, broadcast, { killSession }),
|
|
129
|
+
answerPermission: ({ id, decision, reason }) => (req.agentSession.isBillion
|
|
130
|
+
? answerApproval(id, decision, reason)
|
|
131
|
+
: { error: 'Only Billion answers permission requests.' }),
|
|
132
|
+
billionReady: () => {
|
|
133
|
+
const session = req.agentSession;
|
|
134
|
+
if (!session.isBillion) return { error: 'Only Billion has an inbox to open.' };
|
|
135
|
+
session.messagesHeld = false;
|
|
136
|
+
flushMessages(session);
|
|
137
|
+
return { waiting: pendingMessages(session.id) };
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('MCP call failed:', err);
|
|
142
|
+
return res.json({ jsonrpc: '2.0', id: req.body?.id ?? null, error: { code: -32603, message: 'Internal error' } }); // 200, as JSON-RPC errors are: the Codex bridge drops a non-2xx body. Detail stays in the log.
|
|
143
|
+
}
|
|
144
|
+
// A notification gets no body. 202 is what the MCP HTTP transport expects.
|
|
145
|
+
if (!reply) return res.status(202).end();
|
|
146
|
+
return res.json(reply);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// A worker's PermissionRequest hook (server/permission-hook.js), with that
|
|
150
|
+
// worker's own agent token. Answers with the hook's output: Billion's
|
|
151
|
+
// decision, or {} for none — which is also what anything unexpected gets,
|
|
152
|
+
// so the dialog falls back to a person.
|
|
153
|
+
app.post('/hook/permission', checkOrigin, express.json({ limit: '256kb' }), resolveIdentity, requireAgent, async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const worker = req.agentSession;
|
|
156
|
+
const jobTitle = worker.jobId ? allJobs().find(job => job.id === worker.jobId)?.title : null;
|
|
157
|
+
res.json(await requestApproval(worker, req.body, { jobTitle }));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('Permission hook failed:', err);
|
|
160
|
+
res.json({});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Gate the whole /api surface once, so new routes are origin- and auth-checked
|
|
165
|
+
// by default (a per-route guard that someone forgets to add fails OPEN).
|
|
166
|
+
app.use('/api', checkOrigin, resolveIdentity, express.json({ limit: '128kb' }));
|
|
167
|
+
|
|
168
|
+
// --- Routes an agent token may reach ---
|
|
169
|
+
//
|
|
170
|
+
// These are ABOVE the requireUser gate, and that placement is the whole
|
|
171
|
+
// access-control decision — keep it deliberate. Everything below is people
|
|
172
|
+
// only.
|
|
173
|
+
|
|
174
|
+
// The non-MCP door to the same action, available to clients with an agent
|
|
175
|
+
// session token. MCP calls share this implementation through postJobForAgent.
|
|
176
|
+
app.post('/api/jobs', requireIdentity, (req, res) => {
|
|
177
|
+
const body = req.body || {};
|
|
178
|
+
const session = req.agentSession || null;
|
|
179
|
+
const result = postJobForAgent({
|
|
180
|
+
title: body.title,
|
|
181
|
+
detail: body.detail,
|
|
182
|
+
repo: body.repo || body.repoPath,
|
|
183
|
+
type: body.type,
|
|
184
|
+
schedule: body.schedule,
|
|
185
|
+
agent: body.agent,
|
|
186
|
+
requiresPr: body.requiresPr ?? body.requires_pr,
|
|
187
|
+
session,
|
|
188
|
+
user: req.user || (session ? userById(session.ownerId) : null),
|
|
189
|
+
}, broadcast);
|
|
190
|
+
if (result.error) return res.status(400).json({ error: result.error });
|
|
191
|
+
return res.status(201).json(result);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- People only, from here down ---
|
|
195
|
+
app.use('/api', requireUser);
|
|
196
|
+
|
|
197
|
+
// A card's attached file, for the link on the card. Served sandboxed: an
|
|
198
|
+
// uploaded HTML file must not run as this origin, where the token lives.
|
|
199
|
+
// The link carries the token in its query, so no-referrer keeps a page
|
|
200
|
+
// that loads an outside image from handing that URL on (sandbox alone does
|
|
201
|
+
// not stop subresource loads or a <meta name=referrer>). dotfiles: 'allow'
|
|
202
|
+
// is load-bearing: with no root, send checks EVERY segment of the absolute
|
|
203
|
+
// path for a leading dot, and the default config dir is ~/.agent-007 — the
|
|
204
|
+
// default 'ignore' would 404 every attachment in production (the test
|
|
205
|
+
// config dir has no dot segment, so the suite cannot catch that). It also
|
|
206
|
+
// lets a ".env" attachment through.
|
|
207
|
+
app.get('/api/jobs/:id/attachments/:name', (req, res) => {
|
|
208
|
+
const path = attachmentPath(req.params.id, req.params.name);
|
|
209
|
+
if (!path) return res.status(404).json({ error: 'No such attachment' });
|
|
210
|
+
res.sendFile(resolve(path), {
|
|
211
|
+
dotfiles: 'allow',
|
|
212
|
+
headers: {
|
|
213
|
+
'Content-Security-Policy': 'sandbox',
|
|
214
|
+
'X-Content-Type-Options': 'nosniff',
|
|
215
|
+
'Referrer-Policy': 'no-referrer',
|
|
216
|
+
},
|
|
217
|
+
}, (err) => { if (err && !res.headersSent) res.status(404).json({ error: 'Attachment missing on disk' }); });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
app.get('/api/browse', (req, res) => {
|
|
221
|
+
try {
|
|
222
|
+
const dirPath = req.query.path ? resolve(req.query.path) : homedir();
|
|
223
|
+
if (!existsSync(dirPath)) return res.status(400).json({ error: 'Directory does not exist' });
|
|
224
|
+
let resolved;
|
|
225
|
+
try { resolved = realpathSync(dirPath); } catch { return res.status(400).json({ error: 'Cannot resolve path' }); }
|
|
226
|
+
let stat;
|
|
227
|
+
try { stat = statSync(resolved); } catch { return res.status(400).json({ error: 'Cannot read path' }); }
|
|
228
|
+
if (!stat.isDirectory()) return res.status(400).json({ error: 'Not a directory' });
|
|
229
|
+
let entries;
|
|
230
|
+
try {
|
|
231
|
+
entries = readdirSync(resolved, { withFileTypes: true });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err.code === 'EACCES') return res.status(403).json({ error: 'Permission denied' });
|
|
234
|
+
return res.status(500).json({ error: err.message });
|
|
235
|
+
}
|
|
236
|
+
const showHidden = req.query.showHidden === '1';
|
|
237
|
+
const dirs = entries
|
|
238
|
+
.filter(e => e.isDirectory() && (showHidden || !e.name.startsWith('.')))
|
|
239
|
+
.map(e => {
|
|
240
|
+
const fullPath = join(resolved, e.name);
|
|
241
|
+
const isGitRepo = existsSync(join(fullPath, '.git'));
|
|
242
|
+
return { name: e.name, isGitRepo };
|
|
243
|
+
})
|
|
244
|
+
.sort((a, b) => {
|
|
245
|
+
if (a.isGitRepo !== b.isGitRepo) return a.isGitRepo ? -1 : 1;
|
|
246
|
+
return a.name.localeCompare(b.name);
|
|
247
|
+
});
|
|
248
|
+
// dirname() of a root is that same root ('/' on POSIX, 'C:\' on Windows),
|
|
249
|
+
// so compare instead of hard-coding '/': the Windows drive root would
|
|
250
|
+
// otherwise offer an "up" link that navigates to itself forever.
|
|
251
|
+
const parentDir = dirname(resolved);
|
|
252
|
+
const parent = parentDir === resolved ? null : parentDir;
|
|
253
|
+
const isGitRepo = existsSync(join(resolved, '.git'));
|
|
254
|
+
res.json({ path: resolved, parent, isGitRepo, entries: dirs });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
res.status(500).json({ error: err.message });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// JSON errors for the JSON APIs. Without this a malformed body falls through
|
|
261
|
+
// to Express's default handler, which answers an API client with an HTML
|
|
262
|
+
// error page. Registered last so it only sees errors from the routes above.
|
|
263
|
+
const jsonErrors = (err, req, res, next) => {
|
|
264
|
+
if (!err) return next();
|
|
265
|
+
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Request body too large' });
|
|
266
|
+
if (err.status === 400 || err instanceof SyntaxError) return res.status(400).json({ error: 'Invalid JSON body' });
|
|
267
|
+
console.error('API error:', err.message);
|
|
268
|
+
return res.status(500).json({ error: 'Server error' });
|
|
269
|
+
};
|
|
270
|
+
app.use('/api', jsonErrors);
|
|
271
|
+
app.use('/mcp', jsonErrors);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Import express.static — passed as parameter to avoid coupling
|
|
275
|
+
import express from 'express';
|
|
276
|
+
const express_static = express.static;
|