@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. 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;