@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 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 when the SIMY
14
- web UI sends a backend-issued launch challenge. It builds the requirement
15
- charter, runs Codex or Claude Code, audits structured completion evidence, and
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.0",
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
- const DEFAULT_WEB_ORIGIN = "http://localhost:3000";
21
- const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/app\.simy\.one$/;
22
-
23
- export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
24
- const apiOrigin = process.env.SIMY_API_ORIGIN || DEFAULT_WEB_ORIGIN;
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
- applyCors(req, res);
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
- session_valid: isSessionValid(session),
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: typeof body.api_origin === "string" ? body.api_origin : apiOrigin,
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: session.api_origin || 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: session.api_origin || 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 && ALLOWED_ORIGIN_RE.test(origin)) {
254
- res.setHeader("Access-Control-Allow-Origin", 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 args = new Set(process.argv.slice(2));
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(process.argv.slice(2));
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
+ }
@@ -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
- const SESSION_PATH = join(homedir(), ".simy", "session.json");
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
- return SESSION_PATH;
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(SESSION_PATH, "utf8");
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
- export function isSessionValid(session, now = Date.now()) {
30
- if (!session?.expires_at) return false;
31
- const expiresAt = Date.parse(session.expires_at);
32
- return Number.isFinite(expiresAt) && expiresAt > now;
33
- }
34
-
35
- export async function writeSession(session) {
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
+ }