flint-agent 1.14.0

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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,154 @@
1
+ // Fetch models from provider API — unified format
2
+
3
+ import { getProvider } from "./registry.js";
4
+ import { getKey } from "./keys.js";
5
+ import { createLogger } from "../logging/logger.js";
6
+
7
+ const log = createLogger("models");
8
+
9
+ export async function fetchModels(providerId) {
10
+ const provider = getProvider(providerId);
11
+ if (!provider) {
12
+ log.warn("fetchModels: unknown provider", { providerId });
13
+ return [];
14
+ }
15
+
16
+ if (!provider.modelsEndpoint) {
17
+ // Static models fallback
18
+ if (provider.staticModels) {
19
+ return provider.staticModels.map((m) => ({
20
+ id: m.id,
21
+ name: m.name || m.id,
22
+ context_length: m.context_length || null,
23
+ pricing: null,
24
+ }));
25
+ }
26
+ return [];
27
+ }
28
+
29
+ const apiKey = await getKey(providerId);
30
+
31
+ try {
32
+ const headers = { ...provider.headers };
33
+ if (provider.authType === "bearer" && apiKey) {
34
+ headers["Authorization"] = `Bearer ${apiKey}`;
35
+ } else if (provider.authType === "x-api-key" && apiKey) {
36
+ headers["x-api-key"] = apiKey;
37
+ }
38
+
39
+ const url = provider.ollamaCompat
40
+ ? `${provider.baseUrl}${provider.modelsEndpoint}`
41
+ : `${provider.baseUrl}${provider.modelsEndpoint}`;
42
+
43
+ const res = await fetch(url, {
44
+ headers,
45
+ signal: AbortSignal.timeout(15000),
46
+ });
47
+ if (!res.ok) {
48
+ const body = await res.text().catch(() => "");
49
+ log.warn("fetchModels: http error", { providerId, status: res.status, body: body.slice(0, 200) });
50
+ return [];
51
+ }
52
+ const data = await res.json();
53
+
54
+ // Anthropic format: { data: [{ id, display_name, type: "model", created_at }] }
55
+ if (provider.format === "anthropic") {
56
+ const models = data.data || [];
57
+ return models.map((m) => ({
58
+ id: m.id,
59
+ name: m.display_name || m.id,
60
+ context_length: null,
61
+ pricing: null,
62
+ }));
63
+ }
64
+
65
+ // Ollama format
66
+ if (provider.ollamaCompat) {
67
+ const models = data.models || [];
68
+ return models.map((m) => ({
69
+ id: m.name || m.model,
70
+ name: m.name || m.model,
71
+ context_length: null,
72
+ pricing: null,
73
+ }));
74
+ }
75
+
76
+ // OpenAI-compatible format (OpenRouter, OpenAI, Groq, Together)
77
+ const rawModels = data.data || [];
78
+
79
+ // Filter out non-chat models (audio, image, embedding, moderation, tts, realtime, sora, etc.)
80
+ const NON_CHAT = /^(tts|whisper|dall-e|text-embedding|text-moderation|davinci|babbage|canary|omni-moderation|sora|gpt-image|gpt-audio|gpt-realtime|chatgpt-image)/;
81
+ const NON_CHAT_SUFFIX = /(-(audio|realtime|transcribe|tts|diarize)(-|$))/;
82
+
83
+ const filtered = rawModels
84
+ .filter((m) => {
85
+ if (NON_CHAT.test(m.id)) return false;
86
+ if (NON_CHAT_SUFFIX.test(m.id)) return false;
87
+ // OpenRouter marks modality — keep only models that output text.
88
+ // New OpenRouter schema uses architecture.output_modalities: ["text", ...]
89
+ // Legacy schema uses architecture.modality: "text->text" or "text+image->text"
90
+ const arch = m.architecture;
91
+ if (arch) {
92
+ if (Array.isArray(arch.output_modalities) && arch.output_modalities.length > 0) {
93
+ if (!arch.output_modalities.includes("text")) return false;
94
+ } else if (typeof arch.modality === "string" && arch.modality) {
95
+ // Check the output side (after "->") — if absent, use whole string
96
+ const outputSide = arch.modality.includes("->")
97
+ ? arch.modality.split("->")[1]
98
+ : arch.modality;
99
+ if (!outputSide.includes("text")) return false;
100
+ }
101
+ }
102
+ return true;
103
+ })
104
+ .map((m) => ({
105
+ id: m.id,
106
+ name: m.name || m.id,
107
+ context_length: m.context_length || null,
108
+ // What the model accepts, when the provider publishes it: true/false
109
+ // for images, null when it does not say.
110
+ sees_images: seesImagesFrom(m.architecture),
111
+ pricing: m.pricing ? {
112
+ prompt: parseFloat(m.pricing.prompt || 0),
113
+ completion: parseFloat(m.pricing.completion || 0),
114
+ // Third price line, two orders of magnitude below `prompt`. Without
115
+ // it any local estimate of a cached call is off by ~100x.
116
+ cacheRead: m.pricing.input_cache_read != null
117
+ ? parseFloat(m.pricing.input_cache_read)
118
+ : null,
119
+ } : null,
120
+ }));
121
+
122
+ log.info("fetchModels", { providerId, raw: rawModels.length, filtered: filtered.length });
123
+ return filtered;
124
+ } catch (err) {
125
+ log.warn("fetchModels: exception", { providerId, error: err.message });
126
+ return [];
127
+ }
128
+ }
129
+
130
+ // OpenRouter: architecture.input_modalities ["text", "image", ...], or the
131
+ // legacy architecture.modality "text+image->text". Absent means unknown, not no.
132
+ export function seesImagesFrom(arch) {
133
+ if (!arch) return null;
134
+ if (Array.isArray(arch.input_modalities) && arch.input_modalities.length > 0) {
135
+ return arch.input_modalities.includes("image");
136
+ }
137
+ if (typeof arch.modality === "string" && arch.modality.includes("->")) {
138
+ return arch.modality.split("->")[0].includes("image");
139
+ }
140
+ return null;
141
+ }
142
+
143
+ export async function fetchModelInfo(providerId, modelId) {
144
+ const models = await fetchModels(providerId);
145
+ const model = models.find((m) => m.id === modelId);
146
+ if (!model) return null;
147
+ return {
148
+ prompt: model.pricing?.prompt || 0,
149
+ completion: model.pricing?.completion || 0,
150
+ cacheRead: model.pricing?.cacheRead ?? null,
151
+ contextLength: model.context_length || null,
152
+ seesImages: model.sees_images ?? null,
153
+ };
154
+ }
@@ -0,0 +1,56 @@
1
+ // Provider registry — loads from config/providers.json, falls back to bundled defaults
2
+
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const CONFIG_PATH = join(__dirname, "../../config/providers.json");
10
+ // homedir(), not process.env.HOME: HOME is unset on Windows.
11
+ const USER_CONFIG_PATH = join(homedir(), ".flint", "providers.json");
12
+
13
+ function loadProviders() {
14
+ // User config (~/.flint/providers.json) takes priority, then project config
15
+ for (const path of [USER_CONFIG_PATH, CONFIG_PATH]) {
16
+ try {
17
+ if (existsSync(path)) {
18
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
19
+ // Add id field from key if missing
20
+ const providers = {};
21
+ for (const [id, def] of Object.entries(raw)) {
22
+ if (id.startsWith("_")) continue; // skip comments
23
+ providers[id] = { id, headers: {}, ...def };
24
+ }
25
+ return providers;
26
+ }
27
+ } catch {}
28
+ }
29
+ // Should not happen — config/providers.json is bundled
30
+ return {};
31
+ }
32
+
33
+ export const PROVIDERS = loadProviders();
34
+
35
+ export function getProvider(id) {
36
+ return PROVIDERS[id] || null;
37
+ }
38
+
39
+ export function listProviders() {
40
+ return Object.values(PROVIDERS);
41
+ }
42
+
43
+ export function getProviderIds() {
44
+ return Object.keys(PROVIDERS);
45
+ }
46
+
47
+ /**
48
+ * Reload providers from config (e.g. after user edits ~/.flint/providers.json).
49
+ */
50
+ export function reloadProviders() {
51
+ const fresh = loadProviders();
52
+ // Clear and repopulate
53
+ for (const key of Object.keys(PROVIDERS)) delete PROVIDERS[key];
54
+ Object.assign(PROVIDERS, fresh);
55
+ return PROVIDERS;
56
+ }
@@ -0,0 +1,56 @@
1
+ // Provider state persistence — active provider + last model per provider
2
+ // File: ~/.flint/provider.json
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ const FLINT_DIR = path.join(homedir(), ".flint");
9
+ const STATE_FILE = path.join(FLINT_DIR, "provider.json");
10
+
11
+ const DEFAULT_STATE = {
12
+ activeProvider: "openrouter",
13
+ lastModel: {},
14
+ };
15
+
16
+ function load() {
17
+ if (!existsSync(STATE_FILE)) return { ...DEFAULT_STATE, lastModel: {} };
18
+ try {
19
+ const data = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
20
+ return {
21
+ activeProvider: data.activeProvider || DEFAULT_STATE.activeProvider,
22
+ lastModel: data.lastModel || {},
23
+ };
24
+ } catch {
25
+ return { ...DEFAULT_STATE, lastModel: {} };
26
+ }
27
+ }
28
+
29
+ function save(state) {
30
+ mkdirSync(FLINT_DIR, { recursive: true });
31
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
32
+ }
33
+
34
+ export function getActiveProvider() {
35
+ return load().activeProvider;
36
+ }
37
+
38
+ export function setActiveProvider(providerId) {
39
+ const state = load();
40
+ state.activeProvider = providerId;
41
+ save(state);
42
+ }
43
+
44
+ export function getLastModel(providerId) {
45
+ return load().lastModel[providerId] || null;
46
+ }
47
+
48
+ export function setLastModel(providerId, modelId) {
49
+ const state = load();
50
+ state.lastModel[providerId] = modelId;
51
+ save(state);
52
+ }
53
+
54
+ export function getProviderState() {
55
+ return load();
56
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Agent registry -- tracks running Flint instances.
3
+ * File at ~/.flint/agents.json with 0o600 permissions.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import path from "node:path";
9
+ import { apiUrl } from "./api/address.js";
10
+
11
+ const FLINT_DIR = path.join(homedir(), ".flint");
12
+ const REGISTRY_FILE = path.join(FLINT_DIR, "agents.json");
13
+
14
+ function ensureDir() {
15
+ mkdirSync(FLINT_DIR, { recursive: true, mode: 0o700 });
16
+ }
17
+
18
+ function readRegistry() {
19
+ try {
20
+ if (!existsSync(REGISTRY_FILE)) return [];
21
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
22
+ return Array.isArray(data) ? data : [];
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ function writeRegistry(entries) {
29
+ const tmp = REGISTRY_FILE + ".tmp." + process.pid;
30
+ try {
31
+ ensureDir();
32
+ writeFileSync(tmp, JSON.stringify(entries, null, 2), { mode: 0o600 });
33
+ renameSync(tmp, REGISTRY_FILE);
34
+ } catch {
35
+ // Rename can fail (e.g. EPERM on Windows when the target is open);
36
+ // clean up the tmp sibling so we don't leak on every write. Previously
37
+ // used `require("node:fs")` mid-function which always threw in ESM,
38
+ // leaving 345 agents.json.tmp.<pid> orphans in ~/.flint over months.
39
+ try { unlinkSync(tmp); } catch {}
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Register this agent instance.
45
+ */
46
+ export function registerAgent({ port, sessionId, model, profile, pid, provider, visible }) {
47
+ const entries = readRegistry().filter((e) => e.pid !== pid);
48
+ entries.push({
49
+ port,
50
+ sessionId,
51
+ model,
52
+ profile: profile || "desktop",
53
+ pid,
54
+ provider: provider || null,
55
+ visible: visible !== false,
56
+ startedAt: new Date().toISOString(),
57
+ });
58
+ writeRegistry(entries);
59
+ }
60
+
61
+ /**
62
+ * Unregister this agent instance.
63
+ */
64
+ export function unregisterAgent(pid) {
65
+ const entries = readRegistry().filter((e) => e.pid !== pid);
66
+ writeRegistry(entries);
67
+ }
68
+
69
+ /**
70
+ * List all registered agents, optionally checking if they're alive.
71
+ */
72
+ export async function listAgents({ checkAlive = false } = {}) {
73
+ let entries = readRegistry();
74
+
75
+ if (checkAlive) {
76
+ const alive = [];
77
+ for (const entry of entries) {
78
+ try {
79
+ const res = await fetch(apiUrl(entry.port, "/status"), {
80
+ signal: AbortSignal.timeout(1000),
81
+ });
82
+ if (res.ok) {
83
+ const status = await res.json();
84
+ alive.push({ ...entry, ...status, alive: true });
85
+ }
86
+ } catch {
87
+ // dead -- skip
88
+ }
89
+ }
90
+ // Clean up dead entries
91
+ writeRegistry(alive.map(({ alive, ...rest }) => rest));
92
+ return alive;
93
+ }
94
+
95
+ return entries;
96
+ }
package/src/restart.js ADDED
@@ -0,0 +1,29 @@
1
+ // Restart Flint and keep the session.
2
+ //
3
+ // Exit code 42 tells the launcher to start Flint again, and it used to start
4
+ // it with --new: every restart (/restart, POST /restart, restart_agent) left
5
+ // the conversation behind. Owner, 2026-10-02: an investigation in progress
6
+ // was cut off by a restart and had to be found again with /sessions.
7
+ //
8
+ // Before exiting, the session id goes to the launcher over the IPC channel it
9
+ // opens for us (launcher.js), and the launcher starts the new process with
10
+ // --session <id>. Run without a launcher (no IPC) it is a plain exit 42.
11
+
12
+ export const RESTART_CODE = 42;
13
+
14
+ /**
15
+ * Exit for a restart after `delayMs`, telling the launcher which session to
16
+ * continue. The exit waits for the message to be sent.
17
+ */
18
+ export function restartKeepingSession(sessionId, delayMs = 100, proc = process) {
19
+ setTimeout(() => {
20
+ const exit = () => proc.exit(RESTART_CODE);
21
+ if (sessionId && typeof proc.send === "function" && proc.connected !== false) {
22
+ try {
23
+ proc.send({ type: "flint:restart", sessionId }, () => exit());
24
+ return;
25
+ } catch {}
26
+ }
27
+ exit();
28
+ }, delayMs);
29
+ }
@@ -0,0 +1,130 @@
1
+ // Sandbox backend — isolates shell command execution.
2
+ //
3
+ // Backends:
4
+ // local — current behavior, runs directly on host (default, dev mode)
5
+ // docker — spawns per-session container, all commands inside
6
+ //
7
+ // Config: AGENT_SANDBOX env var (local|docker)
8
+ // Internal task reference removed.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { config } from "../config.js";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ const SANDBOX_MODE = process.env.AGENT_SANDBOX || "local";
16
+ const DOCKER_IMAGE = process.env.AGENT_SANDBOX_IMAGE || "node:20-slim";
17
+
18
+ let _activeContainer = null;
19
+
20
+ /**
21
+ * Get current sandbox mode.
22
+ */
23
+ export function getSandboxMode() {
24
+ return SANDBOX_MODE;
25
+ }
26
+
27
+ /**
28
+ * Initialize sandbox for a session.
29
+ * For docker: creates a container. For local: no-op.
30
+ * @param {string} sessionId
31
+ * @param {string} workdir — host directory to mount
32
+ */
33
+ export async function initSandbox(sessionId, workdir) {
34
+ if (SANDBOX_MODE !== "docker") return;
35
+
36
+ const name = `flint-sandbox-${sessionId.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 30)}`;
37
+
38
+ // Create container with workdir mounted
39
+ const args = [
40
+ "create", "--name", name,
41
+ "-w", "/workdir",
42
+ "-v", `${workdir}:/workdir`,
43
+ "--network", "none", // no outbound by default
44
+ DOCKER_IMAGE,
45
+ "sleep", "infinity",
46
+ ];
47
+
48
+ await runHost("docker", args, 30000);
49
+ await runHost("docker", ["start", name], 10000);
50
+ _activeContainer = name;
51
+ }
52
+
53
+ /**
54
+ * Execute a command in the sandbox.
55
+ * For docker: docker exec. For local: direct spawn.
56
+ * @param {string} command — shell command
57
+ * @param {string} cwd — working directory
58
+ * @param {object} opts — { shell, env, timeout, signal }
59
+ * @returns {Promise<{child, shell, shellArgs}>} — compatible with existing run_command flow
60
+ */
61
+ export function wrapCommand(command, cwd, opts = {}) {
62
+ if (SANDBOX_MODE === "docker" && _activeContainer) {
63
+ // Route through docker exec
64
+ const dockerArgs = [
65
+ "exec", "-w", "/workdir",
66
+ _activeContainer,
67
+ "bash", "-c", command,
68
+ ];
69
+ return {
70
+ shell: "docker",
71
+ shellArgs: dockerArgs,
72
+ cwd: undefined, // docker handles cwd internally
73
+ env: opts.env || process.env,
74
+ };
75
+ }
76
+
77
+ // Local mode — passthrough
78
+ return {
79
+ shell: opts.shell || config.shell,
80
+ shellArgs: ["-c", command],
81
+ cwd,
82
+ env: opts.env,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Cleanup sandbox at session end.
88
+ * For docker: removes the container.
89
+ */
90
+ export async function cleanupSandbox() {
91
+ if (SANDBOX_MODE !== "docker" || !_activeContainer) return;
92
+ try {
93
+ await runHost("docker", ["rm", "-f", _activeContainer], 15000);
94
+ } catch {}
95
+ _activeContainer = null;
96
+ }
97
+
98
+ /**
99
+ * Check if Docker is available on host.
100
+ */
101
+ export async function isDockerAvailable() {
102
+ try {
103
+ await runHost("docker", ["--version"], 5000);
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // Helper: run a command on HOST (not in sandbox)
111
+ function runHost(cmd, args, timeout = 30000) {
112
+ return new Promise((resolve, reject) => {
113
+ let stdout = "";
114
+ let stderr = "";
115
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
116
+ const timer = setTimeout(() => {
117
+ child.kill("SIGTERM");
118
+ reject(new Error(`${cmd} timeout after ${timeout}ms`));
119
+ }, timeout);
120
+ child.stdout.on("data", d => stdout += d);
121
+ child.stderr.on("data", d => stderr += d);
122
+ child.on("close", code => {
123
+ clearTimeout(timer);
124
+ if (code === 0) resolve(stdout.trim());
125
+ else reject(new Error(`${cmd} exit ${code}: ${stderr.slice(0, 200)}`));
126
+ });
127
+ });
128
+ }
129
+
130
+ export const __internal = { SANDBOX_MODE, DOCKER_IMAGE };
@@ -0,0 +1,132 @@
1
+ // API authentication — token-based auth for the HTTP server
2
+
3
+ import crypto from "node:crypto";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { isPairedToken } from "./pairing.js";
8
+
9
+ const FLINT_DIR = join(homedir(), ".flint");
10
+ const TOKEN_FILE = join(FLINT_DIR, "api-token.json");
11
+ // Token TTL: 30 days. After expiry, a fresh one is generated on next startup.
12
+ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Generate a random 48-character hex token.
16
+ * @returns {string} API token
17
+ */
18
+ export function generateApiToken() {
19
+ return crypto.randomBytes(24).toString("hex");
20
+ }
21
+
22
+ /**
23
+ * Load a persisted API token if it exists and hasn't expired.
24
+ * Otherwise generate a new one and persist it.
25
+ * Returns the token string.
26
+ */
27
+ export function loadOrCreateApiToken() {
28
+ try {
29
+ if (existsSync(TOKEN_FILE)) {
30
+ const data = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
31
+ if (data.token && typeof data.token === "string" && typeof data.expiresAt === "number") {
32
+ if (Date.now() < data.expiresAt) {
33
+ return data.token;
34
+ }
35
+ }
36
+ }
37
+ } catch {
38
+ // Fall through to generate a new token
39
+ }
40
+
41
+ const token = generateApiToken();
42
+ try {
43
+ mkdirSync(FLINT_DIR, { recursive: true });
44
+ const payload = {
45
+ token,
46
+ createdAt: Date.now(),
47
+ expiresAt: Date.now() + TOKEN_TTL_MS,
48
+ };
49
+ writeFileSync(TOKEN_FILE, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
50
+ } catch {
51
+ // Persist failed — still return the in-memory token
52
+ }
53
+ return token;
54
+ }
55
+
56
+ /**
57
+ * The token-file key to the HTTP API, only when the operator asked for it
58
+ * with FLINT_API_TOKEN_FILE=1 (automation that starts its own Flint).
59
+ *
60
+ * Owner, 2026-10-02: by default nothing can just connect. Any program running
61
+ * as the same user could read ~/.flint/api-token.json and send tasks that run
62
+ * with the API's automatic approval; a benchmark runner drove a console that
63
+ * way. Without the variable no file is read or written, and a program gets in
64
+ * only by pairing (a PIN the operator sees in the console).
65
+ * @returns {string|null}
66
+ */
67
+ export function masterTokenIfEnabled() {
68
+ return process.env.FLINT_API_TOKEN_FILE === "1" ? loadOrCreateApiToken() : null;
69
+ }
70
+
71
+ /**
72
+ * Constant-time token comparison.
73
+ */
74
+ function tokenEquals(a, b) {
75
+ const bufA = Buffer.from(a, "utf-8");
76
+ const bufB = Buffer.from(b, "utf-8");
77
+ if (bufA.length !== bufB.length) return false;
78
+ return crypto.timingSafeEqual(bufA, bufB);
79
+ }
80
+
81
+ /**
82
+ * Create auth middleware function.
83
+ * @param {string|null} token - The token-file key (masterTokenIfEnabled), or
84
+ * null: then only paired programs and a parent's spawn secret get in. No
85
+ * token never means "no auth"; that fail-open let anything in.
86
+ * @returns {Function} (req, res) => boolean — returns true if authorized, false if rejected (response already sent)
87
+ */
88
+ export function createAuthMiddleware(token) {
89
+ // Collect additional valid tokens: spawn secret from parent
90
+ const spawnSecret = process.env.AGENT_PAIRING_SECRET || null;
91
+
92
+ return function authMiddleware(req, res) {
93
+ // Skip auth for OPTIONS (CORS preflight) and GET /status (health check)
94
+ if (req.method === "OPTIONS") return true;
95
+
96
+ const url = new URL(req.url, `http://localhost`);
97
+ if (url.pathname === "/status" && req.method === "GET") return true;
98
+
99
+ // Skip auth for pairing endpoints
100
+ if (url.pathname.startsWith("/pair/")) return true;
101
+
102
+ // Check Authorization header
103
+ const authHeader = req.headers.authorization;
104
+ if (!authHeader) {
105
+ res.writeHead(401, { "Content-Type": "application/json" });
106
+ res.end(JSON.stringify({ error: "Authorization required. Use: Authorization: Bearer <token>" }));
107
+ return false;
108
+ }
109
+
110
+ const parts = authHeader.split(" ");
111
+ if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
112
+ res.writeHead(403, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify({ error: "Invalid token" }));
114
+ return false;
115
+ }
116
+
117
+ const candidate = parts[1];
118
+
119
+ // Check the token-file key, when enabled (constant-time)
120
+ if (token && tokenEquals(candidate, token)) return true;
121
+
122
+ // Check spawn secret from parent (constant-time)
123
+ if (spawnSecret && tokenEquals(candidate, spawnSecret)) return true;
124
+
125
+ // Check paired tokens
126
+ if (isPairedToken(candidate)) return true;
127
+
128
+ res.writeHead(403, { "Content-Type": "application/json" });
129
+ res.end(JSON.stringify({ error: "Invalid token" }));
130
+ return false;
131
+ };
132
+ }