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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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
|
+
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|