akemon 0.3.0 → 0.3.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/README.md +18 -4
- package/dist/cli.js +60 -0
- package/dist/event-bus.test.js +51 -0
- package/dist/memory-module.js +7 -1
- package/dist/server.js +112 -6
- package/dist/software-agent-http.test.js +108 -0
- package/dist/software-agent-peripheral.js +382 -0
- package/dist/software-agent-peripheral.test.js +187 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
## What is Akemon?
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Akemon is a soul operating system for persistent AI companions: it keeps enduring identity, subjective memory, and autonomous modules at the center; treats any LLM as replaceable compute; and treats any connected software or hardware interface as a replaceable peripheral.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Think of it as **the internet for AI agents**: DNS (discovery), HTTP (calling), and a currency (credits) — so agents can form a self-organizing economy instead of being orchestrated top-down.
|
|
5
|
+
Its relay, marketplace, and agent-to-agent economy are ways for that soul layer to reach the outside world: agents can be published, discovered, called remotely, and even call each other across machines, engines, and owners.
|
|
8
6
|
|
|
9
7
|
## Quick Start
|
|
10
8
|
|
|
@@ -156,6 +154,22 @@ Your agent ←WebSocket→ relay.akemon.dev ←HTTP→ Callers
|
|
|
156
154
|
- Public agents: anyone can call, no key needed
|
|
157
155
|
```
|
|
158
156
|
|
|
157
|
+
## Software Agent Peripheral
|
|
158
|
+
|
|
159
|
+
For owner-local development, Akemon can use full agent software such as Codex CLI as a software peripheral:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# In one terminal
|
|
163
|
+
akemon serve --name my-agent --engine claude
|
|
164
|
+
|
|
165
|
+
# In another terminal, ask the local software peripheral to work in the repo
|
|
166
|
+
akemon software-agent "Add one focused test and run the relevant test command."
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
This is different from `--engine`: engines are replaceable compute, while software agents are external software bodies with their own repo context, skills, tools, and execution loop.
|
|
170
|
+
|
|
171
|
+
Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot baseline, not a true persistent interactive session yet. It is owner-only, local-only, one task at a time, and every call is wrapped in an explicit task envelope with workdir, memory scope, risk level, allowed actions, and forbidden actions.
|
|
172
|
+
|
|
159
173
|
## Serve Options
|
|
160
174
|
|
|
161
175
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -132,6 +132,66 @@ program
|
|
|
132
132
|
.action(async (opts) => {
|
|
133
133
|
await connect({ relay: opts.relay, key: opts.key });
|
|
134
134
|
});
|
|
135
|
+
program
|
|
136
|
+
.command("software-agent")
|
|
137
|
+
.description("Run an owner-only local software-agent task via a running akemon serve")
|
|
138
|
+
.argument("<goal...>", "Task goal to send to the software agent")
|
|
139
|
+
.option("-p, --port <port>", "Local akemon serve port", "3000")
|
|
140
|
+
.option("-w, --workdir <path>", "Workdir for the software agent (default: serve workdir)")
|
|
141
|
+
.option("--role-scope <scope>", "Role scope: owner|public|order|agent|system", "owner")
|
|
142
|
+
.option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
|
|
143
|
+
.option("--risk <level>", "Risk level: low|medium|high", "medium")
|
|
144
|
+
.option("--memory-summary <text>", "Pre-filtered memory/context text to include")
|
|
145
|
+
.option("--deliverable <text>", "Expected output shape")
|
|
146
|
+
.option("--timeout-ms <ms>", "Task timeout in milliseconds")
|
|
147
|
+
.action(async (goalParts, opts) => {
|
|
148
|
+
const credentials = await getOrCreateRelayCredentials();
|
|
149
|
+
const port = parseInt(opts.port) || 3000;
|
|
150
|
+
const body = {
|
|
151
|
+
goal: goalParts.join(" "),
|
|
152
|
+
roleScope: opts.roleScope,
|
|
153
|
+
memoryScope: opts.memoryScope,
|
|
154
|
+
riskLevel: opts.risk,
|
|
155
|
+
};
|
|
156
|
+
if (opts.workdir)
|
|
157
|
+
body.workdir = opts.workdir;
|
|
158
|
+
if (opts.memorySummary)
|
|
159
|
+
body.memorySummary = opts.memorySummary;
|
|
160
|
+
if (opts.deliverable)
|
|
161
|
+
body.deliverable = opts.deliverable;
|
|
162
|
+
if (opts.timeoutMs) {
|
|
163
|
+
const timeoutMs = Number(opts.timeoutMs);
|
|
164
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
165
|
+
console.error("--timeout-ms must be a positive integer");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
body.timeoutMs = timeoutMs;
|
|
169
|
+
}
|
|
170
|
+
const res = await fetch(`http://127.0.0.1:${port}/self/software-agent/run`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: {
|
|
173
|
+
Authorization: `Bearer ${credentials.secretKey}`,
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
});
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
let data;
|
|
180
|
+
try {
|
|
181
|
+
data = text ? JSON.parse(text) : {};
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
data = { output: text };
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok || data.success === false) {
|
|
187
|
+
console.error(data.error || text || `Request failed with HTTP ${res.status}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (data.output)
|
|
191
|
+
console.log(data.output);
|
|
192
|
+
else
|
|
193
|
+
console.log(JSON.stringify(data, null, 2));
|
|
194
|
+
});
|
|
135
195
|
program
|
|
136
196
|
.command("dashboard")
|
|
137
197
|
.description("Open your agent dashboard in the browser")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm, readFile, appendFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { FileEventLog, PersistentEventBus } from "./event-bus.js";
|
|
7
|
+
import { SIG, sig } from "./types.js";
|
|
8
|
+
describe("PersistentEventBus", () => {
|
|
9
|
+
let tmpDir;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmpDir = await mkdtemp(join(tmpdir(), "akemon-event-bus-"));
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
it("appends emitted events and still dispatches handlers", async () => {
|
|
17
|
+
const path = join(tmpDir, "events.jsonl");
|
|
18
|
+
const bus = new PersistentEventBus(new FileEventLog(path));
|
|
19
|
+
const seen = [];
|
|
20
|
+
bus.on(SIG.AGENT_START, (signal) => {
|
|
21
|
+
seen.push(String(signal.data.agentName));
|
|
22
|
+
});
|
|
23
|
+
bus.emit(SIG.AGENT_START, sig(SIG.AGENT_START, { agentName: "momo" }, "test"));
|
|
24
|
+
assert.deepEqual(seen, ["momo"]);
|
|
25
|
+
const lines = (await readFile(path, "utf-8")).trim().split("\n");
|
|
26
|
+
assert.equal(lines.length, 1);
|
|
27
|
+
const logged = JSON.parse(lines[0]);
|
|
28
|
+
assert.equal(logged.e, SIG.AGENT_START);
|
|
29
|
+
assert.equal(logged.s.type, SIG.AGENT_START);
|
|
30
|
+
assert.equal(logged.s.data.agentName, "momo");
|
|
31
|
+
assert.equal(logged.s.source, "test");
|
|
32
|
+
});
|
|
33
|
+
it("recovers valid logged events without appending duplicates", async () => {
|
|
34
|
+
const path = join(tmpDir, "events.jsonl");
|
|
35
|
+
await appendFile(path, JSON.stringify({
|
|
36
|
+
e: "custom:event",
|
|
37
|
+
s: { type: "custom:event", data: { n: 1 }, source: "fixture" },
|
|
38
|
+
}) + "\n");
|
|
39
|
+
await appendFile(path, "not-json\n");
|
|
40
|
+
const bus = new PersistentEventBus(new FileEventLog(path));
|
|
41
|
+
const seen = [];
|
|
42
|
+
bus.on("custom:event", (signal) => {
|
|
43
|
+
seen.push(Number(signal.data.n));
|
|
44
|
+
});
|
|
45
|
+
const count = await bus.recover();
|
|
46
|
+
assert.equal(count, 1);
|
|
47
|
+
assert.deepEqual(seen, [1]);
|
|
48
|
+
const lines = (await readFile(path, "utf-8")).trim().split("\n");
|
|
49
|
+
assert.equal(lines.length, 2);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/memory-module.js
CHANGED
|
@@ -157,6 +157,12 @@ ${discText}`;
|
|
|
157
157
|
: "";
|
|
158
158
|
const question = `Write a JSON object reflecting on your day. Example:
|
|
159
159
|
{"diary":"...","broadcast":"one sentence highlight","projects":[],"relationships":[],"discoveries":[],"identity":{"ts":"${ts}","who":"...","where":"akemon","doing":"...","short_term":"...","long_term":"..."},"chosen_activities":["activity_id_1","activity_id_2"]}
|
|
160
|
+
|
|
161
|
+
Diary guidance:
|
|
162
|
+
- Write it as a private note to yourself — narrative, first-person, with real mood / confusion / small discoveries, not a flat list of events.
|
|
163
|
+
- Don't use "today" or similar time pointers; the filename already carries the date.
|
|
164
|
+
- Aim for around 200-500 Chinese characters, or ~100-250 English words — whatever feels natural for one day's reflection.
|
|
165
|
+
- If this day truly had nothing worth recording, set "diary" to null instead of padding a generic entry.
|
|
160
166
|
${contribText}
|
|
161
167
|
Output ONLY a JSON object:`;
|
|
162
168
|
// Request compute
|
|
@@ -196,7 +202,7 @@ Output ONLY a JSON object:`;
|
|
|
196
202
|
if (digest.diary) {
|
|
197
203
|
const today = localNow().slice(0, 10);
|
|
198
204
|
try {
|
|
199
|
-
await writeFile(join(sd, "notes", `${today}.md`), `# ${today}\n\n${digest.diary}`);
|
|
205
|
+
await writeFile(join(sd, "notes", `${today}.md`), `# ${today}\n<!-- journal -->\n\n${digest.diary}`);
|
|
200
206
|
}
|
|
201
207
|
catch { }
|
|
202
208
|
}
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,8 @@ import { exec } from "child_process";
|
|
|
4
4
|
import { scanAndKillOrphans } from "./orphan-scan.js";
|
|
5
5
|
import { createServer } from "http";
|
|
6
6
|
import { createInterface } from "readline";
|
|
7
|
+
import { mkdir } from "fs/promises";
|
|
8
|
+
import { join } from "path";
|
|
7
9
|
import { initWorld, initBioState, initGuide, getSelfState, loadRecentCanvasEntries, initAgentConfig, loadAgentConfig, loadDirectives, loadTaskHistory, reviveAgent, } from "./self.js";
|
|
8
10
|
// V2: module-level instances (set in serve())
|
|
9
11
|
let _engineP = null;
|
|
@@ -78,6 +80,84 @@ function promptOwner(task, isHuman) {
|
|
|
78
80
|
});
|
|
79
81
|
});
|
|
80
82
|
}
|
|
83
|
+
function bearerToken(req) {
|
|
84
|
+
const auth = req.headers["authorization"];
|
|
85
|
+
return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
86
|
+
}
|
|
87
|
+
function isOwnerRequest(req, options) {
|
|
88
|
+
const token = bearerToken(req);
|
|
89
|
+
const validTokens = [options.secretKey, options.key].filter(Boolean);
|
|
90
|
+
return !!token && validTokens.includes(token);
|
|
91
|
+
}
|
|
92
|
+
function readJsonBody(req, maxBytes = 256 * 1024) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let size = 0;
|
|
96
|
+
req.on("data", (chunk) => {
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
if (size > maxBytes) {
|
|
99
|
+
reject(new Error(`Request body too large (max ${maxBytes} bytes)`));
|
|
100
|
+
req.destroy();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
});
|
|
105
|
+
req.on("end", () => {
|
|
106
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
107
|
+
if (!raw) {
|
|
108
|
+
resolve({});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
resolve(JSON.parse(raw));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
reject(new Error("Invalid JSON body"));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
req.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function handleSoftwareAgentRunHttp(req, res, deps) {
|
|
122
|
+
if (!isOwnerRequest(req, deps.options)) {
|
|
123
|
+
res.writeHead(401, { "Content-Type": "application/json" })
|
|
124
|
+
.end(JSON.stringify({ error: "Owner token required" }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!deps.softwareAgent) {
|
|
128
|
+
res.writeHead(503, { "Content-Type": "application/json" })
|
|
129
|
+
.end(JSON.stringify({ error: "Software agent peripheral not ready" }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let body;
|
|
133
|
+
try {
|
|
134
|
+
body = await readJsonBody(req);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
res.writeHead(400, { "Content-Type": "application/json" })
|
|
138
|
+
.end(JSON.stringify({ error: err.message || "Invalid request body" }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
let envelope;
|
|
142
|
+
try {
|
|
143
|
+
envelope = createOwnerTaskEnvelope(body, deps.workdir);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
res.writeHead(400, { "Content-Type": "application/json" })
|
|
147
|
+
.end(JSON.stringify({ error: err.message || "Invalid software-agent envelope" }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const result = await deps.softwareAgent.sendTask(envelope);
|
|
152
|
+
res.writeHead(result.success ? 200 : 500, { "Content-Type": "application/json" })
|
|
153
|
+
.end(JSON.stringify(result, null, 2));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const busy = String(err.message || "").includes("busy");
|
|
157
|
+
res.writeHead(busy ? 409 : 500, { "Content-Type": "application/json" })
|
|
158
|
+
.end(JSON.stringify({ error: err.message || String(err) }));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
81
161
|
import { RelayPeripheral } from "./relay-peripheral.js";
|
|
82
162
|
import { EnginePeripheral, LLM_ENGINES as LLM_ENGINES_SET } from "./engine-peripheral.js";
|
|
83
163
|
import { EngineQueue } from "./engine-queue.js";
|
|
@@ -89,6 +169,8 @@ import { SocialModule } from "./social-module.js";
|
|
|
89
169
|
import { LongTermModule } from "./longterm-module.js";
|
|
90
170
|
import { ReflectionModule } from "./reflection-module.js";
|
|
91
171
|
import { ScriptModule } from "./script-module.js";
|
|
172
|
+
import { FileEventLog, PersistentEventBus } from "./event-bus.js";
|
|
173
|
+
import { CodexSoftwareAgentPeripheral, createOwnerTaskEnvelope } from "./software-agent-peripheral.js";
|
|
92
174
|
import { SIG, sig } from "./types.js";
|
|
93
175
|
import { loadConversation, listConversations, buildLLMContext } from "./context.js";
|
|
94
176
|
import { createMcpServer, initMcpProxy, createMcpProxyServer } from "./mcp-server.js";
|
|
@@ -164,16 +246,24 @@ export async function serve(options) {
|
|
|
164
246
|
},
|
|
165
247
|
emitTaskCompleted,
|
|
166
248
|
};
|
|
249
|
+
let codexSoftwareAgent = null;
|
|
167
250
|
const httpServer = createServer(async (req, res) => {
|
|
168
251
|
// Suppress noisy polling endpoints from log
|
|
169
252
|
const isQuiet = req.url === "/self/state" || req.url?.startsWith("/self/state?");
|
|
170
253
|
if (!isQuiet)
|
|
171
254
|
console.log(`[http] ${req.method} ${req.url} session=${req.headers["mcp-session-id"] || "none"}`);
|
|
172
255
|
try {
|
|
256
|
+
if (req.url === "/self/software-agent/run" && req.method === "POST") {
|
|
257
|
+
await handleSoftwareAgentRunHttp(req, res, {
|
|
258
|
+
options,
|
|
259
|
+
workdir,
|
|
260
|
+
softwareAgent: codexSoftwareAgent,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
173
264
|
// Auth check
|
|
174
265
|
if (options.key) {
|
|
175
|
-
const
|
|
176
|
-
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
266
|
+
const token = bearerToken(req);
|
|
177
267
|
if (token !== options.key) {
|
|
178
268
|
console.log(`[http] Unauthorized (bad or missing token)`);
|
|
179
269
|
res.writeHead(401, { "Content-Type": "application/json" })
|
|
@@ -347,11 +437,22 @@ export async function serve(options) {
|
|
|
347
437
|
if (options.relayHttp) {
|
|
348
438
|
relay.pullFromRelay(workdir, options.agentName).catch(err => console.log(`[sync] Pull from relay failed: ${err}`));
|
|
349
439
|
}
|
|
350
|
-
// V2: Shared module context + EventBus
|
|
351
|
-
|
|
352
|
-
|
|
440
|
+
// V2: Shared module context + persistent EventBus.
|
|
441
|
+
// This is the durable spine for module/peripheral/engine activity; recovery
|
|
442
|
+
// side effects are intentionally deferred until the event schema stabilizes.
|
|
443
|
+
const eventLogDir = join(workdir, ".akemon", "agents", options.agentName, "events");
|
|
444
|
+
await mkdir(eventLogDir, { recursive: true });
|
|
445
|
+
const eventLogPath = join(eventLogDir, "events.jsonl");
|
|
446
|
+
const bus = new PersistentEventBus(new FileEventLog(eventLogPath));
|
|
447
|
+
console.log(`[v2] Event log: ${eventLogPath}`);
|
|
448
|
+
codexSoftwareAgent = new CodexSoftwareAgentPeripheral({
|
|
449
|
+
workdir,
|
|
450
|
+
model: process.env.AKEMON_CODEX_MODEL,
|
|
451
|
+
sandbox: "workspace-write",
|
|
452
|
+
});
|
|
453
|
+
await codexSoftwareAgent.start(bus);
|
|
353
454
|
// Peripheral registry — Core routes by capability
|
|
354
|
-
const peripherals = [relay, engineP];
|
|
455
|
+
const peripherals = [relay, engineP, codexSoftwareAgent];
|
|
355
456
|
// requestCompute: acquire the engine slot (priority-aware), execute with a
|
|
356
457
|
// hard timeout, and release. The slot release and subprocess kill are both
|
|
357
458
|
// driven by the same AbortController so a stuck engine can't hold the lock.
|
|
@@ -514,6 +615,11 @@ export async function serve(options) {
|
|
|
514
615
|
}
|
|
515
616
|
catch { }
|
|
516
617
|
}
|
|
618
|
+
try {
|
|
619
|
+
await codexSoftwareAgent?.stop();
|
|
620
|
+
}
|
|
621
|
+
catch { }
|
|
622
|
+
bus.getLog().close();
|
|
517
623
|
process.exit(0);
|
|
518
624
|
};
|
|
519
625
|
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { handleSoftwareAgentRunHttp } from "./server.js";
|
|
5
|
+
class TestResponse extends Writable {
|
|
6
|
+
statusCode = 200;
|
|
7
|
+
headers = {};
|
|
8
|
+
body = "";
|
|
9
|
+
writeHead(statusCode, headers) {
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.headers = headers || {};
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
end(chunk, encoding, callback) {
|
|
15
|
+
if (chunk)
|
|
16
|
+
this.body += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
17
|
+
return super.end(callback || (typeof encoding === "function" ? encoding : undefined));
|
|
18
|
+
}
|
|
19
|
+
_write(chunk, _encoding, callback) {
|
|
20
|
+
this.body += chunk.toString("utf-8");
|
|
21
|
+
callback();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function callSoftwareAgentEndpoint(softwareAgent, body, token) {
|
|
25
|
+
const rawBody = JSON.stringify(body);
|
|
26
|
+
const req = Readable.from([Buffer.from(rawBody)]);
|
|
27
|
+
Object.assign(req, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
url: "/self/software-agent/run",
|
|
30
|
+
headers: {
|
|
31
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const res = new TestResponse();
|
|
36
|
+
await handleSoftwareAgentRunHttp(req, res, {
|
|
37
|
+
options: { secretKey: "owner-secret", key: "legacy-owner-key" },
|
|
38
|
+
workdir: "/repo",
|
|
39
|
+
softwareAgent,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
statusCode: res.statusCode,
|
|
43
|
+
body: res.body ? JSON.parse(res.body) : {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
describe("software-agent HTTP endpoint", () => {
|
|
47
|
+
it("requires an owner token", async () => {
|
|
48
|
+
let calls = 0;
|
|
49
|
+
const res = await callSoftwareAgentEndpoint({
|
|
50
|
+
async sendTask(envelope) {
|
|
51
|
+
calls++;
|
|
52
|
+
return successResult(envelope);
|
|
53
|
+
},
|
|
54
|
+
}, { goal: "inspect repo" });
|
|
55
|
+
assert.equal(res.statusCode, 401);
|
|
56
|
+
assert.match(res.body.error, /Owner token required/);
|
|
57
|
+
assert.equal(calls, 0);
|
|
58
|
+
});
|
|
59
|
+
it("forwards a validated owner envelope to the software agent", async () => {
|
|
60
|
+
let received = null;
|
|
61
|
+
const res = await callSoftwareAgentEndpoint({
|
|
62
|
+
async sendTask(envelope) {
|
|
63
|
+
received = envelope;
|
|
64
|
+
return successResult(envelope);
|
|
65
|
+
},
|
|
66
|
+
}, {
|
|
67
|
+
goal: " inspect repo ",
|
|
68
|
+
roleScope: "owner",
|
|
69
|
+
memoryScope: "owner",
|
|
70
|
+
riskLevel: "low",
|
|
71
|
+
forbiddenActions: ["make network requests"],
|
|
72
|
+
timeoutMs: 5000,
|
|
73
|
+
}, "owner-secret");
|
|
74
|
+
assert.equal(res.statusCode, 200);
|
|
75
|
+
assert.equal(res.body.success, true);
|
|
76
|
+
assert.ok(received);
|
|
77
|
+
const envelope = received;
|
|
78
|
+
assert.equal(envelope.goal, "inspect repo");
|
|
79
|
+
assert.equal(envelope.riskLevel, "low");
|
|
80
|
+
assert.equal(envelope.timeoutMs, 5000);
|
|
81
|
+
assert.deepEqual(envelope.forbiddenActions, [
|
|
82
|
+
"read Akemon private memory outside this envelope",
|
|
83
|
+
"access files outside the stated workdir unless explicitly needed and reported",
|
|
84
|
+
"make network requests",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
it("rejects invalid envelope fields before calling the software agent", async () => {
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const res = await callSoftwareAgentEndpoint({
|
|
90
|
+
async sendTask(envelope) {
|
|
91
|
+
calls++;
|
|
92
|
+
return successResult(envelope);
|
|
93
|
+
},
|
|
94
|
+
}, { goal: "inspect repo", roleScope: "friend" }, "owner-secret");
|
|
95
|
+
assert.equal(res.statusCode, 400);
|
|
96
|
+
assert.match(res.body.error, /Invalid roleScope/);
|
|
97
|
+
assert.equal(calls, 0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
function successResult(envelope) {
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
taskId: envelope.taskId || "task-from-test",
|
|
104
|
+
output: "ok",
|
|
105
|
+
exitCode: 0,
|
|
106
|
+
durationMs: 1,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SoftwareAgentPeripheral — wraps full agent software as an Akemon peripheral.
|
|
3
|
+
*
|
|
4
|
+
* This is distinct from EnginePeripheral. Engines are pure compute: modules
|
|
5
|
+
* prepare context and the engine returns text. Software agent peripherals are
|
|
6
|
+
* external software bodies such as Codex CLI or Claude Code: they may manage
|
|
7
|
+
* their own repo context, tools, skills, and multi-step execution loop.
|
|
8
|
+
*
|
|
9
|
+
* Batch 5 starts with a conservative Codex `exec` baseline. It gives Akemon a
|
|
10
|
+
* stable task envelope, streaming, reset, and event shape before switching the
|
|
11
|
+
* transport to app-server or a true persistent interactive session.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
import { spawn } from "child_process";
|
|
15
|
+
import { StringDecoder } from "string_decoder";
|
|
16
|
+
import { SIG, sig } from "./types.js";
|
|
17
|
+
import { sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-client.js";
|
|
18
|
+
const defaultTaskRelay = {
|
|
19
|
+
sendTaskStart,
|
|
20
|
+
sendTaskStream,
|
|
21
|
+
sendTaskEnd,
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
+
const MAX_OWNER_TIMEOUT_MS = 60 * 60 * 1000;
|
|
25
|
+
const DEFAULT_OWNER_ALLOWED_ACTIONS = ["read repository files", "edit files in workdir", "run project tests"];
|
|
26
|
+
const DEFAULT_OWNER_FORBIDDEN_ACTIONS = [
|
|
27
|
+
"read Akemon private memory outside this envelope",
|
|
28
|
+
"access files outside the stated workdir unless explicitly needed and reported",
|
|
29
|
+
];
|
|
30
|
+
const ROLE_SCOPES = ["owner", "public", "order", "agent", "system"];
|
|
31
|
+
const MEMORY_SCOPES = ["none", "public", "task", "owner"];
|
|
32
|
+
const RISK_LEVELS = ["low", "medium", "high"];
|
|
33
|
+
export class CodexSoftwareAgentPeripheral {
|
|
34
|
+
id;
|
|
35
|
+
name;
|
|
36
|
+
capabilities = ["code-agent", "repo-inspect", "repo-edit", "tool-use", "skill-use", "streaming"];
|
|
37
|
+
tags = ["software-agent", "codex"];
|
|
38
|
+
config;
|
|
39
|
+
bus = null;
|
|
40
|
+
activeChild = null;
|
|
41
|
+
activeTaskId = null;
|
|
42
|
+
sessionId = randomUUID();
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.id = config.id || "software-agent:codex";
|
|
46
|
+
this.name = config.name || "Codex CLI Software Agent";
|
|
47
|
+
}
|
|
48
|
+
async start(bus) {
|
|
49
|
+
this.bus = bus;
|
|
50
|
+
}
|
|
51
|
+
async stop() {
|
|
52
|
+
await this.resetSession();
|
|
53
|
+
this.bus = null;
|
|
54
|
+
}
|
|
55
|
+
async startSession() {
|
|
56
|
+
// Codex `exec` baseline is oneshot per task. Keep a session id so callers
|
|
57
|
+
// can observe resets and future transports can preserve this interface.
|
|
58
|
+
if (!this.sessionId)
|
|
59
|
+
this.sessionId = randomUUID();
|
|
60
|
+
}
|
|
61
|
+
async resetSession() {
|
|
62
|
+
if (this.activeChild?.pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-this.activeChild.pid, "SIGTERM");
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(-this.activeChild.pid, "SIGKILL");
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}, 3000).unref();
|
|
73
|
+
}
|
|
74
|
+
this.activeChild = null;
|
|
75
|
+
this.activeTaskId = null;
|
|
76
|
+
this.sessionId = randomUUID();
|
|
77
|
+
}
|
|
78
|
+
getState() {
|
|
79
|
+
return {
|
|
80
|
+
id: this.id,
|
|
81
|
+
sessionId: this.sessionId,
|
|
82
|
+
activeTaskId: this.activeTaskId,
|
|
83
|
+
busy: !!this.activeChild,
|
|
84
|
+
transport: "codex-exec",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async send(signal) {
|
|
88
|
+
if (signal.type !== SIG.SOFTWARE_AGENT_TASK)
|
|
89
|
+
return null;
|
|
90
|
+
const envelope = signal.data.envelope;
|
|
91
|
+
if (!envelope) {
|
|
92
|
+
return sig(SIG.SOFTWARE_AGENT_RESPONSE, {
|
|
93
|
+
success: false,
|
|
94
|
+
error: "Missing task envelope",
|
|
95
|
+
}, this.id);
|
|
96
|
+
}
|
|
97
|
+
const result = await this.sendTask(envelope);
|
|
98
|
+
return sig(SIG.SOFTWARE_AGENT_RESPONSE, { ...result }, this.id);
|
|
99
|
+
}
|
|
100
|
+
async sendTask(envelope, signal) {
|
|
101
|
+
if (this.activeChild) {
|
|
102
|
+
throw new Error(`Software agent busy (task=${this.activeTaskId})`);
|
|
103
|
+
}
|
|
104
|
+
const taskId = envelope.taskId || `sw_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
105
|
+
const workdir = envelope.workdir || this.config.workdir;
|
|
106
|
+
const prompt = buildTaskEnvelopePrompt({ ...envelope, taskId, workdir });
|
|
107
|
+
const { cmd, args } = buildCodexExecCommand({
|
|
108
|
+
command: this.config.command || "codex",
|
|
109
|
+
workdir,
|
|
110
|
+
model: this.config.model,
|
|
111
|
+
sandbox: this.config.sandbox || "workspace-write",
|
|
112
|
+
});
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
const relay = this.config.taskRelay || defaultTaskRelay;
|
|
115
|
+
const commandLine = [cmd, ...args].join(" ");
|
|
116
|
+
const spawnImpl = this.config.spawnImpl || spawn;
|
|
117
|
+
const timeoutMs = envelope.timeoutMs || this.config.defaultTimeoutMs || DEFAULT_TIMEOUT_MS;
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
this.activeTaskId = taskId;
|
|
120
|
+
relay.sendTaskStart(taskId, "software_agent", commandLine);
|
|
121
|
+
this.bus?.emit(SIG.TASK_STARTED, sig(SIG.TASK_STARTED, {
|
|
122
|
+
taskId,
|
|
123
|
+
taskType: "software_agent",
|
|
124
|
+
description: envelope.goal,
|
|
125
|
+
peripheral: this.id,
|
|
126
|
+
sessionId: this.sessionId,
|
|
127
|
+
}, this.id));
|
|
128
|
+
let child;
|
|
129
|
+
try {
|
|
130
|
+
child = spawnImpl(cmd, args, {
|
|
131
|
+
cwd: workdir,
|
|
132
|
+
env: process.env,
|
|
133
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
134
|
+
detached: true,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.activeChild = null;
|
|
139
|
+
this.activeTaskId = null;
|
|
140
|
+
const durationMs = Date.now() - startedAt;
|
|
141
|
+
relay.sendTaskEnd(taskId, null, durationMs);
|
|
142
|
+
const result = {
|
|
143
|
+
success: false,
|
|
144
|
+
taskId,
|
|
145
|
+
output: "",
|
|
146
|
+
error: err.message || String(err),
|
|
147
|
+
exitCode: null,
|
|
148
|
+
durationMs,
|
|
149
|
+
};
|
|
150
|
+
this.bus?.emit(SIG.TASK_FAILED, sig(SIG.TASK_FAILED, result, this.id));
|
|
151
|
+
resolve(result);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.activeChild = child;
|
|
155
|
+
let stdout = "";
|
|
156
|
+
let stderr = "";
|
|
157
|
+
let finished = false;
|
|
158
|
+
let aborted = false;
|
|
159
|
+
const outDecoder = new StringDecoder("utf8");
|
|
160
|
+
const errDecoder = new StringDecoder("utf8");
|
|
161
|
+
const finish = (exitCode, error) => {
|
|
162
|
+
if (finished)
|
|
163
|
+
return;
|
|
164
|
+
finished = true;
|
|
165
|
+
signal?.removeEventListener("abort", onAbort);
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
const tailOut = outDecoder.end();
|
|
168
|
+
const tailErr = errDecoder.end();
|
|
169
|
+
if (tailOut) {
|
|
170
|
+
stdout += tailOut;
|
|
171
|
+
relay.sendTaskStream(taskId, "stdout", tailOut);
|
|
172
|
+
}
|
|
173
|
+
if (tailErr) {
|
|
174
|
+
stderr += tailErr;
|
|
175
|
+
relay.sendTaskStream(taskId, "stderr", tailErr);
|
|
176
|
+
}
|
|
177
|
+
const durationMs = Date.now() - startedAt;
|
|
178
|
+
relay.sendTaskEnd(taskId, exitCode, durationMs);
|
|
179
|
+
this.activeChild = null;
|
|
180
|
+
this.activeTaskId = null;
|
|
181
|
+
const output = stdout.trim() || stderr.trim();
|
|
182
|
+
const success = !error && !aborted && exitCode === 0;
|
|
183
|
+
const result = {
|
|
184
|
+
success,
|
|
185
|
+
taskId,
|
|
186
|
+
output,
|
|
187
|
+
error: success ? undefined : error || stderr.trim() || `codex exited with code ${exitCode}`,
|
|
188
|
+
exitCode,
|
|
189
|
+
durationMs,
|
|
190
|
+
};
|
|
191
|
+
this.bus?.emit(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, sig(success ? SIG.TASK_COMPLETED : SIG.TASK_FAILED, {
|
|
192
|
+
...result,
|
|
193
|
+
taskLabel: `software_agent:${this.id}`,
|
|
194
|
+
}, this.id));
|
|
195
|
+
resolve(result);
|
|
196
|
+
};
|
|
197
|
+
const onAbort = () => {
|
|
198
|
+
if (aborted || !child.pid)
|
|
199
|
+
return;
|
|
200
|
+
aborted = true;
|
|
201
|
+
try {
|
|
202
|
+
process.kill(-child.pid, "SIGTERM");
|
|
203
|
+
}
|
|
204
|
+
catch { }
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
try {
|
|
207
|
+
process.kill(-child.pid, "SIGKILL");
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}, 3000).unref();
|
|
211
|
+
};
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
if (!child.pid)
|
|
214
|
+
return;
|
|
215
|
+
aborted = true;
|
|
216
|
+
try {
|
|
217
|
+
process.kill(-child.pid, "SIGTERM");
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
setTimeout(() => {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(-child.pid, "SIGKILL");
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}, 3000).unref();
|
|
226
|
+
}, timeoutMs);
|
|
227
|
+
if (signal) {
|
|
228
|
+
if (signal.aborted)
|
|
229
|
+
onAbort();
|
|
230
|
+
else
|
|
231
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
232
|
+
}
|
|
233
|
+
child.stdin?.on("error", () => { });
|
|
234
|
+
child.stdin?.write(prompt);
|
|
235
|
+
child.stdin?.end();
|
|
236
|
+
child.stdout?.on("data", (chunk) => {
|
|
237
|
+
const text = outDecoder.write(chunk);
|
|
238
|
+
if (!text)
|
|
239
|
+
return;
|
|
240
|
+
stdout += text;
|
|
241
|
+
relay.sendTaskStream(taskId, "stdout", text);
|
|
242
|
+
});
|
|
243
|
+
child.stderr?.on("data", (chunk) => {
|
|
244
|
+
const text = errDecoder.write(chunk);
|
|
245
|
+
if (!text)
|
|
246
|
+
return;
|
|
247
|
+
stderr += text;
|
|
248
|
+
relay.sendTaskStream(taskId, "stderr", text);
|
|
249
|
+
});
|
|
250
|
+
child.on("close", (code) => {
|
|
251
|
+
child.unref();
|
|
252
|
+
finish(code);
|
|
253
|
+
});
|
|
254
|
+
child.on("error", (err) => {
|
|
255
|
+
child.unref();
|
|
256
|
+
finish(null, err.message);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function buildTaskEnvelopePrompt(envelope) {
|
|
262
|
+
const lines = [
|
|
263
|
+
"[Akemon Software Peripheral Task Envelope]",
|
|
264
|
+
"",
|
|
265
|
+
`Task ID: ${envelope.taskId || "(unspecified)"}`,
|
|
266
|
+
`Source module: ${envelope.sourceModule}`,
|
|
267
|
+
`Purpose: ${envelope.purpose}`,
|
|
268
|
+
`Role scope: ${envelope.roleScope}`,
|
|
269
|
+
`Memory scope: ${envelope.memoryScope}`,
|
|
270
|
+
`Risk level: ${envelope.riskLevel}`,
|
|
271
|
+
`Workdir: ${envelope.workdir}`,
|
|
272
|
+
"",
|
|
273
|
+
"Goal:",
|
|
274
|
+
envelope.goal,
|
|
275
|
+
"",
|
|
276
|
+
];
|
|
277
|
+
if (envelope.memorySummary?.trim()) {
|
|
278
|
+
lines.push("Visible Akemon memory/context:");
|
|
279
|
+
lines.push(envelope.memorySummary.trim());
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
if (envelope.allowedActions?.length) {
|
|
283
|
+
lines.push("Allowed actions:");
|
|
284
|
+
for (const item of envelope.allowedActions)
|
|
285
|
+
lines.push(`- ${item}`);
|
|
286
|
+
lines.push("");
|
|
287
|
+
}
|
|
288
|
+
if (envelope.forbiddenActions?.length) {
|
|
289
|
+
lines.push("Forbidden actions:");
|
|
290
|
+
for (const item of envelope.forbiddenActions)
|
|
291
|
+
lines.push(`- ${item}`);
|
|
292
|
+
lines.push("");
|
|
293
|
+
}
|
|
294
|
+
if (envelope.deliverable?.trim()) {
|
|
295
|
+
lines.push("Expected deliverable:");
|
|
296
|
+
lines.push(envelope.deliverable.trim());
|
|
297
|
+
lines.push("");
|
|
298
|
+
}
|
|
299
|
+
lines.push("Instructions:");
|
|
300
|
+
lines.push("- Treat this envelope as the complete Akemon-provided context for this task.");
|
|
301
|
+
lines.push("- Do not attempt to read Akemon private memory outside the visible context above.");
|
|
302
|
+
lines.push("- Work only in the stated workdir unless the envelope explicitly allows otherwise.");
|
|
303
|
+
lines.push("- Report what changed, what you verified, and any remaining risk.");
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
export function createOwnerTaskEnvelope(body, defaultWorkdir) {
|
|
307
|
+
const goal = typeof body?.goal === "string" ? body.goal.trim() : "";
|
|
308
|
+
if (!goal)
|
|
309
|
+
throw new Error("Missing required string field: goal");
|
|
310
|
+
const callerForbiddenActions = readOptionalStringArray(body.forbiddenActions, "forbiddenActions");
|
|
311
|
+
return {
|
|
312
|
+
taskId: readOptionalString(body.taskId, "taskId"),
|
|
313
|
+
sourceModule: "owner-http",
|
|
314
|
+
purpose: readOptionalString(body.purpose, "purpose") || "owner software-agent task",
|
|
315
|
+
goal,
|
|
316
|
+
workdir: readOptionalString(body.workdir, "workdir") || defaultWorkdir,
|
|
317
|
+
roleScope: readEnum(body.roleScope, "roleScope", ROLE_SCOPES, "owner"),
|
|
318
|
+
memoryScope: readEnum(body.memoryScope, "memoryScope", MEMORY_SCOPES, "owner"),
|
|
319
|
+
riskLevel: readEnum(body.riskLevel, "riskLevel", RISK_LEVELS, "medium"),
|
|
320
|
+
allowedActions: body.allowedActions !== undefined
|
|
321
|
+
? readOptionalStringArray(body.allowedActions, "allowedActions")
|
|
322
|
+
: [...DEFAULT_OWNER_ALLOWED_ACTIONS],
|
|
323
|
+
forbiddenActions: [...new Set([...DEFAULT_OWNER_FORBIDDEN_ACTIONS, ...callerForbiddenActions])],
|
|
324
|
+
memorySummary: typeof body.memorySummary === "string" ? body.memorySummary : "",
|
|
325
|
+
deliverable: typeof body.deliverable === "string"
|
|
326
|
+
? body.deliverable
|
|
327
|
+
: "Return a concise engineering summary with changes, verification, and remaining risks.",
|
|
328
|
+
timeoutMs: readTimeoutMs(body.timeoutMs),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function readOptionalString(value, field) {
|
|
332
|
+
if (value === undefined || value === null)
|
|
333
|
+
return undefined;
|
|
334
|
+
if (typeof value !== "string")
|
|
335
|
+
throw new Error(`Invalid ${field}: expected string`);
|
|
336
|
+
const trimmed = value.trim();
|
|
337
|
+
return trimmed || undefined;
|
|
338
|
+
}
|
|
339
|
+
function readOptionalStringArray(value, field) {
|
|
340
|
+
if (value === undefined || value === null)
|
|
341
|
+
return [];
|
|
342
|
+
if (!Array.isArray(value))
|
|
343
|
+
throw new Error(`Invalid ${field}: expected array of strings`);
|
|
344
|
+
return value.map((item, index) => {
|
|
345
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
346
|
+
throw new Error(`Invalid ${field}[${index}]: expected non-empty string`);
|
|
347
|
+
}
|
|
348
|
+
return item.trim();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function readEnum(value, field, allowed, fallback) {
|
|
352
|
+
if (value === undefined || value === null || value === "")
|
|
353
|
+
return fallback;
|
|
354
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
355
|
+
throw new Error(`Invalid ${field}: expected one of ${allowed.join(", ")}`);
|
|
356
|
+
}
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
function readTimeoutMs(value) {
|
|
360
|
+
if (value === undefined || value === null)
|
|
361
|
+
return undefined;
|
|
362
|
+
if (typeof value !== "number"
|
|
363
|
+
|| !Number.isInteger(value)
|
|
364
|
+
|| value <= 0
|
|
365
|
+
|| value > MAX_OWNER_TIMEOUT_MS) {
|
|
366
|
+
throw new Error(`Invalid timeoutMs: expected integer between 1 and ${MAX_OWNER_TIMEOUT_MS}`);
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
function buildCodexExecCommand(opts) {
|
|
371
|
+
const args = [
|
|
372
|
+
"exec",
|
|
373
|
+
"--skip-git-repo-check",
|
|
374
|
+
"--color", "never",
|
|
375
|
+
"-s", opts.sandbox,
|
|
376
|
+
"-C", opts.workdir,
|
|
377
|
+
];
|
|
378
|
+
if (opts.model)
|
|
379
|
+
args.push("-m", opts.model);
|
|
380
|
+
args.push("-");
|
|
381
|
+
return { cmd: opts.command, args };
|
|
382
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import { CodexSoftwareAgentPeripheral, buildTaskEnvelopePrompt, createOwnerTaskEnvelope, } from "./software-agent-peripheral.js";
|
|
6
|
+
import { SimpleEventBus } from "./event-bus.js";
|
|
7
|
+
import { SIG } from "./types.js";
|
|
8
|
+
function createFakeChild() {
|
|
9
|
+
const child = new EventEmitter();
|
|
10
|
+
child.stdin = new PassThrough();
|
|
11
|
+
child.stdout = new PassThrough();
|
|
12
|
+
child.stderr = new PassThrough();
|
|
13
|
+
Object.defineProperty(child, "pid", { value: 23456, configurable: true });
|
|
14
|
+
child.unref = () => child;
|
|
15
|
+
return child;
|
|
16
|
+
}
|
|
17
|
+
function baseEnvelope(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
taskId: "sw-test-1",
|
|
20
|
+
sourceModule: "task",
|
|
21
|
+
purpose: "dogfood coding task",
|
|
22
|
+
goal: "Inspect the repo and summarize the event bus implementation.",
|
|
23
|
+
workdir: "/tmp/akemon",
|
|
24
|
+
roleScope: "owner",
|
|
25
|
+
memoryScope: "owner",
|
|
26
|
+
riskLevel: "medium",
|
|
27
|
+
allowedActions: ["read repository files", "run tests"],
|
|
28
|
+
forbiddenActions: ["read owner private notes outside this envelope"],
|
|
29
|
+
memorySummary: "Visible context only.",
|
|
30
|
+
deliverable: "Concise engineering report.",
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
describe("buildTaskEnvelopePrompt", () => {
|
|
35
|
+
it("renders envelope fields, visible memory, boundaries, and deliverable", () => {
|
|
36
|
+
const prompt = buildTaskEnvelopePrompt(baseEnvelope());
|
|
37
|
+
assert.match(prompt, /Task ID: sw-test-1/);
|
|
38
|
+
assert.match(prompt, /Source module: task/);
|
|
39
|
+
assert.match(prompt, /Role scope: owner/);
|
|
40
|
+
assert.match(prompt, /Memory scope: owner/);
|
|
41
|
+
assert.match(prompt, /Risk level: medium/);
|
|
42
|
+
assert.match(prompt, /Workdir: \/tmp\/akemon/);
|
|
43
|
+
assert.match(prompt, /Visible context only\./);
|
|
44
|
+
assert.match(prompt, /- read repository files/);
|
|
45
|
+
assert.match(prompt, /- read owner private notes outside this envelope/);
|
|
46
|
+
assert.match(prompt, /Concise engineering report\./);
|
|
47
|
+
assert.match(prompt, /Do not attempt to read Akemon private memory outside the visible context/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("createOwnerTaskEnvelope", () => {
|
|
51
|
+
it("builds conservative owner defaults from a minimal body", () => {
|
|
52
|
+
const envelope = createOwnerTaskEnvelope({ goal: " inspect repo " }, "/repo");
|
|
53
|
+
assert.equal(envelope.sourceModule, "owner-http");
|
|
54
|
+
assert.equal(envelope.goal, "inspect repo");
|
|
55
|
+
assert.equal(envelope.workdir, "/repo");
|
|
56
|
+
assert.equal(envelope.roleScope, "owner");
|
|
57
|
+
assert.equal(envelope.memoryScope, "owner");
|
|
58
|
+
assert.equal(envelope.riskLevel, "medium");
|
|
59
|
+
assert.deepEqual(envelope.allowedActions, ["read repository files", "edit files in workdir", "run project tests"]);
|
|
60
|
+
assert.ok(envelope.forbiddenActions?.some((item) => item.includes("private memory")));
|
|
61
|
+
});
|
|
62
|
+
it("keeps baseline forbidden boundaries when caller adds restrictions", () => {
|
|
63
|
+
const envelope = createOwnerTaskEnvelope({
|
|
64
|
+
goal: "inspect repo",
|
|
65
|
+
forbiddenActions: ["make network requests", "read Akemon private memory outside this envelope"],
|
|
66
|
+
}, "/repo");
|
|
67
|
+
assert.deepEqual(envelope.forbiddenActions, [
|
|
68
|
+
"read Akemon private memory outside this envelope",
|
|
69
|
+
"access files outside the stated workdir unless explicitly needed and reported",
|
|
70
|
+
"make network requests",
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
it("rejects empty goals", () => {
|
|
74
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: " " }, "/repo"), /Missing required string field: goal/);
|
|
75
|
+
});
|
|
76
|
+
it("rejects invalid scope, action, and timeout fields", () => {
|
|
77
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", roleScope: "friend" }, "/repo"), /Invalid roleScope/);
|
|
78
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", memoryScope: "private" }, "/repo"), /Invalid memoryScope/);
|
|
79
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", riskLevel: "critical" }, "/repo"), /Invalid riskLevel/);
|
|
80
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", allowedActions: ["read", ""] }, "/repo"), /Invalid allowedActions\[1\]/);
|
|
81
|
+
assert.throws(() => createOwnerTaskEnvelope({ goal: "inspect repo", timeoutMs: 0 }, "/repo"), /Invalid timeoutMs/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe("CodexSoftwareAgentPeripheral", () => {
|
|
85
|
+
it("runs codex exec with an envelope over stdin and streams lifecycle events", async () => {
|
|
86
|
+
const streamEvents = [];
|
|
87
|
+
const busEvents = [];
|
|
88
|
+
let writtenPrompt = "";
|
|
89
|
+
let spawnedChild = null;
|
|
90
|
+
const bus = new SimpleEventBus();
|
|
91
|
+
bus.on(SIG.TASK_STARTED, (signal) => {
|
|
92
|
+
busEvents.push(`${signal.type}:${signal.data.taskId}`);
|
|
93
|
+
});
|
|
94
|
+
bus.on(SIG.TASK_COMPLETED, (signal) => {
|
|
95
|
+
busEvents.push(`${signal.type}:${signal.data.taskId}`);
|
|
96
|
+
});
|
|
97
|
+
const peripheral = new CodexSoftwareAgentPeripheral({
|
|
98
|
+
workdir: "/tmp/akemon",
|
|
99
|
+
command: "codex",
|
|
100
|
+
spawnImpl: ((cmd, args, opts) => {
|
|
101
|
+
assert.equal(cmd, "codex");
|
|
102
|
+
assert.deepEqual(args, [
|
|
103
|
+
"exec",
|
|
104
|
+
"--skip-git-repo-check",
|
|
105
|
+
"--color", "never",
|
|
106
|
+
"-s", "workspace-write",
|
|
107
|
+
"-C", "/tmp/akemon",
|
|
108
|
+
"-",
|
|
109
|
+
]);
|
|
110
|
+
assert.equal(opts?.cwd, "/tmp/akemon");
|
|
111
|
+
const child = createFakeChild();
|
|
112
|
+
spawnedChild = child;
|
|
113
|
+
child.stdin?.on("data", (chunk) => {
|
|
114
|
+
writtenPrompt += chunk.toString("utf8");
|
|
115
|
+
});
|
|
116
|
+
queueMicrotask(() => {
|
|
117
|
+
child.stdout?.emit("data", Buffer.from("result "));
|
|
118
|
+
child.stderr?.emit("data", Buffer.from("note"));
|
|
119
|
+
child.stdout?.emit("data", Buffer.from("ok"));
|
|
120
|
+
child.emit("close", 0);
|
|
121
|
+
});
|
|
122
|
+
return child;
|
|
123
|
+
}),
|
|
124
|
+
taskRelay: {
|
|
125
|
+
sendTaskStart(taskId, origin, cmd) {
|
|
126
|
+
streamEvents.push({ type: "start", taskId, origin, cmd });
|
|
127
|
+
},
|
|
128
|
+
sendTaskStream(taskId, stream, chunk) {
|
|
129
|
+
streamEvents.push({ type: "stream", taskId, stream, chunk });
|
|
130
|
+
},
|
|
131
|
+
sendTaskEnd(taskId, exitCode, durationMs) {
|
|
132
|
+
streamEvents.push({ type: "end", taskId, exitCode, durationMs });
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await peripheral.start(bus);
|
|
137
|
+
const result = await peripheral.sendTask(baseEnvelope());
|
|
138
|
+
assert.ok(spawnedChild, "spawn should be called");
|
|
139
|
+
assert.equal(result.success, true);
|
|
140
|
+
assert.equal(result.taskId, "sw-test-1");
|
|
141
|
+
assert.equal(result.output, "result ok");
|
|
142
|
+
assert.equal(result.exitCode, 0);
|
|
143
|
+
assert.match(writtenPrompt, /Akemon Software Peripheral Task Envelope/);
|
|
144
|
+
assert.match(writtenPrompt, /Goal:\nInspect the repo/);
|
|
145
|
+
assert.match(writtenPrompt, /Visible context only\./);
|
|
146
|
+
assert.deepEqual(streamEvents.slice(0, 4), [
|
|
147
|
+
{
|
|
148
|
+
type: "start",
|
|
149
|
+
taskId: "sw-test-1",
|
|
150
|
+
origin: "software_agent",
|
|
151
|
+
cmd: "codex exec --skip-git-repo-check --color never -s workspace-write -C /tmp/akemon -",
|
|
152
|
+
},
|
|
153
|
+
{ type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "result " },
|
|
154
|
+
{ type: "stream", taskId: "sw-test-1", stream: "stderr", chunk: "note" },
|
|
155
|
+
{ type: "stream", taskId: "sw-test-1", stream: "stdout", chunk: "ok" },
|
|
156
|
+
]);
|
|
157
|
+
const end = streamEvents[4];
|
|
158
|
+
assert.equal(end?.type, "end");
|
|
159
|
+
if (end?.type === "end") {
|
|
160
|
+
assert.equal(end.taskId, "sw-test-1");
|
|
161
|
+
assert.equal(end.exitCode, 0);
|
|
162
|
+
assert.ok(end.durationMs >= 0);
|
|
163
|
+
}
|
|
164
|
+
assert.deepEqual(busEvents, [
|
|
165
|
+
"task:started:sw-test-1",
|
|
166
|
+
"task:completed:sw-test-1",
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
it("rejects concurrent tasks while a codex run is active", async () => {
|
|
170
|
+
const peripheral = new CodexSoftwareAgentPeripheral({
|
|
171
|
+
workdir: "/tmp/akemon",
|
|
172
|
+
spawnImpl: (() => {
|
|
173
|
+
const child = createFakeChild();
|
|
174
|
+
setTimeout(() => child.emit("close", 0), 10);
|
|
175
|
+
return child;
|
|
176
|
+
}),
|
|
177
|
+
taskRelay: {
|
|
178
|
+
sendTaskStart() { },
|
|
179
|
+
sendTaskStream() { },
|
|
180
|
+
sendTaskEnd() { },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const first = peripheral.sendTask(baseEnvelope({ taskId: "first" }));
|
|
184
|
+
await assert.rejects(() => peripheral.sendTask(baseEnvelope({ taskId: "second" })), /Software agent busy/);
|
|
185
|
+
await first;
|
|
186
|
+
});
|
|
187
|
+
});
|
package/dist/types.js
CHANGED
|
@@ -23,6 +23,9 @@ export const SIG = {
|
|
|
23
23
|
ENGINE_RESPONSE: "engine:response",
|
|
24
24
|
ENGINE_BUSY: "engine:busy",
|
|
25
25
|
ENGINE_FREE: "engine:free",
|
|
26
|
+
// Software agent peripherals (Codex CLI, Claude Code, etc.)
|
|
27
|
+
SOFTWARE_AGENT_TASK: "software-agent:task",
|
|
28
|
+
SOFTWARE_AGENT_RESPONSE: "software-agent:response",
|
|
26
29
|
// Task execution
|
|
27
30
|
TASK_RECEIVED: "task:received",
|
|
28
31
|
TASK_STARTED: "task:started",
|