@agfpd/iapeer 0.2.95 → 0.2.97
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 +14 -4
- package/package.json +1 -1
- package/src/cli/index.ts +19 -3
- package/src/launch/adapters/claude.ts +10 -0
- package/src/launch/index.ts +23 -2
- package/src/launch/ptyHost.ts +7 -2
- package/src/launch/readyGateModel.ts +15 -4
- package/src/onboard/index.ts +24 -0
- package/src/onboard/risk.ts +110 -0
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ iapeer onboard
|
|
|
34
34
|
# 3. create your first agent
|
|
35
35
|
iapeer create assistant --runtime claude
|
|
36
36
|
|
|
37
|
-
# 4. talk to it live in your terminal — detach with Ctrl-]
|
|
37
|
+
# 4. talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
|
|
38
38
|
iapeer attach assistant
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -49,7 +49,7 @@ The peer's name is the folder name, normalized (`myagent`). `create` sets up a f
|
|
|
49
49
|
|
|
50
50
|
Two ways to work with an agent locally in your terminal:
|
|
51
51
|
|
|
52
|
-
- **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep
|
|
52
|
+
- **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep. Detach with `Ctrl-]` — or just close the terminal — and the session keeps running in the background; reattach with `iapeer attach` whenever you need it.
|
|
53
53
|
- **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
|
|
54
54
|
|
|
55
55
|
```sh
|
|
@@ -63,14 +63,24 @@ Agents reach each other with `send_to_peer`; you can also message any peer over
|
|
|
63
63
|
|
|
64
64
|
## What makes it different
|
|
65
65
|
|
|
66
|
-
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
|
|
66
|
+
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call (as of 2026-06-15, `claude -p` no longer counts against the subscription's main usage limits).
|
|
67
67
|
- **Warm-on-demand lifecycle.** A live session answers fast; after about an hour idle the daemon closes it; the next message brings it back with its context resumed. The message flow drives the lifecycle — not a human's presence.
|
|
68
68
|
- **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime — the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime: one personality, one memory, whatever runtime it runs on.
|
|
69
69
|
- **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
|
|
70
70
|
|
|
71
71
|
## Documentation
|
|
72
72
|
|
|
73
|
-
[`docs/`](
|
|
73
|
+
[`docs/`](docs/README.md) — the product side: what it is and how to use it. Available in two languages — English (default) and [Русский](docs/ru/README.md). This repository is the implementation.
|
|
74
|
+
|
|
75
|
+
## Roadmap
|
|
76
|
+
|
|
77
|
+
Planned, not yet built:
|
|
78
|
+
|
|
79
|
+
- **Web UI** — a browser interface for working with the team.
|
|
80
|
+
- **Linux support** — iapeer runs on macOS today; Linux is planned.
|
|
81
|
+
- **More agent runtimes** — `opencode` and `antigravity`, two further CLI runtimes alongside Claude Code and Codex.
|
|
82
|
+
- **Discord runtime** — a human-presence runtime for Discord, alongside Telegram.
|
|
83
|
+
- **Permissions gate** — finer control over what an agent is allowed to do.
|
|
74
84
|
|
|
75
85
|
## License
|
|
76
86
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
} from '../identity/profileStandard.ts'
|
|
64
64
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
65
65
|
import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
66
|
-
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
66
|
+
import { MARKETPLACE_NAME, onboardHost, tccFullDiskAccessNote } from '../onboard/index.ts'
|
|
67
67
|
import { readMemoryProvider } from '../status/index.ts'
|
|
68
68
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
69
69
|
import { pluginLogsDir } from '../storage/index.ts'
|
|
@@ -946,8 +946,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
946
946
|
version | --version | -v print the installed binary's version
|
|
947
947
|
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
948
948
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
949
|
-
onboard [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
|
|
950
|
-
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
|
|
949
|
+
onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
|
|
950
|
+
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively
|
|
951
951
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
952
952
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
953
953
|
update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
|
|
@@ -1002,6 +1002,18 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1002
1002
|
try {
|
|
1003
1003
|
switch (verb) {
|
|
1004
1004
|
case 'onboard': {
|
|
1005
|
+
// SECURITY GATE (pre-release): the operator must consciously accept the risk of
|
|
1006
|
+
// beta infra with live agents BEFORE onboard mutates the host. --accept-risk /
|
|
1007
|
+
// IAPEER_ACCEPT_RISK accepts non-interactively (one-liner installers / CI); a
|
|
1008
|
+
// TTY is prompted; a non-TTY without the flag is REFUSED with how-to (never a
|
|
1009
|
+
// silent proceed / hang). SKIPPED for --dry-run (a read-only preview mutates
|
|
1010
|
+
// nothing, so there is no risk to accept yet). Runs BEFORE daemon/peer steps.
|
|
1011
|
+
if (flags['dry-run'] !== true) {
|
|
1012
|
+
const { confirmOnboardRisk } = await import('../onboard/risk.ts')
|
|
1013
|
+
const gate = await confirmOnboardRisk({ accept: flags['accept-risk'] === true, env, out, errOut })
|
|
1014
|
+
if (gate === 'refused-non-tty') return 2 // explicit refusal: how-to printed
|
|
1015
|
+
if (gate === 'declined') return 1 // operator said no
|
|
1016
|
+
}
|
|
1005
1017
|
// Host-phase: register OUR marketplace in claude + codex (IDEMPOTENT — detect
|
|
1006
1018
|
// → skip when present; an already-configured host is a no-op). --dry-run
|
|
1007
1019
|
// reports the would-be actions without touching anything. --infra <csv> ALSO
|
|
@@ -1084,6 +1096,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1084
1096
|
})
|
|
1085
1097
|
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
1086
1098
|
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
1099
|
+
// macOS TCC advisory (manual grant — no flag/setting can): gated on a memory provider, since a
|
|
1100
|
+
// vault (often an iCloud Obsidian dir) is the TCC-protected resource. Base ~/.iapeer needs none.
|
|
1101
|
+
const tcc = tccFullDiskAccessNote({ memoryActive: mem.provider != null })
|
|
1102
|
+
if (tcc) out(tcc)
|
|
1087
1103
|
return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
1088
1104
|
}
|
|
1089
1105
|
case 'native-memory': {
|
|
@@ -117,6 +117,14 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
117
117
|
* owner watches; the question goes "into the void". Default is the literal
|
|
118
118
|
* 'AskUserQuestion'; the per-peer override (PEER_DISALLOWED_TOOLS empty =
|
|
119
119
|
* allow all) is install-time launch.env, not this path.
|
|
120
|
+
* - '--add-dir','/' grants the file/edit/bash tools access OUTSIDE cwd (the
|
|
121
|
+
* CLI equivalent of settings `permissions.additionalDirectories`). A peer
|
|
122
|
+
* reads/writes its memory vault (often an iCloud Obsidian dir) and ~/.iapeer,
|
|
123
|
+
* both outside cwd. Passing it as a LAUNCH FLAG means a clean host needs NO
|
|
124
|
+
* manual `additionalDirectories` in the user's ~/.claude/settings.json — the
|
|
125
|
+
* requirement travels with the launch, reversible, no user-file mutation.
|
|
126
|
+
* (OS-level access to TCC-protected dirs — iCloud / Desktop / Documents — is
|
|
127
|
+
* a SEPARATE macOS grant, not coverable by any flag; onboard instructs it.)
|
|
120
128
|
* - '--system-prompt-file', spec.systemPromptFile claude-start.sh:318 —
|
|
121
129
|
* ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
|
|
122
130
|
* CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
|
|
@@ -139,6 +147,8 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
139
147
|
'--dangerously-skip-permissions',
|
|
140
148
|
'--disallowedTools',
|
|
141
149
|
'AskUserQuestion',
|
|
150
|
+
'--add-dir',
|
|
151
|
+
'/',
|
|
142
152
|
...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
|
|
143
153
|
...(spec.resume ? ['--continue'] : []),
|
|
144
154
|
...(spec.extraArgs ?? []),
|
package/src/launch/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// marketplace / plugin install / update — that is install-time). lifecycle
|
|
14
14
|
// decides WHEN/HOW-MANY and hands launch a fully-resolved LaunchSpec + adapter.
|
|
15
15
|
|
|
16
|
-
import { mkdirSync } from 'fs'
|
|
16
|
+
import { existsSync, mkdirSync, statSync } from 'fs'
|
|
17
17
|
import { dirname } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
19
19
|
import { type Runtime } from '../core/constants.ts'
|
|
@@ -268,6 +268,13 @@ export const launch: LaunchFn = async (
|
|
|
268
268
|
const invocation = buildLaunchInvocation(spec, adapter, cfg)
|
|
269
269
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
|
|
270
270
|
const paneLogPath = `${cfg.logDir}/${identity}.log`
|
|
271
|
+
// SESSION-START BOUNDARY (premature-ready fix): the pane-log is a SHARED append-only file across a
|
|
272
|
+
// peer's session lineage. Capture its size BEFORE the supervisor opens+writes it, so the hosted
|
|
273
|
+
// ready-gate (waitHostReady → paneLogViewport) renders ONLY this session's bytes — never a PRIOR
|
|
274
|
+
// session's last frame, which (a `❯ ready` row) would satisfy isInputReady before the fresh runtime
|
|
275
|
+
// has painted → a premature deliver into a still-booting session → the first message is LOST and the
|
|
276
|
+
// model never produces a turn. Captured here, before startPtyHost spawns the writer (single writer).
|
|
277
|
+
const paneLogStartByte = existsSync(paneLogPath) ? statSync(paneLogPath).size : 0
|
|
271
278
|
const host = await startPtyHost({
|
|
272
279
|
identity,
|
|
273
280
|
runtime: spec.runtime,
|
|
@@ -293,11 +300,25 @@ export const launch: LaunchFn = async (
|
|
|
293
300
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
|
294
301
|
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
295
302
|
const isReady = await waitHostReady(
|
|
296
|
-
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs },
|
|
303
|
+
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte },
|
|
297
304
|
sleep,
|
|
298
305
|
)
|
|
299
306
|
if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
|
|
300
307
|
if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
|
|
308
|
+
// INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
|
|
309
|
+
// session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
|
|
310
|
+
// paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
|
|
311
|
+
// residual stale-frame read; a healthy fresh boot writes well beyond startByte before readiness.
|
|
312
|
+
appendLifecycleEvent(
|
|
313
|
+
cfg.exitLogDir,
|
|
314
|
+
{
|
|
315
|
+
ev: 'hosted-deliver',
|
|
316
|
+
identity,
|
|
317
|
+
paneLogStartByte,
|
|
318
|
+
paneLogBytes: existsSync(paneLogPath) ? statSync(paneLogPath).size : 0,
|
|
319
|
+
},
|
|
320
|
+
{ env },
|
|
321
|
+
)
|
|
301
322
|
const d = await deliverHosted(identity, firstMessage)
|
|
302
323
|
if (!d.ok) return fail(identity, `hosted deliver failed: ${d.error}`)
|
|
303
324
|
const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -160,15 +160,20 @@ export async function startPtyHost(b: {
|
|
|
160
160
|
* 2 s cadence + bootDeadline.
|
|
161
161
|
*/
|
|
162
162
|
export async function waitHostReady(
|
|
163
|
-
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number },
|
|
163
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number },
|
|
164
164
|
sleep: (ms: number) => Promise<void>,
|
|
165
165
|
): Promise<boolean> {
|
|
166
166
|
const log = `${b.logDir}/${b.identity}.log`
|
|
167
167
|
const iters = Math.max(1, Math.ceil(b.bootDeadlineSecs / 2))
|
|
168
|
+
// paneLogStartByte = the pane-log size at THIS session's spawn (captured by launch before the
|
|
169
|
+
// supervisor opens the shared append-only log). Passing it to the model render confines readiness to
|
|
170
|
+
// bytes THIS session wrote — without it a prior session's trailing `❯ ready` frame in the shared log
|
|
171
|
+
// satisfies isInputReady before the fresh runtime paints, causing a premature deliver into a booting
|
|
172
|
+
// session (the first message is then lost). See readyGateModel.loadPaneLogModel.
|
|
168
173
|
for (let i = 0; i < iters; i++) {
|
|
169
174
|
await sleep(2000)
|
|
170
175
|
if (!hostSessionAlive(b.identity)) return false
|
|
171
|
-
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS)
|
|
176
|
+
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
|
|
172
177
|
if (view && b.adapter.isInputReady(view)) return true
|
|
173
178
|
}
|
|
174
179
|
return false
|
|
@@ -89,14 +89,25 @@ export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<st
|
|
|
89
89
|
* (unreadable/short log, @xterm hiccup) — the caller decides the fallback (capture-pane for the
|
|
90
90
|
* readygate flip; a retry/fail for hosted).
|
|
91
91
|
*/
|
|
92
|
-
async function loadPaneLogModel(log: string, cols: number, rows: number): Promise<Terminal | null> {
|
|
92
|
+
async function loadPaneLogModel(log: string, cols: number, rows: number, startByte = 0): Promise<Terminal | null> {
|
|
93
93
|
if (!(cols > 0 && rows > 0) || !existsSync(log)) return null
|
|
94
94
|
try {
|
|
95
95
|
const { Terminal } = await import('@xterm/headless')
|
|
96
96
|
const fd = openSync(log, 'r')
|
|
97
97
|
try {
|
|
98
98
|
const sz = fstatSync(fd).size
|
|
99
|
-
|
|
99
|
+
// RENDER WINDOW = the SEED tail (last 4 MiB) AND, when given, the SESSION-START boundary. The
|
|
100
|
+
// pane-log is a SHARED append-only file across a peer's session lineage, so its tail can carry a
|
|
101
|
+
// PRIOR session's last frame — and a prior session almost always ended idle at a `❯ ready` row.
|
|
102
|
+
// Rendering that on a FRESH wake (before the new runtime has painted) makes isInputReady=true
|
|
103
|
+
// PREMATURELY → the gate delivers into a still-booting session → the first message is LOST and the
|
|
104
|
+
// model never produces a turn ("no activity advance"). `startByte` = the pane-log size captured at
|
|
105
|
+
// THIS session's spawn; clamping `off` to it renders ONLY this session's bytes, never the prior
|
|
106
|
+
// frame. Trim-safety: if capPaneLogs shrank the file BELOW startByte since capture (rare, mid-boot),
|
|
107
|
+
// the boundary is stale → fall back to the SEED tail (no worse than pre-fix; never hang on a 0-len
|
|
108
|
+
// read). startByte=0 (default) is byte-identical to the prior behavior.
|
|
109
|
+
const seedFloor = Math.max(0, sz - SEED_BYTES)
|
|
110
|
+
const off = startByte > 0 && startByte <= sz ? Math.max(startByte, seedFloor) : seedFloor
|
|
100
111
|
const buf = Buffer.alloc(sz - off)
|
|
101
112
|
readSync(fd, buf, 0, buf.length, off)
|
|
102
113
|
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
@@ -110,8 +121,8 @@ async function loadPaneLogModel(log: string, cols: number, rows: number): Promis
|
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
export async function paneLogViewport(log: string, cols: number, rows: number): Promise<string | null> {
|
|
114
|
-
const term = await loadPaneLogModel(log, cols, rows)
|
|
124
|
+
export async function paneLogViewport(log: string, cols: number, rows: number, startByte = 0): Promise<string | null> {
|
|
125
|
+
const term = await loadPaneLogModel(log, cols, rows, startByte)
|
|
115
126
|
if (!term) return null
|
|
116
127
|
const { modelToPlainText } = await import('../shadow/render.ts')
|
|
117
128
|
return modelToPlainText(term, cols, rows)
|
package/src/onboard/index.ts
CHANGED
|
@@ -212,3 +212,27 @@ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
|
|
|
212
212
|
const noop = marketplaces.every(m => m.state !== 'registered')
|
|
213
213
|
return { marketplaces, noop }
|
|
214
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* macOS Full Disk Access (TCC) advisory printed at the END of onboard. The runtime permission layer
|
|
218
|
+
* (`--add-dir` / `additionalDirectories` for claude, the sandbox flags for codex) governs what the
|
|
219
|
+
* RUNTIME allows; macOS TCC is a SEPARATE OS layer that NO flag or settings key can grant — it is
|
|
220
|
+
* user-granted in System Settings, by design. `~/.iapeer` lives under $HOME and is NOT TCC-protected,
|
|
221
|
+
* so a BASE install needs nothing. The trigger that warrants the grant is a memory vault — commonly an
|
|
222
|
+
* iCloud Obsidian dir, and iCloud Drive / Desktop / Documents / Downloads ARE TCC-protected — so the
|
|
223
|
+
* note is GATED on a memory provider being present; a base ~/.iapeer-only host is never nagged.
|
|
224
|
+
* Returns null when not applicable (caller prints nothing). Pure (no I/O) — unit-testable.
|
|
225
|
+
*/
|
|
226
|
+
export function tccFullDiskAccessNote(opts: { memoryActive: boolean }): string | null {
|
|
227
|
+
if (!opts.memoryActive) return null
|
|
228
|
+
return (
|
|
229
|
+
[
|
|
230
|
+
'macOS Full Disk Access (manual — TCC cannot be set by any flag or settings key):',
|
|
231
|
+
' ~/.iapeer needs none (it is under $HOME). BUT a memory vault or a peer work dir in a',
|
|
232
|
+
' TCC-protected location (iCloud Drive, Desktop, Documents, Downloads — e.g. an iCloud',
|
|
233
|
+
' Obsidian vault) does, or file tools silently fail to read/write there.',
|
|
234
|
+
' Grant it to ~/.local/bin/iapeer (and to Terminal too, if you attach) in:',
|
|
235
|
+
' System Settings → Privacy & Security → Full Disk Access',
|
|
236
|
+
].join('\n') + '\n'
|
|
237
|
+
)
|
|
238
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Onboard security gate (pre-release): the operator must consciously accept the
|
|
2
|
+
// risk of running beta infra with live agents BEFORE onboard mutates the host
|
|
3
|
+
// (daemon start + peer registration). The warning text is the owner's verbatim
|
|
4
|
+
// disclaimer — do NOT reword it.
|
|
5
|
+
//
|
|
6
|
+
// BOUNDARY: this must never break a NON-INTERACTIVE onboard (a one-liner installer /
|
|
7
|
+
// automation calls onboard without a TTY). Acceptance paths:
|
|
8
|
+
// • --accept-risk (CLI flag) or IAPEER_ACCEPT_RISK=1 (env) → accept, no prompt
|
|
9
|
+
// (the explicit non-interactive channel);
|
|
10
|
+
// • a real TTY → show the warning and require a y/yes answer;
|
|
11
|
+
// • a NON-TTY without the flag → REFUSE with how-to (never a silent proceed, never
|
|
12
|
+
// a hang waiting on stdin that no one will type into).
|
|
13
|
+
|
|
14
|
+
/** The owner's verbatim security disclaimer. Shown at the top of an interactive
|
|
15
|
+
* onboard and in the non-interactive refusal. Word-for-word — do not edit. */
|
|
16
|
+
export const ONBOARD_SECURITY_WARNING = `Security warning — please read.
|
|
17
|
+
|
|
18
|
+
iapeer is beta infrastructure for live AI agents on your machine. Expect sharp edges.
|
|
19
|
+
|
|
20
|
+
It starts a local daemon, registers peers, and routes messages between Claude Code,
|
|
21
|
+
Codex CLI, Telegram humans, and services. If tools, memory, or project files are
|
|
22
|
+
enabled, a bad prompt can make an agent do unsafe things.
|
|
23
|
+
|
|
24
|
+
iapeer is not a hostile multi-user security boundary.
|
|
25
|
+
Do not expose it to untrusted users, untrusted Telegram chats, or the public internet.
|
|
26
|
+
|
|
27
|
+
State, profiles, logs, memory, and runtime configuration live under ~/.iapeer and
|
|
28
|
+
peer-local .iapeer folders. Treat that data as sensitive.
|
|
29
|
+
|
|
30
|
+
If you are not comfortable with local daemons, filesystem access, Telegram bot tokens,
|
|
31
|
+
and agent tool permissions, do not continue.
|
|
32
|
+
|
|
33
|
+
Recommended baseline:
|
|
34
|
+
- localhost only;
|
|
35
|
+
- private Telegram bots/chats only;
|
|
36
|
+
- least-privilege project folders and tools;
|
|
37
|
+
- no secrets in reachable files;
|
|
38
|
+
- review peers, memory, logs, and configs after onboarding.
|
|
39
|
+
|
|
40
|
+
I understand this is powerful and inherently risky. Continue?`
|
|
41
|
+
|
|
42
|
+
/** How the onboard verb should react after the gate. */
|
|
43
|
+
export type RiskGateResult =
|
|
44
|
+
| 'accepted-flag' // --accept-risk / IAPEER_ACCEPT_RISK → non-interactive accept (no prompt)
|
|
45
|
+
| 'accepted-prompt' // interactive TTY answered y/yes
|
|
46
|
+
| 'declined' // interactive TTY answered no → abort
|
|
47
|
+
| 'refused-non-tty' // no TTY and no accept flag → refuse with how-to (never proceed)
|
|
48
|
+
|
|
49
|
+
export interface RiskGateOptions {
|
|
50
|
+
/** The --accept-risk CLI flag. */
|
|
51
|
+
accept?: boolean
|
|
52
|
+
env?: NodeJS.ProcessEnv
|
|
53
|
+
/** Override TTY detection (tests). Default: stdin AND stdout are TTYs. */
|
|
54
|
+
isTty?: boolean
|
|
55
|
+
/** Injectable prompt (tests). Default: a readline question on the live TTY. */
|
|
56
|
+
ask?: () => Promise<string>
|
|
57
|
+
/** stdout sink (default process.stdout). */
|
|
58
|
+
out?: (s: string) => void
|
|
59
|
+
/** stderr sink (default process.stderr). */
|
|
60
|
+
errOut?: (s: string) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RISK_ENV = 'IAPEER_ACCEPT_RISK'
|
|
64
|
+
|
|
65
|
+
function envAccepts(env: NodeJS.ProcessEnv): boolean {
|
|
66
|
+
return /^(1|true|yes)$/i.test((env[RISK_ENV] ?? '').trim())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function defaultAsk(): Promise<string> {
|
|
70
|
+
const { createInterface } = await import('node:readline/promises')
|
|
71
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
72
|
+
try {
|
|
73
|
+
return (await rl.question('')).trim()
|
|
74
|
+
} finally {
|
|
75
|
+
rl.close()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Confirm the operator accepts the onboard risk. Returns the gate outcome; the
|
|
81
|
+
* caller maps a non-accept outcome to a non-zero exit. Never throws, never hangs a
|
|
82
|
+
* non-interactive run: the flag/env path is silent-accept, a TTY is prompted, a
|
|
83
|
+
* non-TTY without the flag is refused with the exact how-to.
|
|
84
|
+
*/
|
|
85
|
+
export async function confirmOnboardRisk(opts: RiskGateOptions = {}): Promise<RiskGateResult> {
|
|
86
|
+
const env = opts.env ?? process.env
|
|
87
|
+
const out = opts.out ?? ((s: string) => void process.stdout.write(s))
|
|
88
|
+
const errOut = opts.errOut ?? ((s: string) => void process.stderr.write(s))
|
|
89
|
+
|
|
90
|
+
// Non-interactive accept channel (one-liner installers / automation).
|
|
91
|
+
if (opts.accept === true || envAccepts(env)) return 'accepted-flag'
|
|
92
|
+
|
|
93
|
+
const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
|
|
94
|
+
if (!tty) {
|
|
95
|
+
// No one to answer the prompt — refuse LOUD with how-to, never a silent proceed.
|
|
96
|
+
errOut(
|
|
97
|
+
`${ONBOARD_SECURITY_WARNING}\n\n` +
|
|
98
|
+
`Non-interactive onboard: no TTY to confirm. Re-run with --accept-risk ` +
|
|
99
|
+
`(or set ${RISK_ENV}=1) to accept this risk explicitly.\n`,
|
|
100
|
+
)
|
|
101
|
+
return 'refused-non-tty'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
out(`${ONBOARD_SECURITY_WARNING} `)
|
|
105
|
+
const ask = opts.ask ?? defaultAsk
|
|
106
|
+
const answer = (await ask()).trim().toLowerCase()
|
|
107
|
+
if (answer === 'y' || answer === 'yes') return 'accepted-prompt'
|
|
108
|
+
out('onboard aborted — risk not accepted.\n')
|
|
109
|
+
return 'declined'
|
|
110
|
+
}
|