ghost-dragon 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +23 -0
- package/CHANGELOG.md +96 -0
- package/README.md +193 -0
- package/bootstrap.ps1 +83 -0
- package/bootstrap.sh +71 -0
- package/dist/agent/loop.d.ts +68 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/loop.js +135 -0
- package/dist/agent/mcp.d.ts +33 -0
- package/dist/agent/mcp.d.ts.map +1 -0
- package/dist/agent/mcp.js +107 -0
- package/dist/agent/session.d.ts +16 -0
- package/dist/agent/session.d.ts.map +1 -0
- package/dist/agent/session.js +55 -0
- package/dist/agent/skills.d.ts +36 -0
- package/dist/agent/skills.d.ts.map +1 -0
- package/dist/agent/skills.js +153 -0
- package/dist/agent/stack.d.ts +21 -0
- package/dist/agent/stack.d.ts.map +1 -0
- package/dist/agent/stack.js +158 -0
- package/dist/agent/task.d.ts +21 -0
- package/dist/agent/task.d.ts.map +1 -0
- package/dist/agent/task.js +45 -0
- package/dist/agent/tools.d.ts +44 -0
- package/dist/agent/tools.d.ts.map +1 -0
- package/dist/agent/tools.js +262 -0
- package/dist/agent/trace.d.ts +34 -0
- package/dist/agent/trace.d.ts.map +1 -0
- package/dist/agent/trace.js +72 -0
- package/dist/agent.d.ts +46 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +103 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +116 -0
- package/dist/brain/anthropic.d.ts +19 -0
- package/dist/brain/anthropic.d.ts.map +1 -0
- package/dist/brain/anthropic.js +74 -0
- package/dist/brain/claude-cli.d.ts +20 -0
- package/dist/brain/claude-cli.d.ts.map +1 -0
- package/dist/brain/claude-cli.js +79 -0
- package/dist/brain/ghost-ember.d.ts +28 -0
- package/dist/brain/ghost-ember.d.ts.map +1 -0
- package/dist/brain/ghost-ember.js +97 -0
- package/dist/brain/index.d.ts +22 -0
- package/dist/brain/index.d.ts.map +1 -0
- package/dist/brain/index.js +95 -0
- package/dist/brain/openai-compat.d.ts +21 -0
- package/dist/brain/openai-compat.d.ts.map +1 -0
- package/dist/brain/openai-compat.js +119 -0
- package/dist/brain/router/classify.d.ts +23 -0
- package/dist/brain/router/classify.d.ts.map +1 -0
- package/dist/brain/router/classify.js +160 -0
- package/dist/brain/router/execute.d.ts +23 -0
- package/dist/brain/router/execute.d.ts.map +1 -0
- package/dist/brain/router/execute.js +84 -0
- package/dist/brain/router/index.d.ts +26 -0
- package/dist/brain/router/index.d.ts.map +1 -0
- package/dist/brain/router/index.js +118 -0
- package/dist/brain/router/routing-memory.d.ts +27 -0
- package/dist/brain/router/routing-memory.d.ts.map +1 -0
- package/dist/brain/router/routing-memory.js +77 -0
- package/dist/brain/router/select.d.ts +32 -0
- package/dist/brain/router/select.d.ts.map +1 -0
- package/dist/brain/router/select.js +146 -0
- package/dist/brain/router/two-hop.d.ts +23 -0
- package/dist/brain/router/two-hop.d.ts.map +1 -0
- package/dist/brain/router/two-hop.js +39 -0
- package/dist/brain/router/verify.d.ts +37 -0
- package/dist/brain/router/verify.d.ts.map +1 -0
- package/dist/brain/router/verify.js +111 -0
- package/dist/brain/types.d.ts +55 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +16 -0
- package/dist/brain/worker.d.ts +27 -0
- package/dist/brain/worker.d.ts.map +1 -0
- package/dist/brain/worker.js +71 -0
- package/dist/commands/ai.d.ts +24 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +137 -0
- package/dist/commands/alerts.d.ts +19 -0
- package/dist/commands/alerts.d.ts.map +1 -0
- package/dist/commands/alerts.js +114 -0
- package/dist/commands/billing.d.ts +13 -0
- package/dist/commands/billing.d.ts.map +1 -0
- package/dist/commands/billing.js +55 -0
- package/dist/commands/chat.d.ts +22 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +422 -0
- package/dist/commands/config.d.ts +18 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +136 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/global.d.ts +11 -0
- package/dist/commands/global.d.ts.map +1 -0
- package/dist/commands/global.js +253 -0
- package/dist/commands/keep.d.ts +12 -0
- package/dist/commands/keep.d.ts.map +1 -0
- package/dist/commands/keep.js +58 -0
- package/dist/commands/lifecycle.d.ts +17 -0
- package/dist/commands/lifecycle.d.ts.map +1 -0
- package/dist/commands/lifecycle.js +267 -0
- package/dist/commands/login.d.ts +16 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +234 -0
- package/dist/commands/maintenance.d.ts +12 -0
- package/dist/commands/maintenance.d.ts.map +1 -0
- package/dist/commands/maintenance.js +76 -0
- package/dist/commands/mcp.d.ts +16 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +56 -0
- package/dist/commands/memory.d.ts +13 -0
- package/dist/commands/memory.d.ts.map +1 -0
- package/dist/commands/memory.js +218 -0
- package/dist/commands/osint.d.ts +14 -0
- package/dist/commands/osint.d.ts.map +1 -0
- package/dist/commands/osint.js +161 -0
- package/dist/commands/pentest.d.ts +13 -0
- package/dist/commands/pentest.d.ts.map +1 -0
- package/dist/commands/pentest.js +131 -0
- package/dist/commands/scale.d.ts +14 -0
- package/dist/commands/scale.d.ts.map +1 -0
- package/dist/commands/scale.js +191 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +167 -0
- package/dist/commands/tui.d.ts +17 -0
- package/dist/commands/tui.d.ts.map +1 -0
- package/dist/commands/tui.js +138 -0
- package/dist/commands/wyrm.d.ts +20 -0
- package/dist/commands/wyrm.d.ts.map +1 -0
- package/dist/commands/wyrm.js +274 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +83 -0
- package/dist/ui.d.ts +57 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +174 -0
- package/dist/utils.d.ts +33 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +155 -0
- package/dist/wyrm/mcp.d.ts +37 -0
- package/dist/wyrm/mcp.d.ts.map +1 -0
- package/dist/wyrm/mcp.js +137 -0
- package/docs/SYSTEM-PREMORTEM.md +397 -0
- package/dragon-manifest.toml +241 -0
- package/dragon.py +177 -0
- package/install/launchd/lk.ghosts.dragonkeep.plist +57 -0
- package/install/systemd/dragonkeep.service +40 -0
- package/media/dragon-silver-lockup.svg +931 -0
- package/media/dragon-silver-mark.svg +931 -0
- package/media/dragon-silver.png +0 -0
- package/package.json +45 -0
- package/specs/001-godmode/constitution.md +54 -0
- package/specs/001-godmode/plan.md +30 -0
- package/specs/001-godmode/spec.md +64 -0
- package/specs/001-godmode/tasks.md +35 -0
- package/specs/002-premortem-positioning/premortem.md +211 -0
- package/src/agent/loop.ts +165 -0
- package/src/agent/mcp.ts +92 -0
- package/src/agent/session.ts +48 -0
- package/src/agent/skills.ts +138 -0
- package/src/agent/stack.ts +154 -0
- package/src/agent/task.ts +55 -0
- package/src/agent/tools.ts +255 -0
- package/src/agent/trace.ts +76 -0
- package/src/agent.ts +114 -0
- package/src/auth.ts +133 -0
- package/src/brain/anthropic.ts +83 -0
- package/src/brain/claude-cli.ts +78 -0
- package/src/brain/ghost-ember.ts +94 -0
- package/src/brain/index.ts +99 -0
- package/src/brain/openai-compat.ts +115 -0
- package/src/brain/router/classify.ts +167 -0
- package/src/brain/router/execute.ts +80 -0
- package/src/brain/router/index.ts +125 -0
- package/src/brain/router/routing-memory.ts +71 -0
- package/src/brain/router/select.ts +156 -0
- package/src/brain/router/two-hop.ts +62 -0
- package/src/brain/router/verify.ts +123 -0
- package/src/brain/types.ts +61 -0
- package/src/brain/worker.ts +72 -0
- package/src/commands/ai.ts +144 -0
- package/src/commands/alerts.ts +131 -0
- package/src/commands/billing.ts +59 -0
- package/src/commands/chat.ts +318 -0
- package/src/commands/config.ts +137 -0
- package/src/commands/doctor.ts +71 -0
- package/src/commands/global.ts +256 -0
- package/src/commands/keep.ts +67 -0
- package/src/commands/lifecycle.ts +273 -0
- package/src/commands/login.ts +184 -0
- package/src/commands/maintenance.ts +54 -0
- package/src/commands/mcp.ts +57 -0
- package/src/commands/memory.ts +229 -0
- package/src/commands/osint.ts +171 -0
- package/src/commands/pentest.ts +140 -0
- package/src/commands/scale.ts +185 -0
- package/src/commands/serve.ts +171 -0
- package/src/commands/tui.ts +126 -0
- package/src/commands/wyrm.ts +269 -0
- package/src/config.ts +93 -0
- package/src/index.ts +92 -0
- package/src/manifest.ts +104 -0
- package/src/ui.ts +188 -0
- package/src/utils.ts +153 -0
- package/src/wyrm/mcp.ts +130 -0
- package/test/auth.test.ts +70 -0
- package/test/brain.test.ts +39 -0
- package/test/security.test.ts +104 -0
- package/test/skills.test.ts +38 -0
- package/test/ui.test.ts +46 -0
- package/tsconfig.json +19 -0
- package/worker/package-lock.json +1527 -0
- package/worker/package.json +17 -0
- package/worker/src/index.ts +76 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local coding tools — the agent's hands. These run on THIS machine (that's the
|
|
3
|
+
* whole point of a CLI coding agent vs the hosted Worker assistant): read/write/
|
|
4
|
+
* edit files, list/glob/grep the tree, run shell commands.
|
|
5
|
+
*
|
|
6
|
+
* Mutating tools (write_file, edit_file, bash) pass through an approval gate so
|
|
7
|
+
* nothing touches the disk or runs a command without a yes — unless the session
|
|
8
|
+
* is in auto-approve. Read-only tools (read_file, list_dir, glob, grep) run free.
|
|
9
|
+
*
|
|
10
|
+
* grep/glob shell out to ripgrep (present on this box) for speed + .gitignore
|
|
11
|
+
* awareness; output is capped so a huge result can't blow the context window.
|
|
12
|
+
*
|
|
13
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, readdirSync, existsSync, realpathSync } from 'node:fs'
|
|
18
|
+
import { resolve, dirname, isAbsolute, join, relative } from 'node:path'
|
|
19
|
+
import type { ToolSpec } from '../brain/types.js'
|
|
20
|
+
|
|
21
|
+
const MAX_OUTPUT = 30_000 // chars fed back to the model per tool result
|
|
22
|
+
const MAX_READ_BYTES = 200_000
|
|
23
|
+
|
|
24
|
+
export interface ToolContext {
|
|
25
|
+
cwd: string
|
|
26
|
+
/** Returns true to proceed. `dangerous` actions (bash, anything outside the
|
|
27
|
+
* working dir) are NEVER covered by --auto — they always prompt. */
|
|
28
|
+
approve: (summary: string, opts?: { detail?: string; dangerous?: boolean }) => Promise<boolean>
|
|
29
|
+
/** When true, run bash inside a bwrap sandbox (cwd writable, rest read-only,
|
|
30
|
+
* credential dirs masked). Filesystem confinement only — network is shared. */
|
|
31
|
+
sandbox?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const BWRAP = ['/usr/bin/bwrap', '/usr/local/bin/bwrap'].find((p) => existsSync(p)) ?? null
|
|
35
|
+
|
|
36
|
+
/** Build the argv for a shell command, optionally wrapped in a bwrap jail. */
|
|
37
|
+
function bashArgv(command: string, cwd: string, sandbox: boolean): { cmd: string; args: string[] } {
|
|
38
|
+
if (sandbox && BWRAP) {
|
|
39
|
+
const home = process.env.HOME || ''
|
|
40
|
+
const mask = ['.ssh', '.aws', '.gnupg', '.dragon', '.config/gh', '.npmrc'].flatMap((d) => ['--tmpfs', `${home}/${d}`])
|
|
41
|
+
return {
|
|
42
|
+
cmd: BWRAP,
|
|
43
|
+
args: ['--ro-bind', '/', '/', '--bind', cwd, cwd, '--tmpfs', '/tmp', '--dev', '/dev', '--proc', '/proc', '--unshare-pid', ...mask, '--chdir', cwd, '--', 'bash', '-c', command],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { cmd: 'bash', args: ['-c', command] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Paths the agent must never touch — credentials/keys — even with approval.
|
|
50
|
+
const PROTECTED = [/(^|\/)\.ssh(\/|$)/, /(^|\/)\.aws(\/|$)/, /(^|\/)\.gnupg(\/|$)/, /(^|\/)\.dragon(\/|$)/, /(^|\/)\.git\/config$/, /\.env(\.[\w-]+)?$/, /id_(rsa|ed25519|ecdsa)/, /\.(pem|key|p12|keystore)$/, /(^|\/)\.(npmrc|netrc|pgpass)$/];
|
|
51
|
+
|
|
52
|
+
// Obviously-catastrophic shell — flagged for a louder confirm (never auto-run).
|
|
53
|
+
const CATASTROPHIC = /\brm\s+-[rf]{1,2}\b[^|]*[\s/~*]|\bmkfs\b|\bdd\s+if=|>\s*\/dev\/(sd|nvme|disk)|:\(\)\s*\{\s*:\s*\|\s*:|(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba)?sh\b|\bchmod\s+-R?\s*777\s+\//i;
|
|
54
|
+
|
|
55
|
+
/** Resolve a path and classify it: inside cwd, outside, or protected (symlink-aware). */
|
|
56
|
+
export function guardPath(cwd: string, p: string): { abs: string; outside: boolean; protectedPath: boolean } {
|
|
57
|
+
const absPath = isAbsolute(p) ? resolve(p) : resolve(cwd, p)
|
|
58
|
+
const protectedPath = PROTECTED.some((re) => re.test(absPath))
|
|
59
|
+
let real = absPath
|
|
60
|
+
try { real = realpathSync(absPath) } catch { /* may not exist yet (new file) */ }
|
|
61
|
+
let root = resolve(cwd)
|
|
62
|
+
try { root = realpathSync(cwd) } catch { /* keep resolved */ }
|
|
63
|
+
const within = (x: string) => x === root || x.startsWith(root + '/')
|
|
64
|
+
const outside = !(within(real) || within(absPath))
|
|
65
|
+
return { abs: absPath, outside, protectedPath }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LocalTool {
|
|
69
|
+
spec: ToolSpec
|
|
70
|
+
mutating: boolean
|
|
71
|
+
summary(args: Record<string, unknown>): string
|
|
72
|
+
run(args: Record<string, unknown>, ctx: ToolContext): Promise<string>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clamp(s: string): string {
|
|
76
|
+
return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `\n… [truncated ${s.length - MAX_OUTPUT} chars]` : s
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function capture(cmd: string, args: string[], opts: { cwd: string; input?: string; timeout?: number }): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
80
|
+
return new Promise((res) => {
|
|
81
|
+
const child = spawn(cmd, args, { cwd: opts.cwd })
|
|
82
|
+
let stdout = ''
|
|
83
|
+
let stderr = ''
|
|
84
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, opts.timeout ?? 120_000)
|
|
85
|
+
child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT * 2) stdout += d })
|
|
86
|
+
child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT) stderr += d })
|
|
87
|
+
child.on('error', (e) => { clearTimeout(timer); res({ stdout, stderr: stderr + String(e), code: 127 }) })
|
|
88
|
+
child.on('close', (code) => { clearTimeout(timer); res({ stdout, stderr, code: code ?? 0 }) })
|
|
89
|
+
if (opts.input !== undefined) { child.stdin.write(opts.input); child.stdin.end() }
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const read_file: LocalTool = {
|
|
94
|
+
mutating: false,
|
|
95
|
+
summary: (a) => `read ${a.path}`,
|
|
96
|
+
spec: {
|
|
97
|
+
name: 'read_file',
|
|
98
|
+
description: 'Read a UTF-8 text file. Returns the content with 1-based line numbers. Use offset/limit for large files.',
|
|
99
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number', description: '1-based start line' }, limit: { type: 'number', description: 'max lines' } }, required: ['path'] },
|
|
100
|
+
},
|
|
101
|
+
async run(a, ctx) {
|
|
102
|
+
const g = guardPath(ctx.cwd, String(a.path))
|
|
103
|
+
if (g.protectedPath) return `error: ${a.path} is a protected path (credentials/keys) — refused`
|
|
104
|
+
if (g.outside && !(await ctx.approve(`read OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: read declined (outside working directory)'
|
|
105
|
+
const p = g.abs
|
|
106
|
+
if (!existsSync(p)) return `error: no such file: ${a.path}`
|
|
107
|
+
const st = statSync(p)
|
|
108
|
+
if (st.isDirectory()) return `error: ${a.path} is a directory (use list_dir)`
|
|
109
|
+
if (st.size > MAX_READ_BYTES) return `error: file too large (${st.size} bytes). Use grep or offset/limit.`
|
|
110
|
+
const lines = readFileSync(p, 'utf-8').split('\n')
|
|
111
|
+
const start = a.offset ? Math.max(1, Number(a.offset)) : 1
|
|
112
|
+
const end = a.limit ? start + Number(a.limit) - 1 : lines.length
|
|
113
|
+
const slice = lines.slice(start - 1, end).map((l, i) => `${String(start + i).padStart(5)}\t${l}`).join('\n')
|
|
114
|
+
return clamp(slice || '(empty file)')
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const write_file: LocalTool = {
|
|
119
|
+
mutating: true,
|
|
120
|
+
summary: (a) => `write ${a.path} (${String(a.content ?? '').length} bytes)`,
|
|
121
|
+
spec: {
|
|
122
|
+
name: 'write_file',
|
|
123
|
+
description: 'Create or overwrite a file with the given content. Creates parent directories. Prefer edit_file for changes to existing files.',
|
|
124
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
125
|
+
},
|
|
126
|
+
async run(a, ctx) {
|
|
127
|
+
const g = guardPath(ctx.cwd, String(a.path))
|
|
128
|
+
if (g.protectedPath) return `error: ${a.path} is a protected path — refused`
|
|
129
|
+
const p = g.abs
|
|
130
|
+
const content = String(a.content ?? '')
|
|
131
|
+
const existed = existsSync(p)
|
|
132
|
+
if (!(await ctx.approve(`${existed ? 'overwrite' : 'create'} ${relative(ctx.cwd, p) || a.path}${g.outside ? ' (OUTSIDE working dir)' : ''}`, { detail: content.slice(0, 800), dangerous: g.outside }))) return 'error: write declined by user'
|
|
133
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
134
|
+
writeFileSync(p, content)
|
|
135
|
+
return `wrote ${content.length} bytes to ${a.path}`
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const edit_file: LocalTool = {
|
|
140
|
+
mutating: true,
|
|
141
|
+
summary: (a) => `edit ${a.path}`,
|
|
142
|
+
spec: {
|
|
143
|
+
name: 'edit_file',
|
|
144
|
+
description: 'Replace an exact string in a file. old_string must match verbatim and be unique unless replace_all is true.',
|
|
145
|
+
parameters: { type: 'object', properties: { path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, required: ['path', 'old_string', 'new_string'] },
|
|
146
|
+
},
|
|
147
|
+
async run(a, ctx) {
|
|
148
|
+
const g = guardPath(ctx.cwd, String(a.path))
|
|
149
|
+
if (g.protectedPath) return `error: ${a.path} is a protected path — refused`
|
|
150
|
+
const p = g.abs
|
|
151
|
+
if (!existsSync(p)) return `error: no such file: ${a.path}`
|
|
152
|
+
const src = readFileSync(p, 'utf-8')
|
|
153
|
+
const oldS = String(a.old_string)
|
|
154
|
+
const count = src.split(oldS).length - 1
|
|
155
|
+
if (count === 0) return `error: old_string not found in ${a.path}`
|
|
156
|
+
if (count > 1 && !a.replace_all) return `error: old_string appears ${count}× — pass replace_all:true or add context to make it unique`
|
|
157
|
+
if (!(await ctx.approve(`edit ${relative(ctx.cwd, p) || a.path}${g.outside ? ' (OUTSIDE working dir)' : ''}`, { detail: `- ${oldS.slice(0, 300)}\n+ ${String(a.new_string).slice(0, 300)}`, dangerous: g.outside }))) return 'error: edit declined by user'
|
|
158
|
+
const next = a.replace_all ? src.split(oldS).join(String(a.new_string)) : src.replace(oldS, String(a.new_string))
|
|
159
|
+
writeFileSync(p, next)
|
|
160
|
+
return `edited ${a.path} (${count} replacement${count > 1 ? 's' : ''})`
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const list_dir: LocalTool = {
|
|
165
|
+
mutating: false,
|
|
166
|
+
summary: (a) => `ls ${a.path ?? '.'}`,
|
|
167
|
+
spec: { name: 'list_dir', description: 'List directory entries with type + size.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: [] } },
|
|
168
|
+
async run(a, ctx) {
|
|
169
|
+
const g = guardPath(ctx.cwd, String(a.path ?? '.'))
|
|
170
|
+
if (g.protectedPath) return `error: protected path — refused`
|
|
171
|
+
if (g.outside && !(await ctx.approve(`list OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
|
|
172
|
+
const p = g.abs
|
|
173
|
+
if (!existsSync(p)) return `error: no such directory: ${a.path}`
|
|
174
|
+
const entries = readdirSync(p, { withFileTypes: true })
|
|
175
|
+
.map((e) => {
|
|
176
|
+
const full = join(p, e.name)
|
|
177
|
+
let size = ''
|
|
178
|
+
try { if (e.isFile()) size = ` ${statSync(full).size}b` } catch {}
|
|
179
|
+
return `${e.isDirectory() ? 'd' : '-'} ${e.name}${e.isDirectory() ? '/' : ''}${size}`
|
|
180
|
+
})
|
|
181
|
+
.sort()
|
|
182
|
+
return clamp(entries.join('\n') || '(empty)')
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const glob: LocalTool = {
|
|
187
|
+
mutating: false,
|
|
188
|
+
summary: (a) => `glob ${a.pattern}`,
|
|
189
|
+
spec: { name: 'glob', description: 'Find files matching a glob (ripgrep, .gitignore-aware). e.g. "src/**/*.ts".', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] } },
|
|
190
|
+
async run(a, ctx) {
|
|
191
|
+
const g = guardPath(ctx.cwd, String(a.path ?? '.'))
|
|
192
|
+
if (g.protectedPath) return `error: protected path — refused`
|
|
193
|
+
if (g.outside && !(await ctx.approve(`glob OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
|
|
194
|
+
const dir = g.abs
|
|
195
|
+
const { stdout, stderr, code } = await capture('rg', ['--files', '-g', String(a.pattern), dir], { cwd: ctx.cwd })
|
|
196
|
+
if (code !== 0 && !stdout) return stderr.trim() ? `no matches (${stderr.trim().slice(0, 200)})` : 'no matches'
|
|
197
|
+
return clamp(stdout.split('\n').filter(Boolean).map((f) => relative(ctx.cwd, f) || f).join('\n') || 'no matches')
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const grep: LocalTool = {
|
|
202
|
+
mutating: false,
|
|
203
|
+
summary: (a) => `grep "${a.pattern}"`,
|
|
204
|
+
spec: {
|
|
205
|
+
name: 'grep',
|
|
206
|
+
description: 'Search file contents with a regex (ripgrep). Returns file:line:match. Use glob to scope by file type.',
|
|
207
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, ignore_case: { type: 'boolean' } }, required: ['pattern'] },
|
|
208
|
+
},
|
|
209
|
+
async run(a, ctx) {
|
|
210
|
+
const g = guardPath(ctx.cwd, String(a.path ?? '.'))
|
|
211
|
+
if (g.protectedPath) return `error: protected path — refused`
|
|
212
|
+
if (g.outside && !(await ctx.approve(`grep OUTSIDE the working dir: ${a.path}`, { dangerous: true }))) return 'error: declined (outside working directory)'
|
|
213
|
+
const args = ['--line-number', '--no-heading', '--color', 'never']
|
|
214
|
+
if (a.ignore_case) args.push('-i')
|
|
215
|
+
if (a.glob) args.push('-g', String(a.glob))
|
|
216
|
+
args.push('--', String(a.pattern), g.abs)
|
|
217
|
+
const { stdout, stderr, code } = await capture('rg', args, { cwd: ctx.cwd })
|
|
218
|
+
if (code === 1) return 'no matches'
|
|
219
|
+
if (code > 1) return `error: ${stderr.trim().slice(0, 300)}`
|
|
220
|
+
return clamp(stdout || 'no matches')
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const bash: LocalTool = {
|
|
225
|
+
mutating: true,
|
|
226
|
+
summary: (a) => `bash: ${String(a.command).slice(0, 70)}`,
|
|
227
|
+
spec: {
|
|
228
|
+
name: 'bash',
|
|
229
|
+
description: 'Run a shell command in the working directory and return stdout+stderr+exit code. Use for builds, tests, git, installs. Avoid destructive commands.',
|
|
230
|
+
parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] },
|
|
231
|
+
},
|
|
232
|
+
async run(a, ctx) {
|
|
233
|
+
const command = String(a.command)
|
|
234
|
+
const danger = CATASTROPHIC.test(command)
|
|
235
|
+
const summary = danger ? `⚠ DANGEROUS shell — ${command.slice(0, 80)}` : `run: ${command.slice(0, 80)}`
|
|
236
|
+
// bash is ALWAYS dangerous → never covered by --auto; the full command is shown.
|
|
237
|
+
if (!(await ctx.approve(`${ctx.sandbox && BWRAP ? '[sandboxed] ' : ''}${summary}`, { detail: command, dangerous: true }))) return 'error: command declined by user'
|
|
238
|
+
const { cmd, args } = bashArgv(command, ctx.cwd, !!ctx.sandbox)
|
|
239
|
+
const { stdout, stderr, code } = await capture(cmd, args, { cwd: ctx.cwd, timeout: a.timeout_ms ? Number(a.timeout_ms) : 120_000 })
|
|
240
|
+
const tag = ctx.sandbox ? (BWRAP ? '[sandboxed] ' : '[sandbox unavailable — bwrap not found] ') : ''
|
|
241
|
+
const body = [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`].filter(Boolean).join('\n')
|
|
242
|
+
return clamp(`${tag}exit ${code}\n${body || '(no output)'}`)
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const LOCAL_TOOLS: LocalTool[] = [read_file, write_file, edit_file, list_dir, glob, grep, bash]
|
|
247
|
+
const BY_NAME = new Map(LOCAL_TOOLS.map((t) => [t.spec.name, t]))
|
|
248
|
+
|
|
249
|
+
export function localToolSpecs(): ToolSpec[] {
|
|
250
|
+
return LOCAL_TOOLS.map((t) => t.spec)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function getLocalTool(name: string): LocalTool | undefined {
|
|
254
|
+
return BY_NAME.get(name)
|
|
255
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flywheel — emit a tool-use trace per completed agent turn to ~/.dragon/traces/.
|
|
3
|
+
*
|
|
4
|
+
* These traces are the training data for DragonSpark (our own nano brain): the agent's
|
|
5
|
+
* real work becomes the dataset that makes its next model better. DragonSpark's data
|
|
6
|
+
* pipeline does a mandatory secret/customer scrub before any training; we also do a
|
|
7
|
+
* light redaction here at emit time as defense-in-depth.
|
|
8
|
+
*
|
|
9
|
+
* On by default; disable with `--no-trace` or DRAGON_NO_TRACE=1. Local-only — nothing
|
|
10
|
+
* is uploaded; the file just sits on the operator's machine for the DragonSpark repo
|
|
11
|
+
* to ingest (src/dragonspark/traces.py).
|
|
12
|
+
*
|
|
13
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import type { BrainMessage } from '../brain/types.js'
|
|
20
|
+
|
|
21
|
+
export const TRACE_DIR = join(homedir(), '.dragon', 'traces')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Strip secrets before anything touches disk. Covers known key prefixes
|
|
25
|
+
* (sk-, sk-ant-, ghp_, xoxb-, AKIA, dgn_, JWT), cookie + authorization values,
|
|
26
|
+
* and any long opaque blob. Defense-in-depth for traces that may carry tool output.
|
|
27
|
+
*/
|
|
28
|
+
export function redact(s: string): string {
|
|
29
|
+
if (!s) return s
|
|
30
|
+
return s
|
|
31
|
+
.replace(/\b(?:sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]+|gho_[A-Za-z0-9]+|xox[baprs]-[A-Za-z0-9-]+|AKIA[A-Z0-9]{16}|dgn_[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, '[redacted]')
|
|
32
|
+
.replace(/(gp_session=)[^;\s"']+/gi, '$1[redacted]')
|
|
33
|
+
.replace(/(authorization["']?\s*[:=]\s*["']?(?:bearer\s+)?)[^\s"',}]+/gi, '$1[redacted]')
|
|
34
|
+
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[redacted]')
|
|
35
|
+
.replace(/[A-Za-z0-9+/_-]{40,}={0,2}/g, '[redacted]')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function traceEnabled(disabledFlag?: boolean): boolean {
|
|
39
|
+
return !disabledFlag && !process.env.DRAGON_NO_TRACE
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record one settled turn. `added` is the slice of messages appended during the turn
|
|
44
|
+
* (user → [assistant/tool]* → final assistant). Never throws — a trace failure must
|
|
45
|
+
* not break the session.
|
|
46
|
+
*/
|
|
47
|
+
export function recordTurn(added: BrainMessage[], meta: { cwd: string; brain: string; context?: string | null }): void {
|
|
48
|
+
try {
|
|
49
|
+
const user = added.find((m) => m.role === 'user')?.content ?? ''
|
|
50
|
+
const tool_calls = added
|
|
51
|
+
.filter((m) => m.role === 'assistant')
|
|
52
|
+
.flatMap((m) => (m.toolCalls ?? []).map((t) => ({ name: t.name, arguments: redact(JSON.stringify(t.arguments ?? {})).slice(0, 2000) })))
|
|
53
|
+
const tool_results = added
|
|
54
|
+
.filter((m) => m.role === 'tool')
|
|
55
|
+
.map((m) => ({ name: m.toolName ?? '', result: redact(m.content).slice(0, 4000) }))
|
|
56
|
+
const finals = added.filter((m) => m.role === 'assistant' && m.content)
|
|
57
|
+
const final_answer = finals.length ? finals[finals.length - 1].content : ''
|
|
58
|
+
|
|
59
|
+
const rec = {
|
|
60
|
+
ts: new Date().toISOString(),
|
|
61
|
+
cwd: meta.cwd,
|
|
62
|
+
brain: meta.brain,
|
|
63
|
+
prompt: redact(user).slice(0, 8000),
|
|
64
|
+
retrieved_wyrm_context: meta.context ? redact(meta.context).slice(0, 4000) : undefined,
|
|
65
|
+
tool_calls,
|
|
66
|
+
tool_results,
|
|
67
|
+
final_answer: redact(final_answer).slice(0, 8000),
|
|
68
|
+
}
|
|
69
|
+
mkdirSync(TRACE_DIR, { recursive: true, mode: 0o700 })
|
|
70
|
+
const file = join(TRACE_DIR, `${rec.ts.slice(0, 10)}.jsonl`)
|
|
71
|
+
appendFileSync(file, JSON.stringify(rec) + '\n', { mode: 0o600 })
|
|
72
|
+
chmodSync(file, 0o600) // appendFileSync mode only applies on create — enforce on existing too
|
|
73
|
+
} catch {
|
|
74
|
+
/* never break a session over a trace write */
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the Dragon assistant — account.ghosts.lk `POST /api/v1/agent`.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint runs a server-side tool-calling loop, then streams the answer as
|
|
5
|
+
* SSE. Two frame kinds arrive on the `data:` channel:
|
|
6
|
+
* - `{"tools":[{"name","ok"}]}` — once, up front, if tools ran (→ onTools)
|
|
7
|
+
* - `{"response":"…"}` — token deltas of the natural-language answer
|
|
8
|
+
* - `[DONE]` — terminator
|
|
9
|
+
*
|
|
10
|
+
* Tools execute server-side and role-gated; the client only DISPLAYS which ran.
|
|
11
|
+
* Request body is `{ messages:[{role,content}], surface }` — the server keeps
|
|
12
|
+
* only user/assistant turns and prepends its own system prompt + memory.
|
|
13
|
+
*
|
|
14
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { resolveAuth } from './auth.js'
|
|
18
|
+
|
|
19
|
+
export interface AgentMsg { role: 'user' | 'assistant'; content: string }
|
|
20
|
+
export interface ToolChip { name: string; ok: boolean }
|
|
21
|
+
|
|
22
|
+
/** Surfaces the server understands — they scope the tool set + system context. */
|
|
23
|
+
export const SURFACES = ['dashboard', 'admin', 'chat', 'activity', 'marketing'] as const
|
|
24
|
+
export type Surface = (typeof SURFACES)[number]
|
|
25
|
+
|
|
26
|
+
export type AgentErrorKind = 'unauthenticated' | 'quota' | 'unavailable' | 'http' | 'network'
|
|
27
|
+
|
|
28
|
+
export class AgentError extends Error {
|
|
29
|
+
constructor(public kind: AgentErrorKind, message: string, public detail?: unknown) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'AgentError'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StreamAgentOpts {
|
|
36
|
+
messages: AgentMsg[]
|
|
37
|
+
surface: string
|
|
38
|
+
onDelta: (s: string) => void
|
|
39
|
+
onTools?: (tools: ToolChip[]) => void
|
|
40
|
+
signal?: AbortSignal
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stream one assistant turn. Emits tool chips (if any) then token deltas, and
|
|
45
|
+
* returns the assembled answer when `[DONE]` arrives. Throws AgentError on
|
|
46
|
+
* 401 (unauthenticated), 429 (quota), 502 (model down), or transport failure.
|
|
47
|
+
*/
|
|
48
|
+
export async function streamAgent(opts: StreamAgentOpts): Promise<string> {
|
|
49
|
+
const { apiBase, headers, mode } = resolveAuth()
|
|
50
|
+
|
|
51
|
+
let res: Response
|
|
52
|
+
try {
|
|
53
|
+
res = await fetch(`${apiBase}/api/v1/agent`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
56
|
+
body: JSON.stringify({ messages: opts.messages, surface: opts.surface }),
|
|
57
|
+
signal: opts.signal,
|
|
58
|
+
})
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if ((e as { name?: string })?.name === 'AbortError') throw e
|
|
61
|
+
throw new AgentError('network', `cannot reach ${apiBase}: ${String(e instanceof Error ? e.message : e)}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (res.status === 401) {
|
|
65
|
+
const b = (await res.json().catch(() => ({}))) as { login_url?: string }
|
|
66
|
+
const hint = mode === 'none'
|
|
67
|
+
? 'no credentials configured — run `dragon login`'
|
|
68
|
+
: 'your session is invalid or expired — run `dragon login` again'
|
|
69
|
+
throw new AgentError('unauthenticated', hint, b)
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 429) {
|
|
72
|
+
const b = (await res.json().catch(() => ({}))) as { used?: number; cap?: number; reset_at?: number }
|
|
73
|
+
const reset = b.reset_at ? new Date(b.reset_at).toLocaleString() : '—'
|
|
74
|
+
throw new AgentError('quota', `daily quota reached (${b.used ?? '?'} / ${b.cap ?? '?'} tokens). Resets ${reset}.`, b)
|
|
75
|
+
}
|
|
76
|
+
if (res.status === 502) {
|
|
77
|
+
throw new AgentError('unavailable', 'the model is momentarily unavailable — try again in a moment.')
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok || !res.body) {
|
|
80
|
+
const t = await res.text().catch(() => res.statusText)
|
|
81
|
+
throw new AgentError('http', `HTTP ${res.status}: ${t}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const reader = res.body.getReader()
|
|
85
|
+
const decoder = new TextDecoder()
|
|
86
|
+
let buffer = ''
|
|
87
|
+
let assembled = ''
|
|
88
|
+
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { value, done } = await reader.read()
|
|
91
|
+
if (done) break
|
|
92
|
+
buffer += decoder.decode(value, { stream: true })
|
|
93
|
+
const lines = buffer.split('\n')
|
|
94
|
+
buffer = lines.pop() ?? '' // hold the partial trailing line
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (!line.startsWith('data: ')) continue
|
|
98
|
+
const data = line.slice(6)
|
|
99
|
+
if (data === '[DONE]') return assembled
|
|
100
|
+
try {
|
|
101
|
+
const obj = JSON.parse(data) as { response?: string; tools?: { name: string; ok?: boolean }[] }
|
|
102
|
+
if (Array.isArray(obj.tools)) {
|
|
103
|
+
opts.onTools?.(obj.tools.map((t) => ({ name: String(t.name), ok: t.ok !== false })))
|
|
104
|
+
} else if (typeof obj.response === 'string' && obj.response) {
|
|
105
|
+
assembled += obj.response
|
|
106
|
+
opts.onDelta(obj.response)
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
/* skip a malformed frame */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return assembled
|
|
114
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth for the Dragon assistant (account.ghosts.lk /api/v1/agent).
|
|
3
|
+
*
|
|
4
|
+
* The assistant is account-scoped: it answers AS the signed-in user and gates
|
|
5
|
+
* its tools by role. The browser authenticates with the `gp_session` HttpOnly
|
|
6
|
+
* cookie minted by Google/GitHub OAuth. A CLI has no browser, so we support two
|
|
7
|
+
* credential carriers, in priority order:
|
|
8
|
+
*
|
|
9
|
+
* 1. Bearer token — `DRAGON_TOKEN` env or `auth.token` in config. The primary
|
|
10
|
+
* path: a 90-day personal access token (`dgn_…`) minted by `dragon login`'s
|
|
11
|
+
* browser device-code flow. Backend is live on account.ghosts.lk.
|
|
12
|
+
* 2. Session cookie — `DRAGON_SESSION` env or `auth.session` in config. The
|
|
13
|
+
* `gp_session` value copied from a signed-in browser. The `--paste` fallback
|
|
14
|
+
* for headless boxes where the device flow can't open a browser.
|
|
15
|
+
*
|
|
16
|
+
* Resolution is env-first so a shell can override the stored credential without
|
|
17
|
+
* touching the config file.
|
|
18
|
+
*
|
|
19
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { loadConfig, saveConfig, type DragonConfig } from './config.js'
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_API = 'https://account.ghosts.lk'
|
|
25
|
+
|
|
26
|
+
// Ports browsers refuse to open (ERR_UNSAFE_PORT / "this address is restricted").
|
|
27
|
+
// We guard against ever handing one of these to a browser or using it as an origin.
|
|
28
|
+
const RESTRICTED_PORTS = new Set([
|
|
29
|
+
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
|
|
30
|
+
101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161,
|
|
31
|
+
179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563,
|
|
32
|
+
587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061,
|
|
33
|
+
6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080,
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** True if `raw` is a URL a browser will actually open: http(s), a sane port, and
|
|
37
|
+
* https for any non-loopback host (no plaintext auth over the network). */
|
|
38
|
+
export function isBrowsableHttpUrl(raw: string): boolean {
|
|
39
|
+
let u: URL
|
|
40
|
+
try { u = new URL(raw) } catch { return false }
|
|
41
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
42
|
+
const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1'
|
|
43
|
+
if (!isLocal && u.protocol !== 'https:') return false
|
|
44
|
+
const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80
|
|
45
|
+
return !RESTRICTED_PORTS.has(port)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A trustworthy assistant origin, self-healing: a malformed/restricted-port value
|
|
49
|
+
* (e.g. a stale config) silently falls back to the default instead of breaking login. */
|
|
50
|
+
export function sanitizeApiBase(raw?: string): string {
|
|
51
|
+
const trimmed = (raw ?? '').replace(/\/+$/, '')
|
|
52
|
+
return trimmed && isBrowsableHttpUrl(trimmed) ? trimmed : DEFAULT_API
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AuthMode = 'token' | 'session' | 'none'
|
|
56
|
+
|
|
57
|
+
export interface ResolvedAuth {
|
|
58
|
+
apiBase: string
|
|
59
|
+
headers: Record<string, string>
|
|
60
|
+
mode: AuthMode
|
|
61
|
+
email?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Credential values become HTTP header content — accept only printable, space-free
|
|
65
|
+
* ASCII so a CR/LF or control char can't smuggle extra headers. */
|
|
66
|
+
export function validCred(v?: string): string | undefined {
|
|
67
|
+
return v && /^[\x21-\x7e]+$/.test(v) ? v : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The credential-bearing env vars auth reads — extracted so the precedence
|
|
71
|
+
* logic is unit-testable without a real shell, config file, or second machine. */
|
|
72
|
+
export interface AuthEnv {
|
|
73
|
+
DRAGON_API?: string
|
|
74
|
+
DRAGON_TOKEN?: string
|
|
75
|
+
DRAGON_SESSION?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pure auth resolution: env-first, then config, behind the cred-injection
|
|
80
|
+
* (`validCred`) and restricted-origin (`sanitizeApiBase`) guards. Token beats
|
|
81
|
+
* session beats none. `resolveAuth()` is just this fed the real env + config —
|
|
82
|
+
* keeping the decision logic deterministic and machine-independent for tests.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveAuthFrom(env: AuthEnv, cfg: DragonConfig): ResolvedAuth {
|
|
85
|
+
const apiBase = sanitizeApiBase(env.DRAGON_API || cfg.auth?.apiBase)
|
|
86
|
+
const token = validCred(env.DRAGON_TOKEN || cfg.auth?.token)
|
|
87
|
+
const session = validCred(env.DRAGON_SESSION || cfg.auth?.session)
|
|
88
|
+
const email = cfg.auth?.email
|
|
89
|
+
if (token) return { apiBase, headers: { authorization: `Bearer ${token}` }, mode: 'token', email }
|
|
90
|
+
if (session) return { apiBase, headers: { cookie: `gp_session=${session}` }, mode: 'session', email }
|
|
91
|
+
return { apiBase, headers: {}, mode: 'none', email }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolve the assistant origin + credential headers from env then config. */
|
|
95
|
+
export function resolveAuth(): ResolvedAuth {
|
|
96
|
+
return resolveAuthFrom(process.env, loadConfig())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Merge a patch into config.auth and persist. */
|
|
100
|
+
export function saveAuth(patch: Partial<NonNullable<DragonConfig['auth']>>): void {
|
|
101
|
+
const cfg = loadConfig()
|
|
102
|
+
cfg.auth = { ...(cfg.auth ?? {}), ...patch }
|
|
103
|
+
saveConfig(cfg)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Forget all stored credentials (keeps a custom apiBase if set). */
|
|
107
|
+
export function clearAuth(): void {
|
|
108
|
+
const cfg = loadConfig()
|
|
109
|
+
const apiBase = cfg.auth?.apiBase
|
|
110
|
+
cfg.auth = apiBase ? { apiBase } : {}
|
|
111
|
+
saveConfig(cfg)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface WhoAmI { ok: boolean; status: number; email?: string; error?: string }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate the current (or a supplied) credential against the cheap authed
|
|
118
|
+
* endpoint `GET /api/v1/me/licenses` — no LLM cost. Returns the identity email
|
|
119
|
+
* on success so the caller can confirm + store who we signed in as.
|
|
120
|
+
*/
|
|
121
|
+
export async function whoami(override?: { headers?: Record<string, string>; apiBase?: string }): Promise<WhoAmI> {
|
|
122
|
+
const base = override?.apiBase ?? resolveAuth().apiBase
|
|
123
|
+
const headers = override?.headers ?? resolveAuth().headers
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`${base}/api/v1/me/licenses`, { headers })
|
|
126
|
+
if (res.status === 401) return { ok: false, status: 401, error: 'not signed in' }
|
|
127
|
+
if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` }
|
|
128
|
+
const body = (await res.json().catch(() => ({}))) as { account?: { email?: string } }
|
|
129
|
+
return { ok: true, status: 200, email: body.account?.email }
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, status: 0, error: String(e instanceof Error ? e.message : e) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude brain — Anthropic Messages API via the official SDK. The default,
|
|
3
|
+
* "amazing at programming" path.
|
|
4
|
+
*
|
|
5
|
+
* Maps our normalized BrainMessage[] onto Anthropic's content-block format:
|
|
6
|
+
* - assistant tool calls → `tool_use` blocks
|
|
7
|
+
* - tool results → `tool_result` blocks (folded into a user message;
|
|
8
|
+
* consecutive tool messages are batched into one user turn, as the API wants)
|
|
9
|
+
* Streams `text` deltas and collects `tool_use` blocks from the final message.
|
|
10
|
+
*
|
|
11
|
+
* Copyright 2026 Ghost Protocol (Pvt) Ltd. All Rights Reserved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
15
|
+
import type { Brain, BrainMessage, BrainTurn, ToolSpec, TurnOpts } from './types.js'
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
|
|
18
|
+
|
|
19
|
+
type ABlock =
|
|
20
|
+
| { type: 'text'; text: string }
|
|
21
|
+
| { type: 'tool_use'; id: string; name: string; input: unknown }
|
|
22
|
+
| { type: 'tool_result'; tool_use_id: string; content: string }
|
|
23
|
+
|
|
24
|
+
interface AMessage { role: 'user' | 'assistant'; content: ABlock[] }
|
|
25
|
+
|
|
26
|
+
/** Fold our flat BrainMessage[] into Anthropic's role-batched content blocks. */
|
|
27
|
+
function toAnthropic(messages: BrainMessage[]): AMessage[] {
|
|
28
|
+
const out: AMessage[] = []
|
|
29
|
+
for (const m of messages) {
|
|
30
|
+
if (m.role === 'tool') {
|
|
31
|
+
const block: ABlock = { type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: m.content }
|
|
32
|
+
const last = out[out.length - 1]
|
|
33
|
+
if (last && last.role === 'user' && last.content.every((b) => b.type === 'tool_result')) last.content.push(block)
|
|
34
|
+
else out.push({ role: 'user', content: [block] })
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (m.role === 'assistant') {
|
|
38
|
+
const blocks: ABlock[] = []
|
|
39
|
+
if (m.content) blocks.push({ type: 'text', text: m.content })
|
|
40
|
+
for (const tc of m.toolCalls ?? []) blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments })
|
|
41
|
+
out.push({ role: 'assistant', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
// user
|
|
45
|
+
out.push({ role: 'user', content: [{ type: 'text', text: m.content }] })
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toAnthropicTools(tools: ToolSpec[]) {
|
|
51
|
+
return tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters as Anthropic.Tool.InputSchema }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function makeClaudeBrain(opts: { apiKey: string; model?: string }): Brain {
|
|
55
|
+
const client = new Anthropic({ apiKey: opts.apiKey })
|
|
56
|
+
const model = opts.model || DEFAULT_CLAUDE_MODEL
|
|
57
|
+
return {
|
|
58
|
+
id: 'claude',
|
|
59
|
+
model,
|
|
60
|
+
async turn(t: TurnOpts): Promise<BrainTurn> {
|
|
61
|
+
const stream = client.messages.stream(
|
|
62
|
+
{
|
|
63
|
+
model,
|
|
64
|
+
max_tokens: t.maxTokens ?? 8192,
|
|
65
|
+
system: t.system,
|
|
66
|
+
messages: toAnthropic(t.messages) as Anthropic.MessageParam[],
|
|
67
|
+
tools: t.tools.length ? toAnthropicTools(t.tools) : undefined,
|
|
68
|
+
},
|
|
69
|
+
{ signal: t.signal },
|
|
70
|
+
)
|
|
71
|
+
if (t.onDelta) stream.on('text', (delta) => t.onDelta!(delta))
|
|
72
|
+
const final = await stream.finalMessage()
|
|
73
|
+
|
|
74
|
+
let text = ''
|
|
75
|
+
const toolCalls: BrainTurn['toolCalls'] = []
|
|
76
|
+
for (const block of final.content) {
|
|
77
|
+
if (block.type === 'text') text += block.text
|
|
78
|
+
else if (block.type === 'tool_use') toolCalls.push({ id: block.id, name: block.name, arguments: (block.input ?? {}) as Record<string, unknown> })
|
|
79
|
+
}
|
|
80
|
+
return { text, toolCalls }
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|