@aion0/forge 0.10.78 → 0.10.79

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.
@@ -209,6 +209,9 @@ function MouseToggle() {
209
209
  // ─── Pending commands for new terminal panes ────────────────
210
210
 
211
211
  const pendingCommands = new Map<number, string>();
212
+ // Secret env (API keys) to inject via `tmux set-environment` on connect, so the
213
+ // launch command never types `export KEY=value` into the pane (no echo/history).
214
+ const pendingEnv = new Map<number, Record<string, string>>();
212
215
 
213
216
  // ─── Bell notification tracking ─────────────────────────────
214
217
 
@@ -439,14 +442,14 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
439
442
  // Model flag from profile
440
443
  const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
441
444
 
442
- // Build env exports from profile (exclude CLAUDE_MODEL — passed via --model)
443
- const envExports = profileEnv
444
- ? Object.entries(profileEnv)
445
- .filter(([k]) => k !== 'CLAUDE_MODEL')
446
- .map(([k, v]) => `export ${k}="${v}"`)
447
- .join(' && ')
448
- : '';
449
- const envPrefix = envExports ? envExports + ' && ' : '';
445
+ // Build env injection from profile (exclude CLAUDE_MODEL — passed via
446
+ // --model). Secret values go via `tmux set-environment` (sent on connect);
447
+ // the prefix only references var NAMES so keys never echo into the pane.
448
+ const envEntries = profileEnv
449
+ ? Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
450
+ : [];
451
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
452
+ const envPrefix = tmuxEnvPrefix(envEntries.map(([k]) => k));
450
453
 
451
454
  // Skip-permissions flag. agent === 'claude' means the agent ID is
452
455
  // the base claude — not the resolved path. The check must compare
@@ -479,6 +482,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
479
482
  const tree = makeTerminal(undefined, projectPath);
480
483
  const paneId = firstTerminalId(tree);
481
484
  pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
485
+ if (envEntries.length) pendingEnv.set(paneId, Object.fromEntries(envEntries));
482
486
  const newTab: TabState = {
483
487
  id: nextId++,
484
488
  label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
@@ -1613,6 +1617,13 @@ const MemoTerminalPane = memo(function TerminalPane({
1613
1617
  const cmd = pendingCommands.get(id);
1614
1618
  if (cmd) {
1615
1619
  pendingCommands.delete(id);
1620
+ // Inject secret env into the tmux session first (no echo), so the
1621
+ // launch command can pull it via `tmux show-environment`.
1622
+ const penv = pendingEnv.get(id);
1623
+ if (penv && connectedSession && ws?.readyState === WebSocket.OPEN) {
1624
+ pendingEnv.delete(id);
1625
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: connectedSession, env: penv }));
1626
+ }
1616
1627
  setTimeout(() => {
1617
1628
  if (!disposed && ws?.readyState === WebSocket.OPEN) {
1618
1629
  ws.send(JSON.stringify({ type: 'input', data: cmd }));
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
4
4
  import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
5
5
  import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
6
+ import { updateTreeChildren } from '@/lib/fileTree';
6
7
  import {
7
8
  ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
8
9
  type Node, type NodeProps, MarkerType, type NodeChange,
@@ -559,25 +560,47 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
559
560
  const [search, setSearch] = useState('');
560
561
  const [flatFiles, setFlatFiles] = useState<string[]>([]);
561
562
 
563
+ // Full searchable path list comes from the server's bounded index (files +
564
+ // dirs), computed once. Falls back to walking the loaded tree only if an
565
+ // older server omits the index.
566
+ const buildSearchList = useCallback((data: any) => {
567
+ const fi = data?.fileIndex, di = data?.dirIndex;
568
+ if (Array.isArray(fi) || Array.isArray(di)) {
569
+ return [
570
+ ...(di || []).map((d: any) => `${d.path}/`),
571
+ ...(fi || []).map((f: any) => f.path),
572
+ ];
573
+ }
574
+ const files: string[] = [];
575
+ const walk = (items: any[]) => {
576
+ for (const n of items || []) {
577
+ files.push(n.type === 'dir' ? `${n.path}/` : n.path);
578
+ if (n.children) walk(n.children);
579
+ }
580
+ };
581
+ walk(data?.tree || []);
582
+ return files;
583
+ }, []);
584
+
562
585
  const loadTree = useCallback(() => {
563
586
  if (!projectPath) return;
564
587
  fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
565
588
  .then(r => r.json())
566
589
  .then(data => {
567
590
  setTree(data.tree || []);
568
- // Build flat list for search
569
- const files: string[] = [];
570
- const walk = (nodes: any[], prefix = '') => {
571
- for (const n of nodes || []) {
572
- const path = prefix ? `${prefix}/${n.name}` : n.name;
573
- files.push(n.type === 'dir' ? path + '/' : path);
574
- if (n.children) walk(n.children, path);
575
- }
576
- };
577
- walk(data.tree || []);
578
- setFlatFiles(files);
591
+ setFlatFiles(buildSearchList(data));
579
592
  })
580
593
  .catch(() => {});
594
+ }, [buildSearchList, projectPath]);
595
+
596
+ const loadChildren = useCallback(async (path: string) => {
597
+ if (!projectPath) return;
598
+ try {
599
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
600
+ const data = await res.json();
601
+ // Tree display only — search already covers the whole repo via the index.
602
+ setTree(prev => updateTreeChildren(prev, path, data.tree || []));
603
+ } catch {}
581
604
  }, [projectPath]);
582
605
 
583
606
  const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
@@ -613,7 +636,7 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
613
636
  )) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
614
637
  ) : (
615
638
  // Tree view (first 2 levels)
616
- tree.map(n => <PathTreeNode key={n.name} node={n} prefix="" onSelect={p => { onChange(p); setShowBrowser(false); }} />)
639
+ tree.map(n => <PathTreeNode key={n.path} node={n} onSelect={p => { onChange(p); setShowBrowser(false); }} onLoadChildren={loadChildren} />)
617
640
  )}
618
641
  </div>
619
642
  <div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
@@ -626,21 +649,34 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
626
649
  );
627
650
  }
628
651
 
629
- function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix: string; onSelect: (path: string) => void; depth?: number }) {
630
- const [expanded, setExpanded] = useState(depth < 1);
631
- const path = prefix ? `${prefix}/${node.name}` : node.name;
652
+ function PathTreeNode({ node, onSelect, onLoadChildren, depth = 0 }: { node: any; onSelect: (path: string) => void; onLoadChildren: (path: string) => Promise<void>; depth?: number }) {
653
+ const [expanded, setExpanded] = useState(false);
654
+ const path = node.path;
632
655
  const isDir = node.type === 'dir';
656
+ const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
633
657
 
634
658
  if (!isDir && depth > 1) return null; // only show files at top 2 levels
635
659
 
660
+ const toggleExpanded = async () => {
661
+ if (!isDir) {
662
+ onSelect(path);
663
+ return;
664
+ }
665
+ const nextExpanded = !expanded;
666
+ setExpanded(nextExpanded);
667
+ if (nextExpanded && hasChildren && !node.children) {
668
+ await onLoadChildren(path);
669
+ }
670
+ };
671
+
636
672
  return (
637
673
  <div>
638
674
  <div
639
- onClick={() => isDir ? setExpanded(!expanded) : onSelect(path)}
675
+ onClick={toggleExpanded}
640
676
  className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
641
677
  style={{ paddingLeft: 8 + depth * 12 }}
642
678
  >
643
- <span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? '▼' : '▶') : ''}</span>
679
+ <span className="text-gray-500 mr-1 w-3">{isDir && hasChildren ? (expanded ? '▼' : '▶') : ''}</span>
644
680
  <span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '📁' : '📄'} {node.name}</span>
645
681
  {isDir && (
646
682
  <button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
@@ -648,7 +684,7 @@ function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix
648
684
  )}
649
685
  </div>
650
686
  {isDir && expanded && node.children && depth < 2 && (
651
- node.children.map((c: any) => <PathTreeNode key={c.name} node={c} prefix={path} onSelect={onSelect} depth={depth + 1} />)
687
+ node.children.map((c: any) => <PathTreeNode key={c.path} node={c} onSelect={onSelect} onLoadChildren={onLoadChildren} depth={depth + 1} />)
652
688
  )}
653
689
  </div>
654
690
  );
@@ -782,29 +818,6 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
782
818
  const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
783
819
  initial.watch?.targets || []
784
820
  );
785
- const [projectDirs, setProjectDirs] = useState<string[]>([]);
786
-
787
- useEffect(() => {
788
- if (!watchEnabled || !projectPath) return;
789
- fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
790
- .then(r => r.json())
791
- .then(data => {
792
- // Collect directories with depth limit (max 2 levels for readability)
793
- const dirs: string[] = [];
794
- const walk = (nodes: any[], prefix = '', depth = 0) => {
795
- for (const n of nodes || []) {
796
- if (n.type === 'dir') {
797
- const path = prefix ? `${prefix}/${n.name}` : n.name;
798
- dirs.push(path);
799
- if (n.children && depth < 2) walk(n.children, path, depth + 1);
800
- }
801
- }
802
- };
803
- walk(data.tree || []);
804
- setProjectDirs(dirs);
805
- })
806
- .catch(() => {});
807
- }, [watchEnabled, projectPath]);
808
821
 
809
822
  const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
810
823
  setLabel(p.label); setIcon(p.icon); setRole(p.role);
@@ -2296,8 +2309,11 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
2296
2309
  const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
2297
2310
  commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
2298
2311
  const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
2299
- if (envWithoutForge.length > 0) {
2300
- commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
2312
+ if (envWithoutForge.length > 0 && msg.sessionName) {
2313
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: msg.sessionName, env: Object.fromEntries(envWithoutForge) }));
2314
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
2315
+ const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
2316
+ if (wrap) commands.push(wrap.replace(/ && $/, ''));
2301
2317
  }
2302
2318
  const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
2303
2319
  if (forgeVars.length > 0) {
@@ -2541,13 +2557,18 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2541
2557
  const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
2542
2558
  commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
2543
2559
 
2544
- // 2. Export new profile vars (if any)
2560
+ // 2. Profile vars (secrets) inject via `tmux set-environment` (no
2561
+ // echo), then reference by NAME in the launch command so API keys
2562
+ // never appear in the pane or shell history.
2545
2563
  const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
2546
- if (envWithoutForge.length > 0) {
2547
- commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
2564
+ if (envWithoutForge.length > 0 && sessionNameRef.current) {
2565
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: sessionNameRef.current, env: Object.fromEntries(envWithoutForge) }));
2566
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
2567
+ const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
2568
+ if (wrap) commands.push(wrap.replace(/ && $/, ''));
2548
2569
  }
2549
2570
 
2550
- // 3. Export FORGE vars
2571
+ // 3. Export FORGE vars (not secret — plain export is fine)
2551
2572
  const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
2552
2573
  if (forgeVars.length > 0) {
2553
2574
  commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { execFileSync, execSync } from 'node:child_process';
7
7
  import { loadSettings } from '../settings';
8
+ import { decryptSecret } from '../crypto';
8
9
 
9
10
  // Cache absolute-path resolution per command — `which <cmd>` is cheap
10
11
  // but resolveTerminalLaunch is on the hot path for every chat tool call.
@@ -311,6 +312,14 @@ export function resolveTerminalLaunch(agentId?: string, scene: 'terminal' | 'tas
311
312
  if (model === 'default') model = undefined;
312
313
  }
313
314
  }
315
+ // Decrypt any enc:… env values (e.g. CLAUDE_CODE_OAUTH_TOKEN written by the
316
+ // onboarding wizard). decryptSecret is a no-op on plaintext, so template-
317
+ // derived plaintext env passes through untouched.
318
+ if (env) {
319
+ for (const k of Object.keys(env)) {
320
+ if (typeof env[k] === 'string' && env[k].startsWith('enc:')) env[k] = decryptSecret(env[k]);
321
+ }
322
+ }
314
323
 
315
324
  return {
316
325
  // Prefer the user-configured absolute binary path over the bare command
@@ -0,0 +1,28 @@
1
+ // Shared helpers for the lazily-loaded file trees served by /api/code.
2
+ // The tree is fetched one directory level at a time; children for a directory
3
+ // are merged in on demand. Kept generic so each consumer can use its own node
4
+ // shape (CodeViewer, ProjectDetail, WorkspaceView) as long as it carries a
5
+ // `path` and an optional `children` array.
6
+
7
+ export interface FileTreeNode {
8
+ path: string;
9
+ children?: FileTreeNode[];
10
+ hasChildren?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Return a new tree with `children` spliced in under the node whose `path`
15
+ * matches. Pure — does not mutate the input. `hasChildren` is recomputed so a
16
+ * directory that turns out to be empty loses its expand affordance.
17
+ */
18
+ export function updateTreeChildren<T extends { path: string; children?: T[]; hasChildren?: boolean }>(
19
+ nodes: T[],
20
+ path: string,
21
+ children: T[],
22
+ ): T[] {
23
+ return nodes.map(node => {
24
+ if (node.path === path) return { ...node, children, hasChildren: children.length > 0 };
25
+ if (node.children) return { ...node, children: updateTreeChildren(node.children, path, children) };
26
+ return node;
27
+ });
28
+ }
@@ -53,6 +53,17 @@ Each agent entry in `settings.agents` supports:
53
53
  | `models` | object | Model overrides per context: `terminal`, `task`, `telegram`, `help`, `mobile` |
54
54
  | `profile` | string | Linked profile ID — applies that profile's env/model when launching |
55
55
 
56
+ ### Claude authentication (host vs container)
57
+
58
+ The onboarding wizard's **CLI Agent** step detects your deployment shape and adapts how Claude Code authenticates:
59
+
60
+ - **Host** (desktop/laptop): run `claude login` once in a terminal after onboarding — it stores credentials in the OS keychain.
61
+ - **Container** (Docker / k8s): `claude login` can't reach a keychain. Instead, on a machine where you *can* log in, run `claude setup-token`, then paste the resulting token into the wizard. It's saved encrypted as `CLAUDE_CODE_OAUTH_TOKEN` in the agent's `env` and injected at launch.
62
+
63
+ Detection signals (any → container): `FORGE_CONTAINER=1`, `/.dockerenv`, or a containerized cgroup. The wizard pre-selects the right mode but you can override it.
64
+
65
+ Agent `env` secrets (including the OAuth token) are encrypted at rest (`enc:…`) and injected into terminals via `tmux set-environment`, so they never appear in the pane echo or shell history.
66
+
56
67
  ### CLI Type
57
68
 
58
69
  The `cliType` field determines how Forge interacts with the agent:
@@ -38,7 +38,9 @@ Notes:
38
38
  ## Features
39
39
 
40
40
  ### Code Tab
41
- - File tree browser
41
+ - File tree browser — loads lazily (folders expand on demand), so very large repos open instantly
42
+ - File search uses a fast server-built index; in very large repos the index is sampled per directory, and a note appears when results may be partial
43
+ - "Locate in file tree" (on a changed file) reveals it in the tree
42
44
  - Syntax-highlighted code viewer
43
45
  - Git diff view (click changed files)
44
46
  - Git operations: commit, push, pull
@@ -51,3 +51,22 @@ export async function getMcpFlag(projectPath: string): Promise<string> {
51
51
  return ` --mcp-config "${projectPath}/.forge/mcp.json"`;
52
52
  }
53
53
 
54
+
55
+ /**
56
+ * tmux env injection — keep secret env (API keys) out of pane echo + shell
57
+ * history. The server stores values via `tmux set-environment` (the `setenv` WS
58
+ * message); this prefix pulls them back at launch time so only var NAMES appear
59
+ * in the typed command, never the values.
60
+ *
61
+ * Uses a single `eval "$(tmux show-environment -s | grep …)"` instead of one
62
+ * `export K="$(tmux show-environment K | sed …)"` per var — the latter form
63
+ * grows O(N) in length and hits tmux send-keys buffer limits with many vars.
64
+ * `tmux show-environment -s` outputs `KEY="val"; export KEY;` per line, so
65
+ * grepping `^(K1|K2)=` and eval-ing the matches is both short and safe.
66
+ */
67
+ export function tmuxEnvPrefix(keys: string[]): string {
68
+ const valid = keys.filter((k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k));
69
+ if (!valid.length) return '';
70
+ const pattern = `^(${valid.join('|')})=`;
71
+ return `eval "$(tmux show-environment -s 2>/dev/null | grep -E '${pattern}')" && `;
72
+ }
@@ -424,6 +424,23 @@ wss.on('connection', (ws: WebSocket) => {
424
424
  break;
425
425
  }
426
426
 
427
+ case 'setenv': {
428
+ // Store secret env (API keys) into the tmux SESSION environment so the
429
+ // launch command can pull them via `tmux show-environment` instead of
430
+ // typing `export KEY=value` into the pane — keeps secrets out of the
431
+ // terminal echo and shell history. Values never touch the typed line.
432
+ const name = parsed.sessionName;
433
+ const env = parsed.env;
434
+ if (name && tmuxSessionExists(name) && env && typeof env === 'object') {
435
+ for (const [k, v] of Object.entries(env)) {
436
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
437
+ const val = `'${String(v).replace(/'/g, `'\\''`)}'`;
438
+ try { execSync(`${TMUX} set-environment -t "${name}" ${k} ${val}`, { timeout: 3000 }); } catch {}
439
+ }
440
+ }
441
+ break;
442
+ }
443
+
427
444
  case 'load-state': {
428
445
  const state = loadTerminalState();
429
446
  ws.send(JSON.stringify({ type: 'terminal-state', data: state }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.78",
3
+ "version": "0.10.79",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {