@aion0/forge 0.6.1 → 0.8.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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,87 @@
1
+ /**
2
+ * MemoryStore — common surface for chat's long-term memory.
3
+ *
4
+ * Two implementations:
5
+ * - TemperClient (lib/chat/temper.ts): HTTP-backed, with semantic +
6
+ * graph search via Graphiti.
7
+ * - LocalMemoryStore (lib/chat/local-memory.ts): SQLite-backed
8
+ * fallback when Temper isn't configured. Same block API; search is
9
+ * keyword LIKE over stored block values + episode content.
10
+ *
11
+ * The factory `getMemoryStore()` picks one based on settings:
12
+ * temperUrl + temperKey present → Temper; otherwise → Local.
13
+ *
14
+ * Both report `enabled = true` once they have somewhere to read/write,
15
+ * so the chat loop registers the memory_* tools either way.
16
+ */
17
+
18
+ import { loadSettings } from '@/lib/settings';
19
+ import { TemperClient } from './temper';
20
+ import { LocalMemoryStore } from './local-memory';
21
+ import type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
22
+
23
+ export type { EpisodeInput, MemoryBlock, SearchHit } from './temper';
24
+
25
+ export interface MemoryStore {
26
+ readonly enabled: boolean;
27
+ readonly currentNamespace: string;
28
+ readonly kind: 'temper' | 'local';
29
+
30
+ search(query: string, limit?: number): Promise<SearchHit[]>;
31
+ writeEpisode(ep: EpisodeInput, asyncExtract?: boolean): Promise<boolean>;
32
+ listBlocks(opts?: { pinned?: boolean; scope?: 'own' | 'global' | 'both' }): Promise<MemoryBlock[]>;
33
+ getBlock(key: string, scope?: 'own' | 'global'): Promise<MemoryBlock | null>;
34
+ putBlock(
35
+ key: string,
36
+ value: unknown,
37
+ extras?: { pinned?: boolean; priority?: number; description?: string; scope?: 'own' | 'global' },
38
+ ): Promise<boolean>;
39
+ ping(): Promise<{ ok: boolean; message: string; pinned: number }>;
40
+ }
41
+
42
+ /**
43
+ * Pick the active memory backend.
44
+ *
45
+ * Resolution order:
46
+ * - settings.memoryBackend === 'local' → always LocalMemoryStore.
47
+ * - settings.memoryBackend === 'temper' → always TemperClient (even if
48
+ * credentials are missing — caller will see `enabled = false`).
49
+ * - 'auto' (default): Temper if both URL+key are set, else Local.
50
+ *
51
+ * Callers should NOT cache the result across settings edits — the
52
+ * picker is cheap.
53
+ */
54
+ export function getMemoryStore(): MemoryStore {
55
+ const s = loadSettings();
56
+ const url = (s.temperUrl || '').trim();
57
+ const key = (s.temperKey || '').trim();
58
+ const mode = s.memoryBackend || 'auto';
59
+
60
+ if (mode === 'local') return new LocalMemoryStore();
61
+ if (mode === 'temper') {
62
+ return makeTemperWrapper(new TemperClient(url, key, s.temperNamespace || ''));
63
+ }
64
+ // auto
65
+ if (url && key) {
66
+ return makeTemperWrapper(new TemperClient(url, key, s.temperNamespace || ''));
67
+ }
68
+ return new LocalMemoryStore();
69
+ }
70
+
71
+ function makeTemperWrapper(c: TemperClient): MemoryStore {
72
+ return {
73
+ kind: 'temper',
74
+ get enabled() {
75
+ return c.enabled;
76
+ },
77
+ get currentNamespace() {
78
+ return c.currentNamespace;
79
+ },
80
+ search: (q, n) => c.search(q, n),
81
+ writeEpisode: (ep, a) => c.writeEpisode(ep, a),
82
+ listBlocks: (opts) => c.listBlocks(opts ?? {}),
83
+ getBlock: (k, scope) => c.getBlock(k, scope),
84
+ putBlock: (k, v, extras) => c.putBlock(k, v, extras),
85
+ ping: () => c.ping(),
86
+ };
87
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Memory tools exposed to the LLM by the chat backend.
3
+ *
4
+ * Registered whenever a MemoryStore is enabled — that's always true
5
+ * now: Temper if configured, otherwise the SQLite-backed
6
+ * LocalMemoryStore. The tool schemas and names are identical across
7
+ * backends; only the descriptions stay accurate (e.g. `memory_search`
8
+ * is keyword on local, semantic on Temper).
9
+ *
10
+ * Tool naming uses `memory_*` (no plugin prefix) so they look like
11
+ * first-class builtins next to get_current_time.
12
+ */
13
+
14
+ import type { BuiltinToolDef } from './tool-dispatcher';
15
+ import type { MemoryStore } from './memory-store';
16
+
17
+ export type MemoryToolHandler = (input: unknown) => Promise<string>;
18
+
19
+ export interface MemoryTool {
20
+ def: BuiltinToolDef;
21
+ handle: MemoryToolHandler;
22
+ }
23
+
24
+ export function buildMemoryTools(store: MemoryStore): MemoryTool[] {
25
+ if (!store.enabled) return [];
26
+ const isLocal = store.kind === 'local';
27
+ const searchDescription = isLocal
28
+ ? 'Keyword search over locally-stored memory blocks and episodes (case-insensitive LIKE). Use for finding past notes / events you wrote earlier. For first-person identity/preferences prefer memory_get_block / memory_list_blocks.'
29
+ : 'Semantic + graph search over Temper episodes (Graphiti). Use for NAMED third-party facts ("what did Alice say about X", "what was decided on the migration"). DO NOT use for first-person questions — pronouns are filtered and you will get 0 hits. For first-person, use memory_get_block / memory_list_blocks.';
30
+ const episodeDescription = isLocal
31
+ ? 'Append a short event-shaped fact to the local memory log (one row per call). Searchable via memory_search keyword match. Use for "Alice prefers Postgres", "user decided to ship X on May 20".'
32
+ : 'Write an event-shaped fact to long-term memory (a Temper episode). Use for "Alice prefers Postgres", "user decided to ship X on May 20", external-world facts with subject/object structure. Extraction runs async. ONE discrete fact per call — do NOT dump transcripts.';
33
+ return [
34
+ {
35
+ def: {
36
+ name: 'memory_get_block',
37
+ description:
38
+ 'Read ONE memory block by key. Use this for first-person queries ("my name", "what do you call me", "my preferences"). Returns the JSON value or "(not found)".',
39
+ input_schema: {
40
+ type: 'object',
41
+ properties: {
42
+ key: { type: 'string', description: 'Block key, e.g. forge_user_name / preferences.theme / persona.nickname' },
43
+ scope: { type: 'string', enum: ['own', 'global'], description: 'Default: own (falls back to global server-side).' },
44
+ },
45
+ required: ['key'],
46
+ },
47
+ },
48
+ handle: async (input) => {
49
+ const { key, scope } = input as { key: string; scope?: 'own' | 'global' };
50
+ const b = await store.getBlock(key, scope ?? 'own');
51
+ if (!b) return '(not found)';
52
+ return typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
53
+ },
54
+ },
55
+ {
56
+ def: {
57
+ name: 'memory_list_blocks',
58
+ description:
59
+ 'List memory block keys. Use when the user asks "what do you know about me" / "what have you remembered" or you need to discover what is stored. Returns key + short value preview.',
60
+ input_schema: {
61
+ type: 'object',
62
+ properties: {
63
+ pinned_only: { type: 'boolean', description: 'If true, only pinned (auto-injected) blocks. Default false.' },
64
+ scope: { type: 'string', enum: ['own', 'global', 'both'], description: 'Default: both.' },
65
+ },
66
+ },
67
+ },
68
+ handle: async (input) => {
69
+ const { pinned_only, scope } = input as {
70
+ pinned_only?: boolean;
71
+ scope?: 'own' | 'global' | 'both';
72
+ };
73
+ const blocks = await store.listBlocks({
74
+ pinned: pinned_only,
75
+ scope: scope ?? 'both',
76
+ });
77
+ if (!blocks.length) return '(no blocks)';
78
+ return blocks
79
+ .map((b) => {
80
+ const v = typeof b.value === 'string' ? b.value : JSON.stringify(b.value);
81
+ const short = v.length > 120 ? v.slice(0, 120) + '…' : v;
82
+ return `- ${b.key}: ${short}`;
83
+ })
84
+ .join('\n');
85
+ },
86
+ },
87
+ {
88
+ def: {
89
+ name: 'memory_search',
90
+ description: searchDescription,
91
+ input_schema: {
92
+ type: 'object',
93
+ properties: {
94
+ query: { type: 'string', description: 'Free-text query' },
95
+ limit: { type: 'number', description: 'Max hits to return (1-15). Default 6.' },
96
+ },
97
+ required: ['query'],
98
+ },
99
+ },
100
+ handle: async (input) => {
101
+ const { query, limit } = input as { query: string; limit?: number };
102
+ const hits = await store.search(query, Math.min(15, Math.max(1, limit ?? 6)));
103
+ if (!hits.length) return '(no hits)';
104
+ return hits
105
+ .filter((h) => h.fact && h.invalid_at == null)
106
+ .map((h) => `- ${h.fact}${h.score != null ? ` (score=${h.score.toFixed(2)})` : ''}`)
107
+ .join('\n') || '(no current facts)';
108
+ },
109
+ },
110
+ {
111
+ def: {
112
+ name: 'memory_remember_block',
113
+ description:
114
+ 'Store a first-person fact about the user as a memory block (key/value). Use for preferences, identity, current focus, routines — anything the user asserts about themselves. Block values REPLACE on write. Pin=true to inject into every future system prompt.',
115
+ input_schema: {
116
+ type: 'object',
117
+ properties: {
118
+ key: { type: 'string', description: 'Dotted key, e.g. preferences.language / persona.role / state.current_project' },
119
+ value: { description: 'String or JSON object — the value to store verbatim.' },
120
+ pinned: { type: 'boolean', description: 'If true, auto-injected into every future prompt. Use for stable identity / preferences only.' },
121
+ description: { type: 'string', description: 'Optional human-readable note about why this block exists.' },
122
+ },
123
+ required: ['key', 'value'],
124
+ },
125
+ },
126
+ handle: async (input) => {
127
+ const { key, value, pinned, description } = input as {
128
+ key: string;
129
+ value: unknown;
130
+ pinned?: boolean;
131
+ description?: string;
132
+ };
133
+ const ok = await store.putBlock(key, value, { pinned, description });
134
+ return ok ? `saved block: ${key}${pinned ? ' (pinned)' : ''}` : 'block save failed';
135
+ },
136
+ },
137
+ {
138
+ def: {
139
+ name: 'memory_remember_event',
140
+ description: episodeDescription,
141
+ input_schema: {
142
+ type: 'object',
143
+ properties: {
144
+ content: { type: 'string', description: 'Short statement of the fact, with explicit subject (name them, do not say "I" / "me").' },
145
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags like "preference" / "decision".' },
146
+ },
147
+ required: ['content'],
148
+ },
149
+ },
150
+ handle: async (input) => {
151
+ const { content, tags } = input as { content: string; tags?: string[] };
152
+ const ok = await store.writeEpisode({ content, tags, source_type: 'text' });
153
+ return ok ? 'event recorded' : 'event write failed';
154
+ },
155
+ },
156
+ ];
157
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * HTTP protocol runtime for connector tools.
3
+ *
4
+ * Issues an HTTP request from Forge (server-side, not the user's browser).
5
+ * Used by tools where the upstream offers a clean REST API and no DOM
6
+ * scraping is needed (GitHub API, GitLab API, internal services).
7
+ *
8
+ * Templates: {base_url}, {settings.*}, {args.*} are expanded into url,
9
+ * query values, header values, and body. Body can be a string or an
10
+ * object — strings are sent as-is after expansion; objects are walked
11
+ * and any string leaves are expanded, then JSON.stringify'd.
12
+ *
13
+ * Response: the body is returned verbatim (truncated to 8 KB for the
14
+ * LLM context) with a status-line preamble. Non-2xx is surfaced as
15
+ * is_error so the LLM can react.
16
+ */
17
+
18
+ import type { HttpRequestSpec, ConnectorTool } from '@/lib/plugins/types';
19
+ import { expandAllTokens } from '@/lib/plugins/templates';
20
+
21
+ export interface HttpProtocolArgs {
22
+ tool: ConnectorTool;
23
+ settings: Record<string, any>;
24
+ args: Record<string, any>;
25
+ }
26
+
27
+ export interface HttpProtocolResult {
28
+ content: string;
29
+ is_error?: boolean;
30
+ }
31
+
32
+ const DEFAULT_TIMEOUT_MS = 30_000;
33
+ const MAX_TIMEOUT_MS = 300_000;
34
+ const MAX_BODY_BYTES = 8 * 1024;
35
+
36
+ function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Record<string, any>): any {
37
+ if (obj == null) return obj;
38
+ if (typeof obj === 'string') return expandAllTokens(obj, settings, args);
39
+ if (Array.isArray(obj)) return obj.map((v) => expandObjectLeaves(v, settings, args));
40
+ if (typeof obj === 'object') {
41
+ const out: Record<string, any> = {};
42
+ for (const [k, v] of Object.entries(obj)) out[k] = expandObjectLeaves(v, settings, args);
43
+ return out;
44
+ }
45
+ return obj;
46
+ }
47
+
48
+ function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
49
+ const base = expandAllTokens(spec.url, settings, args);
50
+ if (!spec.query) return base;
51
+ const url = new URL(base);
52
+ for (const [k, raw] of Object.entries(spec.query)) {
53
+ const v = expandAllTokens(String(raw), settings, args);
54
+ url.searchParams.append(k, v);
55
+ }
56
+ return url.toString();
57
+ }
58
+
59
+ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): Headers {
60
+ const h = new Headers();
61
+ if (spec.headers) {
62
+ for (const [k, raw] of Object.entries(spec.headers)) {
63
+ h.set(k, expandAllTokens(String(raw), settings, args));
64
+ }
65
+ }
66
+ return h;
67
+ }
68
+
69
+ function buildBody(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
70
+ if (spec.body == null) return {};
71
+ if (typeof spec.body === 'string') {
72
+ return { body: expandAllTokens(spec.body, settings, args) };
73
+ }
74
+ const obj = expandObjectLeaves(spec.body, settings, args);
75
+ return { body: JSON.stringify(obj), contentType: 'application/json' };
76
+ }
77
+
78
+ function truncate(s: string): { text: string; truncated: boolean; totalBytes: number } {
79
+ const buf = Buffer.from(s, 'utf-8');
80
+ if (buf.byteLength <= MAX_BODY_BYTES) return { text: s, truncated: false, totalBytes: buf.byteLength };
81
+ const slice = buf.subarray(0, MAX_BODY_BYTES).toString('utf-8');
82
+ return { text: slice, truncated: true, totalBytes: buf.byteLength };
83
+ }
84
+
85
+ export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promise<HttpProtocolResult> {
86
+ const spec = tool.request;
87
+ if (!spec || !spec.url) {
88
+ return { content: 'http tool missing `request.url`', is_error: true };
89
+ }
90
+ const method = (spec.method || 'GET').toUpperCase();
91
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
92
+
93
+ const url = buildUrl(spec, settings, args);
94
+ const headers = buildHeaders(spec, settings, args);
95
+ const { body, contentType } = buildBody(spec, settings, args);
96
+ if (body != null && contentType && !headers.has('content-type')) headers.set('content-type', contentType);
97
+
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
100
+
101
+ let res: Response;
102
+ try {
103
+ res = await fetch(url, { method, headers, body, signal: controller.signal });
104
+ } catch (e) {
105
+ clearTimeout(timer);
106
+ return { content: `http request failed: ${(e as Error).message}`, is_error: true };
107
+ }
108
+ clearTimeout(timer);
109
+
110
+ const text = await res.text().catch(() => '');
111
+ const { text: shown, truncated, totalBytes } = truncate(text);
112
+ const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
113
+ (truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n');
114
+ return {
115
+ content: preamble + shown,
116
+ is_error: !res.ok,
117
+ };
118
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shell protocol runtime for connector tools.
3
+ *
4
+ * Spawns a process with an explicit arg array (no shell:true) so
5
+ * templated values can never inject shell metacharacters. Each arg
6
+ * is templated independently — e.g. `["git", "-C", "{args.repo}",
7
+ * "log", "-n", "{args.n}"]` produces a literal arg, not a shell line.
8
+ *
9
+ * Returns combined output. Non-zero exit code is surfaced as is_error
10
+ * with the exit code in the preamble and stderr if any.
11
+ *
12
+ * Safety boundary: connectors are user-installed manifests. A malicious
13
+ * yaml could still pick a destructive `command[0]` (rm, etc.), so
14
+ * shell-protocol tools must be reviewed at install time. There's no
15
+ * automatic command allow-list in v1.
16
+ */
17
+
18
+ import type { ConnectorTool } from '@/lib/plugins/types';
19
+ import { expandAllTokens } from '@/lib/plugins/templates';
20
+ import { spawn } from 'node:child_process';
21
+
22
+ export interface ShellProtocolArgs {
23
+ tool: ConnectorTool;
24
+ settings: Record<string, any>;
25
+ args: Record<string, any>;
26
+ }
27
+
28
+ export interface ShellProtocolResult {
29
+ content: string;
30
+ is_error?: boolean;
31
+ }
32
+
33
+ const DEFAULT_TIMEOUT_MS = 30_000;
34
+ const MAX_TIMEOUT_MS = 300_000;
35
+ const MAX_OUTPUT_BYTES = 8 * 1024;
36
+
37
+ function truncate(s: string): string {
38
+ const buf = Buffer.from(s, 'utf-8');
39
+ if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
40
+ return buf.subarray(0, MAX_OUTPUT_BYTES).toString('utf-8') +
41
+ `\n(...truncated, total ${buf.byteLength} bytes)`;
42
+ }
43
+
44
+ export async function runShell({ tool, settings, args }: ShellProtocolArgs): Promise<ShellProtocolResult> {
45
+ const cmd = tool.command;
46
+ if (!cmd || !Array.isArray(cmd) || cmd.length === 0) {
47
+ return { content: 'shell tool missing `command` array', is_error: true };
48
+ }
49
+ const expanded = cmd.map((part) => expandAllTokens(String(part), settings, args));
50
+ const [bin, ...argv] = expanded;
51
+ const cwd = tool.cwd ? expandAllTokens(tool.cwd, settings, args) : undefined;
52
+ const env = tool.env
53
+ ? Object.fromEntries(Object.entries(tool.env).map(([k, v]) => [k, expandAllTokens(String(v), settings, args)]))
54
+ : undefined;
55
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
56
+
57
+ return new Promise<ShellProtocolResult>((resolve) => {
58
+ let proc;
59
+ try {
60
+ proc = spawn(bin!, argv, {
61
+ cwd,
62
+ env: env ? { ...process.env, ...env } : process.env,
63
+ stdio: ['ignore', 'pipe', 'pipe'],
64
+ });
65
+ } catch (e) {
66
+ return resolve({ content: `shell spawn failed: ${(e as Error).message}`, is_error: true });
67
+ }
68
+
69
+ let stdout = '';
70
+ let stderr = '';
71
+ let killed = false;
72
+ const timer = setTimeout(() => {
73
+ killed = true;
74
+ try { proc.kill('SIGTERM'); } catch {}
75
+ }, timeoutMs);
76
+
77
+ proc.stdout?.on('data', (b) => { stdout += b.toString('utf-8'); });
78
+ proc.stderr?.on('data', (b) => { stderr += b.toString('utf-8'); });
79
+
80
+ proc.on('error', (err) => {
81
+ clearTimeout(timer);
82
+ resolve({ content: `shell error: ${err.message}`, is_error: true });
83
+ });
84
+
85
+ proc.on('close', (code, signal) => {
86
+ clearTimeout(timer);
87
+ const preamble = killed
88
+ ? `[killed after ${timeoutMs}ms]\n`
89
+ : signal
90
+ ? `[signal ${signal}]\n`
91
+ : code === 0
92
+ ? ''
93
+ : `[exit ${code}]\n`;
94
+ const trail = stderr.trim() && code !== 0 ? `\n--- stderr ---\n${truncate(stderr)}` : '';
95
+ resolve({
96
+ content: preamble + truncate(stdout) + trail,
97
+ is_error: killed || code !== 0,
98
+ });
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Thin proxy helper — forwards a Next.js request to chat-standalone
3
+ * (127.0.0.1:$CHAT_PORT, default 8408). Auth is enforced by the Next
4
+ * middleware before we get here; chat-standalone itself is loopback-only.
5
+ */
6
+
7
+ const CHAT_PORT = Number(process.env.CHAT_PORT) || 8408;
8
+
9
+ export async function proxyToChat(req: Request, path: string): Promise<Response> {
10
+ const url = new URL(req.url);
11
+ const target = `http://127.0.0.1:${CHAT_PORT}${path}${url.search || ''}`;
12
+
13
+ // Pass the body through verbatim for POST/PATCH. GET/DELETE go without.
14
+ const init: RequestInit = {
15
+ method: req.method,
16
+ headers: { 'content-type': 'application/json' },
17
+ body: req.method === 'GET' || req.method === 'DELETE' || req.method === 'HEAD'
18
+ ? undefined
19
+ : await req.text(),
20
+ };
21
+
22
+ let upstream: Response;
23
+ try {
24
+ upstream = await fetch(target, init);
25
+ } catch (e) {
26
+ return new Response(JSON.stringify({ error: `chat service unreachable on ${CHAT_PORT}: ${(e as Error).message}` }), {
27
+ status: 502, headers: { 'content-type': 'application/json' },
28
+ });
29
+ }
30
+
31
+ // For SSE streams, pipe the body through as a streaming response so
32
+ // events reach the browser as they arrive.
33
+ const ct = upstream.headers.get('content-type') || '';
34
+ if (ct.startsWith('text/event-stream') && upstream.body) {
35
+ return new Response(upstream.body, {
36
+ status: upstream.status,
37
+ headers: {
38
+ 'content-type': 'text/event-stream',
39
+ 'cache-control': 'no-cache, no-transform',
40
+ 'connection': 'keep-alive',
41
+ 'x-accel-buffering': 'no',
42
+ },
43
+ });
44
+ }
45
+
46
+ const body = await upstream.text();
47
+ return new Response(body, {
48
+ status: upstream.status,
49
+ headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' },
50
+ });
51
+ }