@awak-app/simy-cli 0.1.0 → 0.1.1
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 +10 -4
- package/package.json +2 -2
- package/src/agent.js +34 -18
- package/src/index.js +23 -3
- package/src/session-store.js +46 -17
- package/src/web-origin.js +46 -0
package/README.md
CHANGED
|
@@ -7,13 +7,19 @@ npx @awak-app/simy-cli
|
|
|
7
7
|
npm install -g @awak-app/simy-cli
|
|
8
8
|
simy
|
|
9
9
|
simy --daemon
|
|
10
|
+
simy --host https://simy.example.com
|
|
10
11
|
```
|
|
11
12
|
|
|
13
|
+
The published CLI connects to `https://app.simy.one` by default. Use `--host`
|
|
14
|
+
with an absolute SIMY Web origin for another deployment. Non-loopback hosts
|
|
15
|
+
must use HTTPS. `SIMY_WEB_ORIGIN` and the legacy `SIMY_API_ORIGIN` remain
|
|
16
|
+
available for automation, but the command-line flag takes precedence.
|
|
17
|
+
|
|
12
18
|
The CLI starts a loopback HTTP agent on a random available port, keeps a
|
|
13
|
-
48-hour local session, and owns the coding-loop state machine
|
|
14
|
-
web UI sends a backend-issued launch challenge. It builds the
|
|
15
|
-
charter, runs Codex or Claude Code, audits structured completion
|
|
16
|
-
re-instructs the executor within the configured attempt budget.
|
|
19
|
+
48-hour, origin-scoped local session, and owns the coding-loop state machine
|
|
20
|
+
when the SIMY web UI sends a backend-issued launch challenge. It builds the
|
|
21
|
+
requirement charter, runs Codex or Claude Code, audits structured completion
|
|
22
|
+
evidence, and re-instructs the executor within the configured attempt budget.
|
|
17
23
|
|
|
18
24
|
Structured run snapshots and process events are persisted remotely. Executor
|
|
19
25
|
instructions and the latest bounded log lines are redacted at both the CLI and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local SIMY coding-loop agent for Codex and Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"start": "node ./src/index.js",
|
|
15
15
|
"test": "node --test",
|
|
16
16
|
"check": "npm run check:syntax && npm test && npm run check:package",
|
|
17
|
-
"check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/session-store.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/check-package-contents.js",
|
|
17
|
+
"check:syntax": "node --check ./src/index.js && node --check ./src/agent.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./scripts/check-package-contents.js",
|
|
18
18
|
"check:package": "node ./scripts/check-package-contents.js"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
package/src/agent.js
CHANGED
|
@@ -16,19 +16,26 @@ import {
|
|
|
16
16
|
sessionPath,
|
|
17
17
|
writeSession,
|
|
18
18
|
} from "./session-store.js";
|
|
19
|
+
import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
export async function startAgent({
|
|
22
|
+
requestedPort = 0,
|
|
23
|
+
daemon = false,
|
|
24
|
+
webOrigin = null,
|
|
25
|
+
sessionRoot,
|
|
26
|
+
} = {}) {
|
|
27
|
+
const apiOrigin = resolveWebOrigin(webOrigin);
|
|
25
28
|
const registry = new LocalRunRegistry();
|
|
26
29
|
const authNonce = randomBytes(16).toString("base64url");
|
|
27
|
-
let session = await readSession();
|
|
30
|
+
let session = await readSession(apiOrigin, sessionRoot);
|
|
28
31
|
|
|
29
32
|
const server = createServer(async (req, res) => {
|
|
30
33
|
try {
|
|
31
|
-
|
|
34
|
+
if (!isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
|
|
35
|
+
json(res, 403, { error: "request origin does not match the configured SIMY Web host" });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
applyCors(req, res, apiOrigin);
|
|
32
39
|
if (req.method === "OPTIONS") {
|
|
33
40
|
res.writeHead(204).end();
|
|
34
41
|
return;
|
|
@@ -39,7 +46,8 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
39
46
|
json(res, 200, {
|
|
40
47
|
ok: true,
|
|
41
48
|
daemon,
|
|
42
|
-
|
|
49
|
+
web_origin: apiOrigin,
|
|
50
|
+
session_valid: isSessionValid(session, Date.now(), apiOrigin),
|
|
43
51
|
session_expires_at: session?.expires_at ?? null,
|
|
44
52
|
});
|
|
45
53
|
return;
|
|
@@ -54,24 +62,28 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
54
62
|
json(res, 401, { error: "invalid nonce" });
|
|
55
63
|
return;
|
|
56
64
|
}
|
|
65
|
+
if (body.api_origin !== apiOrigin) {
|
|
66
|
+
json(res, 403, { error: "authorization origin does not match the configured host" });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
57
69
|
session = {
|
|
58
70
|
token: String(body.token || ""),
|
|
59
71
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
60
|
-
api_origin:
|
|
72
|
+
api_origin: apiOrigin,
|
|
61
73
|
expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
|
|
62
74
|
};
|
|
63
|
-
await writeSession(session);
|
|
75
|
+
await writeSession(apiOrigin, session, sessionRoot);
|
|
64
76
|
json(res, 200, { ok: true, expires_at: session.expires_at });
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
67
79
|
if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
|
|
68
|
-
if (!isSessionValid(session)) {
|
|
80
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
69
81
|
json(res, 401, { error: "simy session expired; run simy again" });
|
|
70
82
|
return;
|
|
71
83
|
}
|
|
72
84
|
const body = await readJson(req);
|
|
73
85
|
const verified = await verifyLaunchChallenge({
|
|
74
|
-
apiOrigin
|
|
86
|
+
apiOrigin,
|
|
75
87
|
token: session.token,
|
|
76
88
|
runId: String(body.run_id || ""),
|
|
77
89
|
challenge: String(body.challenge || ""),
|
|
@@ -125,7 +137,7 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
125
137
|
proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
|
|
126
138
|
},
|
|
127
139
|
session,
|
|
128
|
-
apiOrigin
|
|
140
|
+
apiOrigin,
|
|
129
141
|
});
|
|
130
142
|
registry.create(run);
|
|
131
143
|
void startLocalCodingRun(run);
|
|
@@ -165,13 +177,14 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
|
|
|
165
177
|
const address = server.address();
|
|
166
178
|
const port = typeof address === "object" && address ? address.port : requestedPort;
|
|
167
179
|
console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
|
|
180
|
+
console.log(`SIMY Web host: ${apiOrigin}`);
|
|
168
181
|
|
|
169
|
-
if (!isSessionValid(session)) {
|
|
182
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
170
183
|
const loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
171
184
|
loginUrl.searchParams.set("port", String(port));
|
|
172
185
|
loginUrl.searchParams.set("nonce", authNonce);
|
|
173
186
|
console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
|
|
174
|
-
console.log(`Session file: ${sessionPath()}`);
|
|
187
|
+
console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
return { server, port };
|
|
@@ -248,11 +261,14 @@ function streamRun(res, run) {
|
|
|
248
261
|
res.on("close", () => run.emitter.off("event", listener));
|
|
249
262
|
}
|
|
250
263
|
|
|
251
|
-
function applyCors(req, res) {
|
|
264
|
+
function applyCors(req, res, webOrigin) {
|
|
252
265
|
const origin = req.headers.origin;
|
|
253
|
-
if (origin
|
|
254
|
-
res.setHeader("Access-Control-Allow-Origin",
|
|
266
|
+
if (origin === webOrigin) {
|
|
267
|
+
res.setHeader("Access-Control-Allow-Origin", webOrigin);
|
|
255
268
|
res.setHeader("Vary", "Origin");
|
|
269
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
270
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
271
|
+
}
|
|
256
272
|
}
|
|
257
273
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
258
274
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
package/src/index.js
CHANGED
|
@@ -4,8 +4,10 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
import { startAgent } from "./agent.js";
|
|
7
|
+
import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const args = new Set(argv);
|
|
9
11
|
|
|
10
12
|
if (args.has("--help") || args.has("-h")) {
|
|
11
13
|
console.log(`Usage:
|
|
@@ -15,12 +17,20 @@ if (args.has("--help") || args.has("-h")) {
|
|
|
15
17
|
Options:
|
|
16
18
|
--daemon, --deamon Run detached in the background
|
|
17
19
|
--port <port> Bind a specific localhost port
|
|
20
|
+
--host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
|
|
18
21
|
`);
|
|
19
22
|
process.exit(0);
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const daemon = args.has("--daemon") || args.has("--deamon");
|
|
23
|
-
const port = readPort(
|
|
26
|
+
const port = readPort(argv);
|
|
27
|
+
let webOrigin;
|
|
28
|
+
try {
|
|
29
|
+
webOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
24
34
|
|
|
25
35
|
if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
|
|
26
36
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
@@ -33,7 +43,7 @@ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
|
|
|
33
43
|
process.exit(0);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
|
-
await startAgent({ requestedPort: port, daemon });
|
|
46
|
+
await startAgent({ requestedPort: port, daemon, webOrigin });
|
|
37
47
|
|
|
38
48
|
function readPort(argv) {
|
|
39
49
|
const index = argv.indexOf("--port");
|
|
@@ -45,3 +55,13 @@ function readPort(argv) {
|
|
|
45
55
|
}
|
|
46
56
|
return value;
|
|
47
57
|
}
|
|
58
|
+
|
|
59
|
+
function readOption(argv, name) {
|
|
60
|
+
const index = argv.indexOf(name);
|
|
61
|
+
if (index === -1) return null;
|
|
62
|
+
const value = argv[index + 1];
|
|
63
|
+
if (!value || value.startsWith("--")) {
|
|
64
|
+
throw new Error(`${name} requires a value`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
package/src/session-store.js
CHANGED
|
@@ -1,21 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
import { normalizeWebOrigin } from "./web-origin.js";
|
|
7
|
+
|
|
6
8
|
const SESSION_TTL_MS = 48 * 60 * 60 * 1000;
|
|
7
9
|
|
|
8
|
-
export function sessionPath() {
|
|
9
|
-
|
|
10
|
+
export function sessionPath(webOrigin, sessionRoot = defaultSessionRoot()) {
|
|
11
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
12
|
+
const originHash = createHash("sha256").update(origin).digest("hex");
|
|
13
|
+
return join(sessionRoot, "sessions", `${originHash}.json`);
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export function expiresAtFromNow(now = Date.now()) {
|
|
13
17
|
return new Date(now + SESSION_TTL_MS).toISOString();
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
export async function readSession() {
|
|
20
|
+
export async function readSession(webOrigin, sessionRoot = defaultSessionRoot()) {
|
|
21
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
22
|
+
const scoped = await readSessionFile(sessionPath(origin, sessionRoot));
|
|
23
|
+
if (sessionMatchesOrigin(scoped, origin)) return scoped;
|
|
24
|
+
|
|
25
|
+
const legacy = await readSessionFile(join(sessionRoot, "session.json"));
|
|
26
|
+
return sessionMatchesOrigin(legacy, origin) ? legacy : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isSessionValid(session, now = Date.now(), webOrigin = null) {
|
|
30
|
+
if (!session?.expires_at) return false;
|
|
31
|
+
if (webOrigin && !sessionMatchesOrigin(session, normalizeWebOrigin(webOrigin))) return false;
|
|
32
|
+
const expiresAt = Date.parse(session.expires_at);
|
|
33
|
+
return Number.isFinite(expiresAt) && expiresAt > now;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeSession(webOrigin, session, sessionRoot = defaultSessionRoot()) {
|
|
37
|
+
const origin = normalizeWebOrigin(webOrigin);
|
|
38
|
+
const targetPath = sessionPath(origin, sessionRoot);
|
|
39
|
+
await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
40
|
+
await writeFile(targetPath, `${JSON.stringify({ ...session, api_origin: origin }, null, 2)}\n`, {
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultSessionRoot() {
|
|
47
|
+
return process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readSessionFile(path) {
|
|
17
51
|
try {
|
|
18
|
-
const raw = await readFile(
|
|
52
|
+
const raw = await readFile(path, "utf8");
|
|
19
53
|
const parsed = JSON.parse(raw);
|
|
20
54
|
if (!parsed || typeof parsed !== "object") return null;
|
|
21
55
|
if (typeof parsed.token !== "string" || typeof parsed.expires_at !== "string") return null;
|
|
@@ -26,16 +60,11 @@ export async function readSession() {
|
|
|
26
60
|
}
|
|
27
61
|
}
|
|
28
62
|
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
await mkdir(dirname(SESSION_PATH), { recursive: true, mode: 0o700 });
|
|
37
|
-
await writeFile(SESSION_PATH, `${JSON.stringify(session, null, 2)}\n`, {
|
|
38
|
-
encoding: "utf8",
|
|
39
|
-
mode: 0o600,
|
|
40
|
-
});
|
|
63
|
+
function sessionMatchesOrigin(session, origin) {
|
|
64
|
+
if (typeof session?.api_origin !== "string") return false;
|
|
65
|
+
try {
|
|
66
|
+
return normalizeWebOrigin(session.api_origin) === origin;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
41
70
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const DEFAULT_WEB_ORIGIN = "https://app.simy.one";
|
|
2
|
+
|
|
3
|
+
export function resolveWebOrigin(host, env = process.env) {
|
|
4
|
+
return normalizeWebOrigin(
|
|
5
|
+
host || env.SIMY_WEB_ORIGIN || env.SIMY_API_ORIGIN || DEFAULT_WEB_ORIGIN,
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeWebOrigin(value) {
|
|
10
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
11
|
+
throw new Error("SIMY Web host must be an absolute http(s) URL.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value.trim());
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error("SIMY Web host must be an absolute http(s) URL.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
22
|
+
throw new Error("SIMY Web host must use http or https.");
|
|
23
|
+
}
|
|
24
|
+
if (url.username || url.password) {
|
|
25
|
+
throw new Error("SIMY Web host must not contain credentials.");
|
|
26
|
+
}
|
|
27
|
+
if ((url.pathname && url.pathname !== "/") || url.search || url.hash) {
|
|
28
|
+
throw new Error("SIMY Web host must contain only an origin, without a path or query.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
32
|
+
if (url.protocol === "http:" && !loopbackHosts.has(url.hostname)) {
|
|
33
|
+
throw new Error("SIMY Web host must use https unless it is a loopback address.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return url.origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isRequestOriginAllowed(requestOrigin, webOrigin) {
|
|
40
|
+
if (!requestOrigin) return true;
|
|
41
|
+
try {
|
|
42
|
+
return normalizeWebOrigin(requestOrigin) === webOrigin;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|