@inneranimalmedia/agentsam-sdk 2.5.0 → 2.6.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.
Files changed (211) hide show
  1. package/AGENTSAM.md +55 -0
  2. package/README.md +12 -8
  3. package/bin/agentsam +2 -0
  4. package/docs/AGENTSAM_ASTRA_OPENAI_INTEGRATION.md +1363 -0
  5. package/docs/CLI_SHELL.md +163 -53
  6. package/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
  7. package/docs/RELEASES.md +16 -7
  8. package/docs/SOURCE_ARCHITECTURE.md +58 -0
  9. package/docs/TEST_TIERS.md +26 -0
  10. package/migrations/runtime/0001_cli_runtime.sql +298 -0
  11. package/package.json +45 -12
  12. package/packages/agentsam-repository/README.md +15 -0
  13. package/packages/agentsam-repository/package.json +25 -0
  14. package/packages/agentsam-repository/src/contracts.js +113 -0
  15. package/packages/agentsam-repository/src/index.js +3 -0
  16. package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
  17. package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
  18. package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
  19. package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
  20. package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
  21. package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
  22. package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
  23. package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
  24. package/packages/connectors/cloudflare/package.json +10 -0
  25. package/packages/connectors/cloudflare/src/index.js +127 -0
  26. package/packages/connectors/cloudflare/src/owner.js +76 -0
  27. package/packages/connectors/cloudflare/src/routes.js +223 -0
  28. package/packages/connectors/cloudflare/src/vault.js +80 -0
  29. package/packages/connectors/cloudflare/tests/connector.test.mjs +44 -0
  30. package/packages/identity/package.json +2 -2
  31. package/packages/identity/src/contracts/auth-config.js +18 -7
  32. package/packages/identity/tests/auth-config.test.mjs +9 -5
  33. package/packages/identity/tests/oauth-credentials.test.mjs +4 -4
  34. package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
  35. package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
  36. package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
  37. package/protocol/README.md +1 -0
  38. package/protocol/capabilities/cloudflare-cpu-audit-input.schema.json +19 -0
  39. package/protocol/capabilities/cloudflare-cpu-profile-input.schema.json +13 -0
  40. package/protocol/capabilities/cloudflare-wrangler-native-input.schema.json +19 -0
  41. package/protocol/capabilities/manifest.json +47 -0
  42. package/protocol/context/context-budget.schema.json +10 -15
  43. package/protocol/context/context-item.schema.json +4 -5
  44. package/protocol/context/resolved-context-pack.schema.json +19 -14
  45. package/protocol/models/README.md +373 -0
  46. package/protocol/models/model-inventory-v2.schema.json +212 -0
  47. package/protocol/repository/repository-contract.schema.json +24 -0
  48. package/protocol/repository/repository-dependency.schema.json +24 -0
  49. package/protocol/repository/repository-identity.schema.json +17 -0
  50. package/protocol/rpc/v1/common.proto +16 -0
  51. package/protocol/rpc/v1/errors.proto +35 -0
  52. package/protocol/rpc/v1/knowledge.proto +77 -0
  53. package/services/knowledge/package-lock.json +333 -0
  54. package/services/knowledge/package.json +5 -1
  55. package/skills/agentsam-cloudflare-workers/SKILL.md +53 -0
  56. package/skills/agentsam-cloudflare-workers/references/cpu-profiling.md +16 -0
  57. package/skills/agentsam-cloudflare-workers/references/errors-and-observability.md +29 -0
  58. package/skills/agentsam-cloudflare-workers/references/wrangler-native-map.md +28 -0
  59. package/skills/catalog.json +18 -0
  60. package/src/agent/capability-adapter.js +25 -13
  61. package/src/agent/index.js +1 -0
  62. package/src/agent/responses-runner.js +353 -0
  63. package/src/capabilities/repository-snapshot.js +3 -3
  64. package/src/cli.js +118 -31
  65. package/src/cloudflare/cpu-profile.js +115 -0
  66. package/src/cloudflare/index.js +14 -0
  67. package/src/cloudflare/wrangler.js +132 -0
  68. package/src/commands/account-auth.js +47 -0
  69. package/src/commands/cloudflare.js +58 -0
  70. package/src/commands/connections.js +93 -0
  71. package/src/commands/context-economics.js +129 -0
  72. package/src/commands/context.js +1 -1
  73. package/src/commands/db.js +20 -3
  74. package/src/commands/deploy.js +39 -3
  75. package/src/commands/env.js +90 -0
  76. package/src/commands/eval.js +63 -0
  77. package/src/commands/interactive.js +2 -5
  78. package/src/commands/knowledge.js +12 -4
  79. package/src/commands/merkle-persist.js +30 -11
  80. package/src/commands/merkle.js +1 -1
  81. package/src/commands/models.js +149 -46
  82. package/src/commands/ollama.js +26 -0
  83. package/src/commands/preferences.js +130 -61
  84. package/src/commands/resume.js +67 -0
  85. package/src/commands/security.js +5 -3
  86. package/src/commands/shell.js +568 -119
  87. package/src/commands/tunnel.js +2 -2
  88. package/src/commands/whoami.js +86 -0
  89. package/src/context/budget.js +68 -6
  90. package/src/context/index.js +3 -1
  91. package/src/context/rehydrate.js +35 -0
  92. package/src/context/resolve.js +44 -12
  93. package/src/errors/contract.js +236 -0
  94. package/src/errors/diagnostic.js +160 -0
  95. package/src/errors/index.js +23 -0
  96. package/src/eval/context.js +191 -0
  97. package/src/eval/index.js +1 -0
  98. package/src/index.js +68 -2
  99. package/src/knowledge/service/auth.js +13 -0
  100. package/src/knowledge/service/grpc-client.js +115 -0
  101. package/src/knowledge/service/grpc-codec.js +237 -0
  102. package/src/knowledge/service/grpc-server.js +83 -0
  103. package/src/knowledge/service/job-engine.js +248 -0
  104. package/src/knowledge/service/server.js +87 -135
  105. package/src/knowledge/source.js +1 -1
  106. package/src/lib/account-session.js +98 -0
  107. package/src/lib/agent-instructions.js +73 -0
  108. package/src/lib/auth.js +4 -0
  109. package/src/lib/cli-preferences.js +55 -24
  110. package/src/lib/deploy/git-guard.js +69 -0
  111. package/src/lib/deploy/health.js +57 -0
  112. package/src/lib/deploy/local-studio.js +283 -0
  113. package/src/lib/deploy/secret-scan.js +65 -0
  114. package/src/lib/deploy-receipt/index.js +2 -2
  115. package/src/lib/detect-context.js +2 -2
  116. package/src/lib/execution-approvals.js +59 -0
  117. package/src/lib/knowledge-docker.js +6 -3
  118. package/src/lib/local-sessions.js +148 -0
  119. package/src/lib/local-status.js +1 -1
  120. package/src/lib/project-config.js +1 -1
  121. package/src/lib/provider-credentials.js +183 -0
  122. package/src/lib/scaffold/templates/worker-api/index.js +101 -20
  123. package/src/lib/scaffold/wizards/worker-api.js +27 -11
  124. package/src/lib/slash-commands.js +23 -16
  125. package/src/local/migrations.js +93 -0
  126. package/src/local/runtime-store.js +141 -0
  127. package/src/local/sqlite.js +2 -0
  128. package/src/local-pty/server.js +113 -51
  129. package/src/models/catalog.js +135 -0
  130. package/src/models/discovery.js +292 -0
  131. package/src/models/index.js +7 -0
  132. package/src/providers/anthropic-messages.js +192 -0
  133. package/src/providers/cloudflare-chat.js +183 -0
  134. package/src/providers/factory.js +69 -0
  135. package/src/providers/gemini-generate-content.js +208 -0
  136. package/src/providers/index.js +10 -0
  137. package/src/providers/ollama-chat.js +148 -0
  138. package/src/providers/openai-responses.js +426 -0
  139. package/src/repository/index.js +14 -2
  140. package/src/rpc/generated/common_grpc_pb.js +1 -0
  141. package/src/rpc/generated/common_pb.js +536 -0
  142. package/src/rpc/generated/errors_grpc_pb.js +1 -0
  143. package/src/rpc/generated/errors_pb.js +482 -0
  144. package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
  145. package/src/rpc/generated/knowledge_pb.js +2168 -0
  146. package/src/rpc/generated/package.json +3 -0
  147. package/src/security/process.js +35 -9
  148. package/src/security/trust-boundary.js +2 -2
  149. package/src/telemetry/contracts.js +203 -0
  150. package/src/telemetry/events.js +51 -0
  151. package/src/telemetry/index.js +8 -0
  152. package/src/tools/hydrate.js +35 -0
  153. package/src/tools/index.js +1 -0
  154. package/src/ui/boot.js +15 -17
  155. package/src/ui/cli/activity.js +76 -0
  156. package/src/ui/cli/compaction.js +15 -0
  157. package/src/ui/cli/footer.js +39 -0
  158. package/src/ui/cli/help.js +192 -0
  159. package/src/ui/cli/plan.js +20 -0
  160. package/src/ui/cli/runtime-events.js +110 -0
  161. package/src/ui/cli/waiting.js +16 -0
  162. package/src/ui/merkle/render.js +1 -1
  163. package/test/account-session.test.mjs +36 -0
  164. package/test/cli/preferences-runtime.test.mjs +11 -0
  165. package/test/cli/runtime-ui.test.mjs +74 -0
  166. package/test/cli-preferences.test.mjs +26 -5
  167. package/test/cloudflare-connector.test.mjs +96 -0
  168. package/test/cloudflare-runtime.test.mjs +75 -0
  169. package/test/context.test.mjs +61 -12
  170. package/test/deploy-health-scan.test.mjs +67 -0
  171. package/test/error-diagnostics.test.mjs +115 -0
  172. package/test/eval-context.test.mjs +37 -0
  173. package/test/execution-approvals.test.mjs +27 -0
  174. package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
  175. package/test/integration/cli-help.test.mjs +37 -0
  176. package/test/integration/knowledge-rpc.test.mjs +112 -0
  177. package/test/integration/merkle-cli.test.mjs +61 -0
  178. package/test/integration/merkle-persistence-identity.test.mjs +48 -0
  179. package/test/integration/provider-env-cli.test.mjs +49 -0
  180. package/test/integration/provider-factory.test.mjs +197 -0
  181. package/test/integration/repository-company-graph.test.mjs +90 -0
  182. package/test/integration/runtime-migrations.test.mjs +82 -0
  183. package/test/knowledge-service.test.mjs +5 -0
  184. package/test/knowledge.test.mjs +16 -0
  185. package/test/live/terminal-transport.live.test.mjs +24 -0
  186. package/test/local-sessions.test.mjs +48 -0
  187. package/test/local-studio-deploy.test.mjs +83 -0
  188. package/test/model-catalog.test.mjs +43 -0
  189. package/test/models.test.mjs +127 -16
  190. package/test/npm10-lock.test.mjs +29 -0
  191. package/test/ollama.test.mjs +21 -0
  192. package/test/openai-responses.test.mjs +95 -0
  193. package/test/portable-context.test.mjs +1 -1
  194. package/test/provider-credentials.test.mjs +96 -0
  195. package/test/rehydrate.test.mjs +25 -0
  196. package/test/release-hygiene.test.mjs +13 -5
  197. package/test/responses-runner.test.mjs +150 -0
  198. package/test/shell.test.mjs +92 -23
  199. package/test/smoke.mjs +4 -1
  200. package/test/telemetry.test.mjs +79 -0
  201. package/test/terminal/local-pty.mock.test.mjs +151 -0
  202. package/test/tools-search.test.mjs +14 -1
  203. package/test/whoami-resume.test.mjs +56 -0
  204. /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
  205. /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
  206. /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
  207. /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
  208. /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
  209. /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
  210. /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
  211. /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
@@ -1,168 +1,120 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { randomUUID, timingSafeEqual } from 'node:crypto';
5
- import { fork } from 'node:child_process';
6
- import { DatabaseSync } from 'node:sqlite';
7
4
  import { fileURLToPath } from 'node:url';
8
- import { fingerprint, validateConfig } from '../config.js';
9
-
10
- const workerPath = fileURLToPath(new URL('./job-worker.js', import.meta.url));
11
- const fail = (status, message) => Object.assign(new Error(message), { status });
12
- const decode = row => row && ({ id: row.id, status: row.status, attempts: row.attempts, created_at: row.created_at,
13
- updated_at: row.updated_at, result: row.result ? JSON.parse(row.result) : null, error: row.error });
14
-
15
- function normalizeRequest(body, repositories, allowEmbeddings) {
16
- if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(400, 'Expected a JSON object.');
17
- const keys = ['repository', 'operation', 'scope', 'include', 'exclude', 'embed', 'semantic', 'query', 'top_k', 'token_budget', 'generation_id', 'max_inputs', 'max_characters'];
18
- if (Object.keys(body).some(k => !keys.includes(k))) throw fail(400, 'Unknown job field.');
19
- const repo = Object.hasOwn(repositories, body.repository) && repositories[body.repository];
20
- if (!repo) throw fail(404, 'Repository is not registered.');
21
- if (!['index', 'plan', 'search'].includes(body.operation)) throw fail(400, 'operation must be index, plan, or search.');
22
- for (const key of ['embed', 'semantic']) if (body[key] !== undefined && typeof body[key] !== 'boolean') throw fail(400, `${key} must be a boolean.`);
23
- if (((body.embed && body.operation !== 'plan') || body.semantic) && !allowEmbeddings) throw fail(403, 'Embedding calls are disabled on this service.');
24
- if (body.exclude !== undefined && !Array.isArray(body.exclude)) throw fail(400, 'exclude must be an array.');
25
- if (body.operation === 'search' && (typeof body.query !== 'string' || !body.query.trim() || body.query.length > 8000)) throw fail(400, 'A query of 1..8000 characters is required.');
26
- const bounded = (key, fallback, min, max) => {
27
- const value = body[key] ?? fallback;
28
- if (!Number.isInteger(value) || value < min || value > max) throw fail(400, `${key} must be ${min}..${max}.`);
29
- return value;
30
- };
31
- const request = { ...body, embed: body.embed ?? false, semantic: body.semantic ?? false,
32
- max_inputs: bounded('max_inputs', 100, 0, 1000), max_characters: bounded('max_characters', 200000, 0, 2000000),
33
- top_k: bounded('top_k', 8, 1, 8), token_budget: bounded('token_budget', 6000, 256, 6000) };
34
- const config = structuredClone(repo.config);
35
- if (body.scope !== undefined) config.scope.name = body.scope;
36
- if (body.include !== undefined) config.scope.include = body.include;
37
- if (body.exclude !== undefined) config.scope.exclude = [...config.scope.exclude, ...body.exclude];
38
- let checked;
39
- try { checked = validateConfig(config); } catch (error) { throw fail(400, error.message); }
40
- // Request scopes can narrow a registered repository's scope, never widen it.
41
- if (checked.scope.include.some(p => !repo.config.scope.include.some(allowed => allowed === '.' || p === allowed || p.startsWith(allowed + '/')))) throw fail(403, 'Requested include is outside the registered scope.');
42
- return { request, config: checked, root: repo.root };
43
- }
5
+ import { createBearerTokenVerifier } from './auth.js';
6
+ import { createKnowledgeJobEngine, serviceError } from './job-engine.js';
44
7
 
45
8
  async function readBody(req) {
46
- const chunks = []; let bytes = 0;
9
+ const chunks = [];
10
+ let bytes = 0;
47
11
  for await (const chunk of req) {
48
12
  bytes += chunk.length;
49
- if (bytes > 16384) throw fail(413, 'Job body exceeds 16 KiB.');
13
+ if (bytes > 16384) throw serviceError(413, 'Job body exceeds 16 KiB.', 'RESOURCE_EXHAUSTED');
50
14
  chunks.push(chunk);
51
15
  }
52
- try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { throw fail(400, 'Invalid JSON.'); }
16
+ try {
17
+ return JSON.parse(Buffer.concat(chunks).toString());
18
+ } catch {
19
+ throw serviceError(400, 'Invalid JSON.', 'INVALID_ARGUMENT');
20
+ }
53
21
  }
54
22
 
55
- /** Trusted backend API. The calling host must authorize its user before submitting jobs. */
56
- export async function startKnowledgeService({ stateDir, repositories, token, port = 8792, host = '127.0.0.1',
57
- allowEmbeddings = false, maxQueued = 32, maxFiles = 2000, jobTimeoutMs = 600000 } = {}) {
58
- if (typeof token !== 'string' || token.length < 32 || token.length > 256) throw new Error('Service token must be 32..256 characters.');
59
- if (!repositories || !Object.keys(repositories).length) throw new Error('Register at least one repository.');
60
- repositories = Object.fromEntries(Object.entries(repositories).map(([name, repo]) => {
61
- if (!/^[a-z][a-z0-9_-]{0,47}$/.test(name)) throw new Error('Invalid repository alias.');
62
- const root = fs.realpathSync(repo.root);
63
- if (!fs.statSync(root).isDirectory()) throw new Error('Repository root must be a directory.');
64
- const config = validateConfig(repo.config);
65
- if (config.storage.driver !== 'sqlite') throw new Error('This service release uses its durable SQLite volume; Postgres is not configured by this preset.');
66
- return [name, { root, config }];
67
- }));
68
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
69
- const db = new DatabaseSync(path.join(stateDir, 'jobs.sqlite'));
70
- db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
71
- CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, idem TEXT UNIQUE, digest TEXT NOT NULL,
72
- payload TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
73
- created_at TEXT NOT NULL, updated_at TEXT NOT NULL, result TEXT, error TEXT);
74
- CREATE INDEX IF NOT EXISTS jobs_pending ON jobs(status, created_at);`);
75
- fs.chmodSync(path.join(stateDir, 'jobs.sqlite'), 0o600);
76
- // Interrupted work reuses SDK caches. Bound restarts so a poison job cannot loop forever.
77
- db.prepare("UPDATE jobs SET status=CASE WHEN attempts>=3 THEN 'failed' ELSE 'queued' END, error=CASE WHEN attempts>=3 THEN 'Interrupted three times; submit a new job after investigation.' ELSE NULL END WHERE status='running'").run();
78
- let child = null, closing = false, pumping = false;
79
- const get = id => decode(db.prepare('SELECT * FROM jobs WHERE id=?').get(id));
80
- const pump = () => {
81
- if (closing || pumping) return;
82
- const row = db.prepare("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at,rowid LIMIT 1").get();
83
- if (!row) return;
84
- pumping = true;
85
- db.prepare("UPDATE jobs SET status='running',attempts=attempts+1,updated_at=? WHERE id=?").run(new Date().toISOString(), row.id);
86
- let response = null, timedOut = false;
87
- child = fork(workerPath, [], { execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
88
- const timer = setTimeout(() => { timedOut = true; child?.kill('SIGKILL'); }, jobTimeoutMs);
89
- child.once('message', value => { response = value; });
90
- child.once('error', () => { response = { ok: false, error: 'Could not start indexing process.' }; });
91
- child.once('close', () => {
92
- clearTimeout(timer); child = null;
93
- if (!closing) {
94
- const ok = response?.ok && !timedOut;
95
- db.prepare('UPDATE jobs SET status=?,result=?,error=?,updated_at=? WHERE id=?').run(ok ? 'completed' : 'failed', ok ? JSON.stringify(response.result) : null,
96
- ok ? null : timedOut ? 'Job exceeded its time limit; narrow the scope.' : response?.error || 'Indexing process exited unexpectedly.', new Date().toISOString(), row.id);
97
- }
98
- pumping = false;
99
- if (!closing) setImmediate(pump);
100
- });
101
- // Re-resolve registry on replay. Removed repositories do not get resumed.
102
- const payload = JSON.parse(row.payload), registered = repositories[payload.request.repository];
103
- if (!registered || registered.config.repository_id !== payload.config.repository_id || fingerprint(registered.config.scope) !== payload.registered_scope || (!allowEmbeddings && ((payload.request.embed && payload.request.operation !== 'plan') || payload.request.semantic))) {
104
- response = { ok: false, error: 'Repository registration changed; resubmit this job.' }; child.kill(); return;
105
- }
106
- child.send({ ...payload, root: registered.root, filename: path.join(stateDir, 'knowledge.sqlite'), maxFiles });
107
- };
108
- const secret = Buffer.from(token);
23
+ export async function startKnowledgeHttpServer({ engine, token, verifyToken, port = 8792, host = '127.0.0.1' } = {}) {
24
+ if (!engine) throw new Error('Knowledge job engine is required.');
25
+ const verify = verifyToken || createBearerTokenVerifier(token);
109
26
  const server = http.createServer(async (req, res) => {
110
- const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
27
+ const send = (status, value) => {
28
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
29
+ res.end(JSON.stringify(value));
30
+ };
111
31
  try {
112
32
  const route = new URL(req.url, 'http://local').pathname;
113
33
  if (req.method === 'GET' && route === '/healthz') return send(200, { ok: true, service: 'agentsam-knowledge', version: 1 });
114
- const provided = Buffer.from((req.headers.authorization || '').replace(/^Bearer /, ''));
115
- if (provided.length !== secret.length || !timingSafeEqual(provided, secret)) throw fail(401, 'Unauthorized.');
116
- if (req.method === 'GET' && route === '/v1/repositories') return send(200, { repositories: Object.keys(repositories), embeddings_enabled: allowEmbeddings });
117
- if (req.method === 'GET' && /^\/v1\/jobs\/[a-f0-9-]{36}$/.test(route)) {
118
- const job = get(route.split('/').pop()); if (!job) throw fail(404, 'Job not found.'); return send(200, job);
34
+ if (!verify(req.headers.authorization)) throw serviceError(401, 'Unauthorized.', 'UNAUTHENTICATED');
35
+ if (req.method === 'GET' && route === '/v1/repositories') {
36
+ const value = engine.listRepositories();
37
+ return send(200, { repositories: value.repositories.map(repo => repo.alias), embeddings_enabled: value.embeddings_enabled });
119
38
  }
120
- if (req.method !== 'POST' || route !== '/v1/jobs') throw fail(404, 'Route not found.');
121
- if (closing) throw fail(503, 'Service is stopping.');
122
- if (!(req.headers['content-type'] || '').startsWith('application/json')) throw fail(415, 'Use application/json.');
123
- const payload = normalizeRequest(await readBody(req), repositories, allowEmbeddings);
124
- payload.registered_scope = fingerprint(repositories[payload.request.repository].config.scope);
125
- const key = req.headers['idempotency-key'];
126
- if (key !== undefined && (typeof key !== 'string' || !/^[\w:.-]{1,128}$/.test(key))) throw fail(400, 'Invalid Idempotency-Key.');
127
- const digest = fingerprint(payload), idem = key ? fingerprint([payload.config.repository_id, payload.registered_scope, key]) : null;
128
- const previous = idem && db.prepare('SELECT * FROM jobs WHERE idem=?').get(idem);
129
- if (previous) {
130
- if (previous.digest !== digest) throw fail(409, 'Idempotency-Key already used for a different job.');
131
- return send(200, decode(previous));
39
+ if (req.method === 'GET' && /^\/v1\/jobs\/[a-f0-9-]{36}$/.test(route)) {
40
+ return send(200, engine.getJob(route.split('/').pop()));
132
41
  }
133
- if (db.prepare("SELECT COUNT(*) AS n FROM jobs WHERE status IN ('queued','running')").get().n >= maxQueued) throw fail(429, 'Job queue is full; retry later with the same Idempotency-Key.');
134
- const id = randomUUID(), now = new Date().toISOString();
135
- db.prepare("INSERT INTO jobs(id,idem,digest,payload,status,created_at,updated_at) VALUES(?,?,?,?,'queued',?,?)").run(id, idem, digest, JSON.stringify(payload), now, now);
136
- send(202, get(id)); setImmediate(pump);
137
- } catch (error) { if (!res.writableEnded) send(error.status || 500, { error: error.status ? error.message : 'Service request failed.' }); }
42
+ if (req.method !== 'POST' || route !== '/v1/jobs') throw serviceError(404, 'Route not found.', 'NOT_FOUND');
43
+ if (!(req.headers['content-type'] || '').startsWith('application/json')) throw serviceError(415, 'Use application/json.', 'INVALID_ARGUMENT');
44
+ const { job, created } = engine.submitJob(await readBody(req), { idempotencyKey: req.headers['idempotency-key'] });
45
+ return send(created ? 202 : 200, job);
46
+ } catch (error) {
47
+ if (!res.writableEnded) send(error.status || 500, { error: error.status ? error.message : 'Service request failed.' });
48
+ }
138
49
  });
139
- server.requestTimeout = 15000; server.headersTimeout = 10000;
50
+ server.requestTimeout = 15000;
51
+ server.headersTimeout = 10000;
140
52
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
141
- setImmediate(pump);
142
- return { server, address: server.address(), async close() {
143
- closing = true;
144
- if (child) { const active = child; await new Promise(resolve => { active.once('close', resolve); active.kill('SIGKILL'); }); }
145
- await new Promise(resolve => server.close(resolve)); db.close();
146
- } };
53
+ return {
54
+ server,
55
+ address: server.address(),
56
+ async close() {
57
+ await new Promise(resolve => server.close(resolve));
58
+ },
59
+ };
60
+ }
61
+
62
+ /** Trusted backend API. The calling host must authorize its user before submitting jobs. */
63
+ export async function startKnowledgeService({ token, port = 8792, host = '127.0.0.1', ...engineOptions } = {}) {
64
+ const verifyToken = createBearerTokenVerifier(token);
65
+ const engine = createKnowledgeJobEngine(engineOptions);
66
+ try {
67
+ const httpService = await startKnowledgeHttpServer({ engine, verifyToken, port, host });
68
+ return {
69
+ ...httpService,
70
+ engine,
71
+ async close() {
72
+ await httpService.close();
73
+ await engine.close();
74
+ },
75
+ };
76
+ } catch (error) {
77
+ await engine.close();
78
+ throw error;
79
+ }
147
80
  }
148
81
 
149
82
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
150
83
  try {
151
- // Initialize the named volume, then match the host file owner before reading mounts.
152
- // The long-running server and all indexing children run without root privileges.
153
84
  if (process.getuid?.() === 0 && process.env.AGENTSAM_RUNTIME_UID) {
154
- const uid = Number(process.env.AGENTSAM_RUNTIME_UID), gid = Number(process.env.AGENTSAM_RUNTIME_GID);
85
+ const uid = Number(process.env.AGENTSAM_RUNTIME_UID);
86
+ const gid = Number(process.env.AGENTSAM_RUNTIME_GID);
155
87
  if (!Number.isInteger(uid) || uid < 0 || !Number.isInteger(gid) || gid < 0) throw new Error('Invalid runtime uid/gid.');
156
88
  const state = process.env.AGENTSAM_STATE_DIR || '/data';
157
- fs.mkdirSync(state, { recursive: true, mode: 0o700 }); fs.chownSync(state, uid, gid);
158
- process.setgroups([]); process.setgid(gid); process.setuid(uid);
89
+ fs.mkdirSync(state, { recursive: true, mode: 0o700 });
90
+ fs.chownSync(state, uid, gid);
91
+ process.setgroups([]);
92
+ process.setgid(gid);
93
+ process.setuid(uid);
159
94
  }
160
95
  const registry = JSON.parse(fs.readFileSync(process.env.AGENTSAM_REPOSITORIES_FILE || '/config/repositories.json', 'utf8'));
161
96
  const token = fs.readFileSync(process.env.AGENTSAM_SERVICE_TOKEN_FILE || '/config/service.token', 'utf8').trim();
162
- const service = await startKnowledgeService({ stateDir: process.env.AGENTSAM_STATE_DIR || '/data', repositories: registry,
163
- token, host: process.env.HOST || '0.0.0.0', port: Number(process.env.PORT || 8792), allowEmbeddings: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true' });
97
+ const service = await startKnowledgeService({
98
+ stateDir: process.env.AGENTSAM_STATE_DIR || '/data',
99
+ repositories: registry,
100
+ token,
101
+ host: process.env.HOST || '0.0.0.0',
102
+ port: Number(process.env.PORT || 8792),
103
+ allowEmbeddings: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true',
104
+ });
164
105
  console.log(JSON.stringify({ event: 'listening', port: service.address.port, repositories: Object.keys(registry), embeddings_enabled: process.env.AGENTSAM_ALLOW_EMBEDDINGS === 'true' }));
165
106
  let stopped = false;
166
- for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, async () => { if (!stopped) { stopped = true; await service.close(); process.exit(0); } });
167
- } catch { console.error('Knowledge service could not start; check repository registrations, token file, and state permissions.'); process.exit(1); }
107
+ for (const signal of ['SIGTERM', 'SIGINT']) {
108
+ process.on(signal, async () => {
109
+ if (!stopped) {
110
+ stopped = true;
111
+ await service.close();
112
+ process.exit(0);
113
+ }
114
+ });
115
+ }
116
+ } catch {
117
+ console.error('Knowledge service could not start; check repository registrations, token file, and state permissions.');
118
+ process.exit(1);
119
+ }
168
120
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import ts from 'typescript';
5
- import { fileHasher } from '../lib/merkle/hash.js';
5
+ import { fileHasher } from '../../packages/agentsam-repository/src/merkle/hash.js';
6
6
  import { fingerprint } from './config.js';
7
7
 
8
8
  export const PARSER = `typescript:${ts.version}:agentsam-1`;
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { resolveSdkKey } from '../../packages/identity/src/contracts/auth-config.js';
5
+
6
+ export const ACCOUNT_SESSION_SCHEMA = 'agentsam-account-session-v1';
7
+
8
+ function clean(value) { return value == null ? '' : String(value).trim(); }
9
+ function homeDirectory(options = {}) {
10
+ return path.resolve(clean(options.home) || clean(options.env?.HOME) || clean(options.env?.USERPROFILE) || os.homedir());
11
+ }
12
+
13
+ export function accountSessionPath(options = {}) {
14
+ return path.join(homeDirectory(options), '.agentsam', 'auth', 'session.json');
15
+ }
16
+
17
+ export function readAccountSession(options = {}) {
18
+ const filename = accountSessionPath(options);
19
+ if (!fs.existsSync(filename)) return null;
20
+ try {
21
+ const stat = fs.statSync(filename);
22
+ if (!stat.isFile()) return null;
23
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) return null;
24
+ const parsed = JSON.parse(fs.readFileSync(filename, 'utf8'));
25
+ if (parsed?.schema_version !== ACCOUNT_SESSION_SCHEMA) return null;
26
+ const token = clean(parsed.sdk_key);
27
+ if (!token.startsWith('sdk_')) return null;
28
+ return {
29
+ schema_version: ACCOUNT_SESSION_SCHEMA,
30
+ sdk_key: token,
31
+ user_id: clean(parsed.user_id) || null,
32
+ account_id: clean(parsed.account_id) || null,
33
+ email: clean(parsed.email) || null,
34
+ created_at: clean(parsed.created_at) || null,
35
+ updated_at: clean(parsed.updated_at) || null,
36
+ };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function saveAccountSession(session = {}, options = {}) {
43
+ const token = clean(session.sdk_key || session.access_token);
44
+ if (!token.startsWith('sdk_')) throw new Error('account_session_sdk_key_required');
45
+ const filename = accountSessionPath(options);
46
+ const dir = path.dirname(filename);
47
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ if (process.platform !== 'win32') {
49
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
50
+ }
51
+ const previous = readAccountSession(options);
52
+ const now = new Date().toISOString();
53
+ const value = {
54
+ schema_version: ACCOUNT_SESSION_SCHEMA,
55
+ sdk_key: token,
56
+ user_id: clean(session.user_id) || previous?.user_id || null,
57
+ account_id: clean(session.account_id) || previous?.account_id || null,
58
+ email: clean(session.email) || previous?.email || null,
59
+ created_at: previous?.created_at || now,
60
+ updated_at: now,
61
+ };
62
+ const temp = `${filename}.${process.pid}.tmp`;
63
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
64
+ if (process.platform !== 'win32') {
65
+ try { fs.chmodSync(temp, 0o600); } catch { /* best effort */ }
66
+ }
67
+ fs.renameSync(temp, filename);
68
+ return { ...value };
69
+ }
70
+
71
+ export function clearAccountSession(options = {}) {
72
+ const filename = accountSessionPath(options);
73
+ if (!fs.existsSync(filename)) return false;
74
+ fs.rmSync(filename, { force: true });
75
+ return true;
76
+ }
77
+
78
+ export function resolveAccountSdkKey(options = {}) {
79
+ const env = options.env || process.env;
80
+ const explicit = clean(options.explicit);
81
+ const fromEnv = resolveSdkKey(env, explicit);
82
+ if (fromEnv) return { value: fromEnv, source: explicit ? 'explicit' : 'environment' };
83
+ const session = readAccountSession({ ...options, env });
84
+ return session?.sdk_key
85
+ ? { value: session.sdk_key, source: 'agentsam_account_session', session }
86
+ : { value: '', source: null, session: null };
87
+ }
88
+
89
+ export function describeAccountSession(options = {}) {
90
+ const resolved = resolveAccountSdkKey(options);
91
+ return {
92
+ configured: Boolean(resolved.value),
93
+ source: resolved.source,
94
+ user_id: resolved.session?.user_id || null,
95
+ account_id: resolved.session?.account_id || null,
96
+ email: resolved.session?.email || null,
97
+ };
98
+ }
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { findProjectRules } from './project-rules.js';
5
+
6
+ export const AGENT_RUNTIME_FILENAME = 'AGENTSAM.md';
7
+ export const AGENT_INSTRUCTION_PRECEDENCE = Object.freeze(['AGENTSAM.md', '.agentsamrules']);
8
+
9
+ function sha256(value) {
10
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
11
+ }
12
+
13
+ function findUp(startDir, filename) {
14
+ let dir = path.resolve(startDir);
15
+ for (let i = 0; i < 16; i += 1) {
16
+ const candidate = path.join(dir, filename);
17
+ if (fs.existsSync(candidate)) return candidate;
18
+ const boundary = fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.agentsam', 'config.json'));
19
+ if (boundary) break;
20
+ const parent = path.dirname(dir);
21
+ if (parent === dir) break;
22
+ dir = parent;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function findAgentRuntimeContract(startDir = process.cwd()) {
28
+ return findUp(startDir, AGENT_RUNTIME_FILENAME);
29
+ }
30
+
31
+ export function compileAgentInstructions(startDir = process.cwd(), options = {}) {
32
+ const maxChars = Number(options.maxChars ?? 24_000);
33
+ if (!Number.isInteger(maxChars) || maxChars < 1) throw new RangeError('agent instructions maxChars must be a positive integer');
34
+
35
+ const runtimePath = options.runtimeFilename ? path.resolve(options.runtimeFilename) : findAgentRuntimeContract(startDir);
36
+ const projectPath = options.projectFilename ? path.resolve(options.projectFilename) : findProjectRules(startDir);
37
+ const descriptors = [
38
+ { id: 'runtime', filename: AGENT_RUNTIME_FILENAME, path: runtimePath },
39
+ { id: 'project', filename: '.agentsamrules', path: projectPath },
40
+ ];
41
+ const sections = [];
42
+ const sources = [];
43
+ let sourceChars = 0;
44
+
45
+ for (const descriptor of descriptors) {
46
+ if (!descriptor.path || !fs.existsSync(descriptor.path)) continue;
47
+ const source = fs.readFileSync(descriptor.path, 'utf8');
48
+ sourceChars += source.length;
49
+ sources.push(Object.freeze({
50
+ id: descriptor.id,
51
+ filename: descriptor.filename,
52
+ path: descriptor.path,
53
+ chars: source.length,
54
+ hash: sha256(source),
55
+ }));
56
+ sections.push(`<!-- agentsam:${descriptor.id}:${descriptor.filename} -->\n${source.trim()}\n`);
57
+ }
58
+
59
+ const combined = sections.join('\n');
60
+ const truncated = combined.length > maxChars;
61
+ const content = truncated ? `${combined.slice(0, Math.max(0, maxChars - 1))}…` : combined;
62
+ return Object.freeze({
63
+ found: sources.length > 0,
64
+ path: projectPath || runtimePath || null,
65
+ content,
66
+ chars: content.length,
67
+ source_chars: sourceChars,
68
+ truncated,
69
+ hash: combined ? sha256(combined) : null,
70
+ precedence: AGENT_INSTRUCTION_PRECEDENCE,
71
+ sources: Object.freeze(sources),
72
+ });
73
+ }
package/src/lib/auth.js CHANGED
@@ -5,6 +5,7 @@ import http from 'node:http';
5
5
  import { randomBytes } from 'node:crypto';
6
6
  import { postJson } from './core-client.js';
7
7
  import { promptToOpenUrl } from './open-url.js';
8
+ import { saveAccountSession } from './account-session.js';
8
9
 
9
10
  function randomState() {
10
11
  return randomBytes(16).toString('hex');
@@ -63,5 +64,8 @@ export async function authenticateViaBrowser() {
63
64
 
64
65
  const code = await codePromise;
65
66
  const session = await postJson('/api/sdk/auth/exchange', { code, state });
67
+ if (String(session?.access_token || '').trim().startsWith('sdk_')) {
68
+ saveAccountSession(session);
69
+ }
66
70
  return session;
67
71
  }
@@ -3,14 +3,12 @@ import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { getProjectName, tryReadProjectConfig } from './project-config.js';
5
5
 
6
- export const CLI_PREFERENCES_SCHEMA = 'agentsam-cli-preferences-v1';
6
+ export const CLI_PREFERENCES_SCHEMA = 'agentsam-cli-preferences-v3';
7
+ export const LEGACY_CLI_PREFERENCES_SCHEMAS = new Set(['agentsam-cli-preferences-v1', 'agentsam-cli-preferences-v2']);
7
8
 
8
9
  function readJson(filename) {
9
- try {
10
- return JSON.parse(fs.readFileSync(filename, 'utf8'));
11
- } catch {
12
- return null;
13
- }
10
+ try { return JSON.parse(fs.readFileSync(filename, 'utf8')); }
11
+ catch { return null; }
14
12
  }
15
13
 
16
14
  function gitValue(cwd, args) {
@@ -22,14 +20,9 @@ export function findCliProjectRoot(startDir = process.cwd()) {
22
20
  const cwd = path.resolve(startDir);
23
21
  const gitRoot = gitValue(cwd, ['rev-parse', '--show-toplevel']);
24
22
  if (gitRoot) return path.resolve(gitRoot);
25
-
26
23
  let dir = cwd;
27
24
  for (let i = 0; i < 16; i += 1) {
28
- if (
29
- fs.existsSync(path.join(dir, '.agentsam', 'cli.json')) ||
30
- fs.existsSync(path.join(dir, '.agentsam', 'config.json')) ||
31
- fs.existsSync(path.join(dir, 'package.json'))
32
- ) return dir;
25
+ if (fs.existsSync(path.join(dir, '.agentsam', 'cli.json')) || fs.existsSync(path.join(dir, '.agentsam', 'config.json')) || fs.existsSync(path.join(dir, 'package.json'))) return dir;
33
26
  const parent = path.dirname(dir);
34
27
  if (parent === dir) break;
35
28
  dir = parent;
@@ -48,27 +41,65 @@ export function detectCliProject(startDir = process.cwd()) {
48
41
  return { root, project, branch, remote, website, configured: Boolean(getProjectName(config)) };
49
42
  }
50
43
 
51
- export function cliPreferencesPath(root) {
52
- return path.join(path.resolve(root), '.agentsam', 'cli.json');
44
+ export function cliPreferencesPath(root) { return path.join(path.resolve(root), '.agentsam', 'cli.json'); }
45
+
46
+ function safeModelSnapshot(value) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
48
+ const provider = String(value.provider || '').trim();
49
+ const providerModelId = String(value.provider_model_id || '').trim();
50
+ if (!provider || !providerModelId) return null;
51
+ return {
52
+ model_key: String(value.model_key || `${provider}:${providerModelId}`),
53
+ provider,
54
+ provider_model_id: providerModelId,
55
+ label: String(value.label || providerModelId),
56
+ availability: value.availability === 'available' ? 'available' : 'unverified',
57
+ availability_source: String(value.availability_source || ''),
58
+ context_window: Number.isFinite(Number(value.context_window)) && Number(value.context_window) > 0 ? Number(value.context_window) : null,
59
+ context_window_source: String(value.context_window_source || 'unknown'),
60
+ max_output_tokens: Number.isFinite(Number(value.max_output_tokens)) && Number(value.max_output_tokens) > 0 ? Number(value.max_output_tokens) : null,
61
+ max_output_tokens_source: String(value.max_output_tokens_source || 'unknown'),
62
+ reasoning_efforts: Array.isArray(value.reasoning_efforts) && value.reasoning_efforts.length ? value.reasoning_efforts.map(String) : ['auto'],
63
+ service_tiers: Array.isArray(value.service_tiers) && value.service_tiers.length ? value.service_tiers.map(String) : ['default'],
64
+ capabilities: value.capabilities && typeof value.capabilities === 'object' ? { ...value.capabilities } : {},
65
+ pricing: value.pricing && typeof value.pricing === 'object' ? { ...value.pricing } : null,
66
+ context_policy: value.context_policy && typeof value.context_policy === 'object' ? { ...value.context_policy } : null,
67
+ source: value.source && typeof value.source === 'object' ? { ...value.source } : null,
68
+ };
69
+ }
70
+
71
+ function normalizePreferences(value = {}) {
72
+ const modelSnapshot = safeModelSnapshot(value.modelSnapshot);
73
+ return {
74
+ schemaVersion: CLI_PREFERENCES_SCHEMA,
75
+ trustedDirectory: value.trustedDirectory === true,
76
+ runtime: value.runtime || 'local',
77
+ terminal: value.terminal || '',
78
+ modelPreference: value.modelPreference || 'auto',
79
+ modelSnapshot,
80
+ reasoningEffort: value.reasoningEffort || 'auto',
81
+ serviceTier: value.serviceTier || 'default',
82
+ modelAuthority: modelSnapshot?.availability === 'available' ? 'provider-verified' : 'preference-only',
83
+ updatedAt: value.updatedAt || null,
84
+ };
53
85
  }
54
86
 
55
87
  export function readCliPreferences(root) {
56
88
  const value = readJson(cliPreferencesPath(root));
57
- if (!value || value.schemaVersion !== CLI_PREFERENCES_SCHEMA) return null;
58
- return value;
89
+ if (!value) return null;
90
+ if (value.schemaVersion !== CLI_PREFERENCES_SCHEMA && !LEGACY_CLI_PREFERENCES_SCHEMAS.has(value.schemaVersion)) return null;
91
+ return normalizePreferences(value);
59
92
  }
60
93
 
61
94
  export function writeCliPreferences(root, value = {}) {
62
95
  const filename = cliPreferencesPath(root);
63
96
  fs.mkdirSync(path.dirname(filename), { recursive: true });
64
- const next = {
65
- schemaVersion: CLI_PREFERENCES_SCHEMA,
66
- runtime: value.runtime || 'local',
67
- terminal: value.terminal || '',
68
- modelPreference: value.modelPreference || 'auto',
69
- modelAuthority: 'preference-only',
70
- updatedAt: new Date().toISOString(),
71
- };
97
+ const previous = readCliPreferences(root) || {};
98
+ const next = normalizePreferences({ ...previous, ...value, updatedAt: new Date().toISOString() });
72
99
  fs.writeFileSync(filename, `${JSON.stringify(next, null, 2)}\n`);
73
100
  return next;
74
101
  }
102
+
103
+ export function updateCliPreferences(root, patch = {}) {
104
+ return writeCliPreferences(root, { ...(readCliPreferences(root) || {}), ...patch });
105
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Production Wrangler deploys are allowed only from a clean main that matches origin/main.
3
+ * Dry-run / plan may run from a feature branch. Never from /private/tmp.
4
+ */
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const BLOCKED_BRANCH_PREFIXES = ['feat/', 'fix/', 'chore/', 'release/'];
9
+
10
+ function git(cwd, args) {
11
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
12
+ return {
13
+ status: r.status,
14
+ stdout: String(r.stdout || '').trim(),
15
+ stderr: String(r.stderr || '').trim(),
16
+ };
17
+ }
18
+
19
+ export function inspectDeployGit(cwd = process.cwd()) {
20
+ const root = git(cwd, ['rev-parse', '--show-toplevel']);
21
+ const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
22
+ const head = git(cwd, ['rev-parse', 'HEAD']);
23
+ const origin = git(cwd, ['rev-parse', 'origin/main']);
24
+ const porcelain = git(cwd, ['status', '--porcelain']);
25
+ const detached = branch.stdout === 'HEAD';
26
+ const repoRoot = root.stdout || path.resolve(cwd);
27
+ return {
28
+ ok: root.status === 0,
29
+ repoRoot,
30
+ branch: branch.stdout,
31
+ detached,
32
+ head: head.stdout,
33
+ originMain: origin.stdout,
34
+ dirty: Boolean(porcelain.stdout),
35
+ tmpCheckout: repoRoot.includes('/private/tmp/') || repoRoot.includes('/tmp/'),
36
+ };
37
+ }
38
+
39
+ export function productionDeployBlockedReason(info) {
40
+ if (!info?.ok) return 'not a git checkout';
41
+ if (info.tmpCheckout) return 'refusing production deploy from a temporary checkout';
42
+ if (info.detached) return 'refusing production deploy from detached HEAD';
43
+ if (info.branch !== 'main') {
44
+ return `refusing production deploy from ${info.branch}; origin/main only`;
45
+ }
46
+ for (const prefix of BLOCKED_BRANCH_PREFIXES) {
47
+ if (info.branch.startsWith(prefix)) {
48
+ return `refusing production deploy from ${info.branch}`;
49
+ }
50
+ }
51
+ if (!info.originMain) return 'origin/main is missing; fetch before deploying';
52
+ if (info.head !== info.originMain) {
53
+ return `HEAD (${info.head.slice(0, 12)}) != origin/main (${info.originMain.slice(0, 12)})`;
54
+ }
55
+ if (info.dirty) return 'working tree is not clean';
56
+ return null;
57
+ }
58
+
59
+ export function assertProductionDeployAllowed(cwd = process.cwd()) {
60
+ const info = inspectDeployGit(cwd);
61
+ const reason = productionDeployBlockedReason(info);
62
+ if (reason) {
63
+ const err = new Error(reason);
64
+ err.code = 'production_deploy_refused';
65
+ err.git = info;
66
+ throw err;
67
+ }
68
+ return info;
69
+ }