pi-code 0.2.2 → 0.2.4
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/README.md +4 -1
- package/extensions/mcp.ts +13 -18
- package/extensions/subagent/agents.ts +0 -10
- package/extensions/web-transport.ts +51 -0
- package/extensions/web.ts +30 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,8 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
41
41
|
| Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
|
|
42
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
43
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
|
|
44
|
-
| MCP servers | `~/.claude.json`, `~/.pi/agent/mcp.json
|
|
44
|
+
| MCP servers | user `~/.claude.json`, `~/.pi/agent/mcp.json` (loaded on session start); project `.mcp.json`, `.pi/mcp.json` (only once the project is approved); stdio, HTTP, SSE | `mcp.ts` |
|
|
45
|
+
| Project trust | prompts before loading project config (MCP servers, hooks, agents) that pi would otherwise trust silently | `project-approval.ts` |
|
|
45
46
|
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
46
47
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
47
48
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
@@ -54,6 +55,8 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
54
55
|
|
|
55
56
|
`CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
|
|
56
57
|
|
|
58
|
+
`output-guard.ts` and `web-transport.ts` are shared internals (context-budget truncation, DNS-pinned fetch) with no feature of their own; the tools above use them.
|
|
59
|
+
|
|
57
60
|
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
58
61
|
|
|
59
62
|
## Development
|
package/extensions/mcp.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Adapter Extension
|
|
3
3
|
*
|
|
4
|
-
* Connects MCP (Model Context Protocol) servers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (streamable with SSE fallback)
|
|
4
|
+
* Connects MCP (Model Context Protocol) servers and registers their tools in pi
|
|
5
|
+
* as `<server>_<tool>`. Connects on `session_start`, not from the factory (pi runs
|
|
6
|
+
* the factory for invocations that never start a session); per-server timeout,
|
|
7
|
+
* failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
|
|
8
|
+
* transports; /mcp shows status.
|
|
8
9
|
*
|
|
9
|
-
* Reads Claude Code's MCP config too.
|
|
10
|
-
* ~/.pi/agent/mcp.json
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Reads Claude Code's MCP config too. User config (~/.claude.json,
|
|
11
|
+
* ~/.pi/agent/mcp.json) is the user's own and loads on the first session. Project
|
|
12
|
+
* config (.mcp.json, .pi/mcp.json) can run arbitrary commands on connect, so it
|
|
13
|
+
* loads only once the project is approved (see project-approval). The two scopes
|
|
14
|
+
* are loaded separately, not merged; user config connects first, so a project
|
|
15
|
+
* server cannot take the name of a user server that connected.
|
|
16
|
+
* Values support ${VAR} interpolation, and a stdio server receives only the SDK's
|
|
17
|
+
* default environment plus its own `env` block, not the whole process environment.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import * as fs from 'node:fs'
|
|
@@ -66,15 +70,6 @@ export function projectConfigPaths(cwd: string): string[] {
|
|
|
66
70
|
return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
/** All config files, later winning: user first, then project. */
|
|
70
|
-
export function configPaths(cwd: string, home: string): string[] {
|
|
71
|
-
return [...userConfigPaths(home), ...projectConfigPaths(cwd)]
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function loadConfig(cwd: string): Record<string, ServerConfig> {
|
|
75
|
-
return loadConfigFrom(configPaths(cwd, os.homedir()))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
73
|
export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
79
74
|
const servers: Record<string, ServerConfig> = {}
|
|
80
75
|
for (const file of files) {
|
|
@@ -159,13 +159,3 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
159
159
|
const agentMap = buildAgentMap(userAgents, projectAgents, scope)
|
|
160
160
|
return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
164
|
-
if (agents.length === 0) return { text: 'none', remaining: 0 }
|
|
165
|
-
const listed = agents.slice(0, maxItems)
|
|
166
|
-
const remaining = agents.length - listed.length
|
|
167
|
-
return {
|
|
168
|
-
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join('; '),
|
|
169
|
-
remaining,
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web transport
|
|
3
|
+
*
|
|
4
|
+
* A single HTTP(S) request pinned to a caller-supplied DNS resolution. Global `fetch`
|
|
5
|
+
* resolves the hostname itself, independently of any prior guard, so a validate-then-fetch
|
|
6
|
+
* SSRF check has a time-of-check/time-of-use gap: a zero-TTL record can answer public to
|
|
7
|
+
* the guard and private to fetch's own lookup. `node:http`/`node:https` accept a `lookup`
|
|
8
|
+
* option, which is the seam that closes the gap: the socket connects to exactly the address
|
|
9
|
+
* the guard validated, while `servername` (SNI, certificate validation) and the `Host`
|
|
10
|
+
* header stay the real hostname, so virtual hosts and TLS still work.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { request as httpRequest } from 'node:http'
|
|
14
|
+
import { request as httpsRequest } from 'node:https'
|
|
15
|
+
import type { LookupFunction } from 'node:net'
|
|
16
|
+
import { Readable } from 'node:stream'
|
|
17
|
+
|
|
18
|
+
export interface TransportOptions {
|
|
19
|
+
signal: AbortSignal
|
|
20
|
+
lookup: LookupFunction
|
|
21
|
+
userAgent: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One request, no redirect following (the caller re-validates and re-pins per hop). */
|
|
25
|
+
export function httpFetch(url: URL, opts: TransportOptions): Promise<Response> {
|
|
26
|
+
const request = url.protocol === 'https:' ? httpsRequest : httpRequest
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const req = request(
|
|
29
|
+
url,
|
|
30
|
+
{
|
|
31
|
+
method: 'GET',
|
|
32
|
+
headers: { 'User-Agent': opts.userAgent },
|
|
33
|
+
signal: opts.signal,
|
|
34
|
+
lookup: opts.lookup,
|
|
35
|
+
// servername is left to default to url.hostname, so SNI and certificate
|
|
36
|
+
// validation use the real host even though the socket connects to the pinned IP.
|
|
37
|
+
},
|
|
38
|
+
(res) => {
|
|
39
|
+
const headers = new Headers()
|
|
40
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
41
|
+
if (typeof value === 'string') headers.set(key, value)
|
|
42
|
+
else if (Array.isArray(value)) headers.set(key, value.join(', '))
|
|
43
|
+
}
|
|
44
|
+
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>
|
|
45
|
+
resolve(new Response(body, { status: res.statusCode ?? 0, headers }))
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
req.on('error', reject)
|
|
49
|
+
req.end()
|
|
50
|
+
})
|
|
51
|
+
}
|
package/extensions/web.ts
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* Honors the local-only setup: no cloud accounts, plain HTTPS to public web.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { LookupAddress } from 'node:dns'
|
|
9
10
|
import { lookup } from 'node:dns/promises'
|
|
11
|
+
import type { LookupFunction } from 'node:net'
|
|
10
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
11
13
|
import { Type } from 'typebox'
|
|
12
14
|
|
|
15
|
+
import { httpFetch } from './web-transport.js'
|
|
16
|
+
|
|
13
17
|
const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
|
|
14
18
|
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
|
|
15
19
|
const MAX_FETCH_CHARS = 30_000
|
|
@@ -128,15 +132,32 @@ export function isPrivateAddress(ip: string): boolean {
|
|
|
128
132
|
return addr.includes(':') ? isPrivateIpv6(addr) : isPrivateIpv4(addr)
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
|
|
135
|
+
/** A lookup that always yields `addresses`, so the socket cannot resolve the host again. */
|
|
136
|
+
export function pinnedLookup(addresses: LookupAddress[]): LookupFunction {
|
|
137
|
+
return (_hostname, options, callback) => {
|
|
138
|
+
const cb = (typeof options === 'function' ? options : callback) as (err: Error | null, address: unknown, family?: number) => void
|
|
139
|
+
if (typeof options !== 'function' && options.all) return cb(null, addresses)
|
|
140
|
+
const [first] = addresses
|
|
141
|
+
cb(null, first.address, first.family)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a host once, reject any private address, and return a lookup pinned to exactly
|
|
147
|
+
* those addresses. Passing that lookup to the transport is what closes the SSRF
|
|
148
|
+
* time-of-check/time-of-use gap: the connection reuses the validated resolution rather
|
|
149
|
+
* than issuing a second, unchecked DNS query that a rebinding record could answer privately.
|
|
150
|
+
*/
|
|
151
|
+
async function resolveAndPin(url: URL): Promise<LookupFunction> {
|
|
132
152
|
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
133
153
|
const addresses = await lookup(host, { all: true, verbatim: true })
|
|
134
|
-
// An empty list would leave nothing
|
|
135
|
-
//
|
|
154
|
+
// An empty list would leave nothing to reject, so the guard would pass vacuously.
|
|
155
|
+
// Schemes without a host (data:, file:) reach here the same way.
|
|
136
156
|
if (addresses.length === 0) throw new Error(`${url.hostname || url.protocol} did not resolve to any address`)
|
|
137
157
|
for (const { address } of addresses) {
|
|
138
158
|
if (isPrivateAddress(address)) throw new Error(`refusing to fetch private/internal address for ${url.hostname} (${address})`)
|
|
139
159
|
}
|
|
160
|
+
return pinnedLookup(addresses)
|
|
140
161
|
}
|
|
141
162
|
|
|
142
163
|
const MAX_REDIRECTS = 5
|
|
@@ -156,14 +177,15 @@ async function readCapped(response: Response): Promise<string> {
|
|
|
156
177
|
return text.slice(0, MAX_RAW_CHARS)
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
async function fetchText(rawUrl: string): Promise<{ text: string; contentType: string }> {
|
|
180
|
+
async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text: string; contentType: string }> {
|
|
160
181
|
let url = new URL(rawUrl)
|
|
161
182
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
183
|
+
// Resolve, validate and pin per hop: a redirect target gets the same guarantee.
|
|
184
|
+
const lookup = await resolveAndPin(url)
|
|
185
|
+
const response = await transport(url, {
|
|
165
186
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
166
|
-
|
|
187
|
+
lookup,
|
|
188
|
+
userAgent: USER_AGENT,
|
|
167
189
|
})
|
|
168
190
|
if (response.status >= 300 && response.status < 400) {
|
|
169
191
|
const location = response.headers.get('location')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|