pidriver 0.4.2__tar.gz → 0.5.0__tar.gz
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.
- {pidriver-0.4.2 → pidriver-0.5.0}/PKG-INFO +4 -4
- {pidriver-0.4.2 → pidriver-0.5.0}/README.md +3 -3
- {pidriver-0.4.2 → pidriver-0.5.0}/pyproject.toml +1 -1
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/__init__.py +1 -1
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/_install.py +1 -1
- pidriver-0.5.0/src/pidriver/assets/pi_login.mjs +149 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/auth.py +7 -6
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/config.py +2 -2
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/events.py +2 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/session.py +28 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/README.md +7 -0
- pidriver-0.5.0/tests/test_auth.py +150 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_config.py +6 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_install.py +8 -8
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_replay_session.py +8 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_session.py +67 -0
- pidriver-0.4.2/src/pidriver/assets/pi_login.mjs +0 -115
- pidriver-0.4.2/tests/test_auth.py +0 -81
- {pidriver-0.4.2 → pidriver-0.5.0}/.gitignore +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/.sc/last-chat-settings.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/LICENSE +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/examples/basic_prompt.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/_transport.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/client.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/errors.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/instances.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/interaction.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/manager.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/models.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/py.typed +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/repository.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/staging.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/store.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/supervisor.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/usage.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/web.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/src/pidriver/workspace.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/conftest.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/_capture_driver.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/agent_end.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/agent_start.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_start_assistant.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_start_user.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.text_delta.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/ready.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/response_prompt_ack.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_end.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_start.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/tool_execution_update.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/turn_end.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/turn_start.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/payloads/get_state.response.json +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_event_summary.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_events.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_fixtures_replay.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_install_resolve.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_manager.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_models.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_repository.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_staging.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_store.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_supervisor.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_transport.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_ui_methods.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_usage.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_web.py +0 -0
- {pidriver-0.4.2 → pidriver-0.5.0}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pidriver
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work.
|
|
5
5
|
Project-URL: Homepage, https://github.com/bobuk/pidriver
|
|
6
6
|
Project-URL: Repository, https://github.com/bobuk/pidriver
|
|
@@ -154,7 +154,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
|
|
|
154
154
|
| `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
|
|
155
155
|
| `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
|
|
156
156
|
| `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
|
|
157
|
-
| `pi_version` | `"
|
|
157
|
+
| `pi_version` | `"1.5.0"` | Pinned pi version to install (overridable). |
|
|
158
158
|
| `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
|
|
159
159
|
|
|
160
160
|
**Model / provider**
|
|
@@ -421,7 +421,7 @@ the driver. Every event carries `.type` (the raw tag) and `.raw` (the decoded di
|
|
|
421
421
|
| `ToolUpdate` | `tool_call_id`, `name`, `output` | Partial output while a tool runs. |
|
|
422
422
|
| `ToolEnd` | `tool_call_id`, `name`, `result`, `is_error` | A tool finished. |
|
|
423
423
|
| `Usage` | `totals` (a `UsageTotals`) | Token/cost for a step; also folded into `session.usage`. |
|
|
424
|
-
| `AgentEnd` | `reason`, `final_text` | The
|
|
424
|
+
| `AgentEnd` | `reason`, `final_text` | The turn finished (`completed` / `cancelled` / `error` / `limit`): on upstream pi, at `agent_settled`, after any retries or compaction. |
|
|
425
425
|
| `Error` | `message`, `code`, `fatal` | An error (or a failed command ack) surfaced by pi. |
|
|
426
426
|
| `InteractionRequest` | `request_id`, `kind`, `prompt`, `options`, `tool_call_id`, `default` | The agent is blocked waiting for the host. |
|
|
427
427
|
| `Event` | `type`, `raw` | Base / fallback for unrecognized records. |
|
|
@@ -604,7 +604,7 @@ for m in models:
|
|
|
604
604
|
## Provider OAuth login
|
|
605
605
|
|
|
606
606
|
Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
|
|
607
|
-
drives a bundled Node helper that reuses pi's own
|
|
607
|
+
drives a bundled Node helper that reuses pi's own login code (no version skew), writing
|
|
608
608
|
credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
|
|
609
609
|
install.
|
|
610
610
|
|
|
@@ -128,7 +128,7 @@ PiConfig(provider="anthropic", model="claude-sonnet-4-6",
|
|
|
128
128
|
| `install_dir` | `None` | Where to `npm install`/look for a managed `pi` binary. |
|
|
129
129
|
| `auto_install` | `True` | `npm install` `pi` into `install_dir` if the binary can't be resolved. |
|
|
130
130
|
| `npm_package` | `"@earendil-works/pi-coding-agent"` | The npm package that provides `pi`. |
|
|
131
|
-
| `pi_version` | `"
|
|
131
|
+
| `pi_version` | `"1.5.0"` | Pinned pi version to install (overridable). |
|
|
132
132
|
| `extensions` | `()` | Add-on sources (`npm:…`, `git:…`, path) installed via `pi install` before start. |
|
|
133
133
|
|
|
134
134
|
**Model / provider**
|
|
@@ -395,7 +395,7 @@ the driver. Every event carries `.type` (the raw tag) and `.raw` (the decoded di
|
|
|
395
395
|
| `ToolUpdate` | `tool_call_id`, `name`, `output` | Partial output while a tool runs. |
|
|
396
396
|
| `ToolEnd` | `tool_call_id`, `name`, `result`, `is_error` | A tool finished. |
|
|
397
397
|
| `Usage` | `totals` (a `UsageTotals`) | Token/cost for a step; also folded into `session.usage`. |
|
|
398
|
-
| `AgentEnd` | `reason`, `final_text` | The
|
|
398
|
+
| `AgentEnd` | `reason`, `final_text` | The turn finished (`completed` / `cancelled` / `error` / `limit`): on upstream pi, at `agent_settled`, after any retries or compaction. |
|
|
399
399
|
| `Error` | `message`, `code`, `fatal` | An error (or a failed command ack) surfaced by pi. |
|
|
400
400
|
| `InteractionRequest` | `request_id`, `kind`, `prompt`, `options`, `tool_call_id`, `default` | The agent is blocked waiting for the host. |
|
|
401
401
|
| `Event` | `type`, `raw` | Base / fallback for unrecognized records. |
|
|
@@ -578,7 +578,7 @@ for m in models:
|
|
|
578
578
|
## Provider OAuth login
|
|
579
579
|
|
|
580
580
|
Some providers authenticate via OAuth (Claude Pro/Max, Codex, …) instead of an API key. `PiOAuthLogin`
|
|
581
|
-
drives a bundled Node helper that reuses pi's own
|
|
581
|
+
drives a bundled Node helper that reuses pi's own login code (no version skew), writing
|
|
582
582
|
credentials into the isolated `<agent_dir>/auth.json`. Requires `node` on PATH and a resolvable pi
|
|
583
583
|
install.
|
|
584
584
|
|
|
@@ -123,7 +123,7 @@ from .supervisor import PiInstanceSupervisor
|
|
|
123
123
|
from .usage import UsageTotals
|
|
124
124
|
from .workspace import ProjectDir, list_candidate_projects, list_projects, resolve_project
|
|
125
125
|
|
|
126
|
-
__version__ = "0.
|
|
126
|
+
__version__ = "0.5.0"
|
|
127
127
|
|
|
128
128
|
__all__ = [
|
|
129
129
|
"__version__",
|
|
@@ -286,7 +286,7 @@ def resolve_node_modules(config: PiConfig, *, runner: Runner = subprocess.run) -
|
|
|
286
286
|
bin to its node_modules). Package identity comes from ``config.npm_package`` so this
|
|
287
287
|
works for the omp/oh-my-pi fork too. Returns ``None`` when no install is found.
|
|
288
288
|
|
|
289
|
-
Useful for tools that need pi's *bundled* Node libraries (e.g. its
|
|
289
|
+
Useful for tools that need pi's *bundled* Node libraries (e.g. its login code
|
|
290
290
|
for the OAuth flow — see :mod:`pidriver.auth`), not just the ``pi`` executable.
|
|
291
291
|
"""
|
|
292
292
|
package = _package_components(config.npm_package)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pi OAuth/login helper for pidriver (driven by pidriver.auth).
|
|
3
|
+
//
|
|
4
|
+
// Reuses pi's own login code (public exports of @earendil-works/pi-coding-agent, which bundles the
|
|
5
|
+
// pi-ai OAuth providers — so there is no version skew and nothing extra to install): ModelRuntime
|
|
6
|
+
// on pi 1.x, AuthStorage before it. The package is found at PI_NODE_MODULES
|
|
7
|
+
// (<pi install_dir>/lib/node_modules). Credentials are read/written at PI_AUTH_PATH (the isolated
|
|
8
|
+
// <agent_dir>/auth.json).
|
|
9
|
+
//
|
|
10
|
+
// Commands:
|
|
11
|
+
// node pi_login.mjs list -> {"providers":[{id,name}], "status":{provider:type}}
|
|
12
|
+
// node pi_login.mjs logout <provider> -> {"ok":true}
|
|
13
|
+
// node pi_login.mjs login <provider> -> JSONL events on stdout; reads the auth code from stdin
|
|
14
|
+
//
|
|
15
|
+
// login protocol (one JSON object per line on stdout):
|
|
16
|
+
// {"event":"auth","url":"...","instructions":"..."} # open this URL in a browser
|
|
17
|
+
// {"event":"need_code","message":"...","placeholder":"..."} # then write the code as one stdin line
|
|
18
|
+
// {"event":"progress","message":"..."}
|
|
19
|
+
// {"event":"done"} # credentials saved to PI_AUTH_PATH
|
|
20
|
+
// {"event":"error","message":"..."}
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import readline from "node:readline";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
function emit(o) {
|
|
28
|
+
process.stdout.write(JSON.stringify(o) + "\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Import pi from the managed install (PI_NODE_MODULES = <install>/lib/node_modules). We import the
|
|
32
|
+
// package's entry by absolute path: its `exports` map only declares the ESM `import` condition, so
|
|
33
|
+
// require.resolve can't find it, but a direct file import resolves its nested pi-ai too.
|
|
34
|
+
async function loadPi() {
|
|
35
|
+
const nm = process.env.PI_NODE_MODULES;
|
|
36
|
+
if (!nm) throw new Error("PI_NODE_MODULES is not set");
|
|
37
|
+
const pkgDir = join(nm, "@earendil-works", "pi-coding-agent");
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
|
39
|
+
const rel = pkg?.exports?.["."]?.import || pkg.main || "./dist/index.js";
|
|
40
|
+
return await import(pathToFileURL(join(pkgDir, rel)).href);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readLine() {
|
|
44
|
+
return await new Promise((resolve) => {
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
46
|
+
rl.once("line", (line) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(String(line).trim());
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function selectLoginOption(prompt) {
|
|
54
|
+
const options = Array.isArray(prompt?.options) ? prompt.options : [];
|
|
55
|
+
const selected = options.find((option) => {
|
|
56
|
+
const haystack = String(`${option?.id || ""} ${option?.label || ""}`).toLowerCase();
|
|
57
|
+
return haystack.includes("browser") || haystack.includes("default");
|
|
58
|
+
}) || options[0];
|
|
59
|
+
emit({
|
|
60
|
+
event: "select",
|
|
61
|
+
message: prompt?.message || "Select login method",
|
|
62
|
+
options: options.map((option) => ({ id: option.id, label: option.label })),
|
|
63
|
+
selected: selected?.id || "",
|
|
64
|
+
});
|
|
65
|
+
return selected?.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function askCode(prompt) {
|
|
69
|
+
emit({ event: "need_code", message: (prompt && prompt.message) || "Paste the authorization code",
|
|
70
|
+
placeholder: (prompt && prompt.placeholder) || "" });
|
|
71
|
+
return await readLine();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// pi 1.x: provider auth belongs to ModelRuntime, AuthStorage only keeps the credentials.
|
|
75
|
+
async function modelRuntimeAuth(pi) {
|
|
76
|
+
const runtime = await pi.ModelRuntime.create({
|
|
77
|
+
authPath: process.env.PI_AUTH_PATH,
|
|
78
|
+
allowModelNetwork: false,
|
|
79
|
+
refreshOnCreate: false,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
providers: () => runtime.getProviders()
|
|
83
|
+
.filter((p) => p.auth.oauth)
|
|
84
|
+
.map((p) => ({ id: p.id, name: p.auth.oauth.name || p.name })),
|
|
85
|
+
status: async () => Object.fromEntries(
|
|
86
|
+
(await runtime.listCredentials()).map((c) => [c.providerId, c.type])),
|
|
87
|
+
logout: (id) => runtime.logout(id),
|
|
88
|
+
login: (id) => runtime.login(id, "oauth", {
|
|
89
|
+
prompt: async (prompt) => (prompt.type === "select" ? selectLoginOption(prompt) : askCode(prompt)),
|
|
90
|
+
notify: (event) => {
|
|
91
|
+
if (event.type === "auth_url") emit({ event: "auth", url: event.url, instructions: event.instructions || "" });
|
|
92
|
+
else if (event.type === "device_code") emit({ event: "device_code", ...event, type: undefined });
|
|
93
|
+
else emit({ event: "progress", message: String(event.message) });
|
|
94
|
+
},
|
|
95
|
+
}, { getDeviceId: () => pi.SettingsManager.create(process.cwd(), pi.getAgentDir()).getOrCreateDeviceId() }),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Before pi 1.x AuthStorage ran the logins itself.
|
|
100
|
+
function authStorageAuth(pi) {
|
|
101
|
+
const storage = pi.AuthStorage.create(process.env.PI_AUTH_PATH);
|
|
102
|
+
return {
|
|
103
|
+
providers: () => storage.getOAuthProviders().map((p) => ({ id: p.id, name: p.name })),
|
|
104
|
+
status: () => Object.fromEntries(
|
|
105
|
+
Object.entries(storage.getAll() || {}).map(([k, v]) => [k, v && v.type ? v.type : "unknown"])),
|
|
106
|
+
logout: (id) => storage.logout(id),
|
|
107
|
+
login: (id) => storage.login(id, {
|
|
108
|
+
onAuth: (info) => emit({ event: "auth", url: info.url, instructions: info.instructions || "" }),
|
|
109
|
+
onDeviceCode: (info) => emit({ event: "device_code", ...info }),
|
|
110
|
+
onProgress: (m) => emit({ event: "progress", message: String(m) }),
|
|
111
|
+
onManualCodeInput: async () => askCode(),
|
|
112
|
+
onPrompt: async (prompt) => askCode(prompt),
|
|
113
|
+
onSelect: async (prompt) => selectLoginOption(prompt),
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
const pi = await loadPi();
|
|
120
|
+
if (!pi.ModelRuntime && !pi.AuthStorage) throw new Error("no login API exported by pi-coding-agent");
|
|
121
|
+
const auth = pi.ModelRuntime ? await modelRuntimeAuth(pi) : authStorageAuth(pi);
|
|
122
|
+
const [cmd, providerId] = process.argv.slice(2);
|
|
123
|
+
|
|
124
|
+
if (cmd === "list") {
|
|
125
|
+
emit({ providers: auth.providers(), status: await auth.status() });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (cmd === "logout") {
|
|
130
|
+
if (!providerId) throw new Error("logout requires a provider id");
|
|
131
|
+
await auth.logout(providerId);
|
|
132
|
+
emit({ ok: true });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (cmd === "login") {
|
|
137
|
+
if (!providerId) throw new Error("login requires a provider id");
|
|
138
|
+
await auth.login(providerId);
|
|
139
|
+
emit({ event: "done" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new Error(`unknown command: ${cmd}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
main().then(() => process.exit(0)).catch((e) => {
|
|
147
|
+
emit({ event: "error", message: (e && e.message) || String(e) });
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Some providers (Claude Pro/Max subscriptions, Codex, …) authenticate the agent
|
|
4
4
|
via OAuth rather than an API key, writing credentials into the *isolated* pi
|
|
5
5
|
config (``<agent_dir>/auth.json``). pidriver drives that flow through a bundled
|
|
6
|
-
Node helper (``assets/pi_login.mjs``) that reuses pi's OWN
|
|
7
|
-
|
|
8
|
-
nothing extra to install beyond pi
|
|
6
|
+
Node helper (``assets/pi_login.mjs``) that reuses pi's OWN login code — public
|
|
7
|
+
exports of the pi npm package (``ModelRuntime`` on pi 1.x, ``AuthStorage`` before
|
|
8
|
+
it) — so there is never any version skew and nothing extra to install beyond pi
|
|
9
|
+
itself.
|
|
9
10
|
|
|
10
11
|
The browser-authorize → paste-code handshake is exposed as a small async API so a
|
|
11
12
|
host (a web app, a chat bot) can surface the authorize URL and feed the code back::
|
|
@@ -16,7 +17,7 @@ host (a web app, a chat bot) can surface the authorize URL and feed the code bac
|
|
|
16
17
|
await login.submit_code(info["session"], code) # credentials now in auth.json
|
|
17
18
|
|
|
18
19
|
Requires Node (the ``node`` binary) on PATH and a resolvable pi install (so its
|
|
19
|
-
|
|
20
|
+
login code can be imported). Both are *runtime* requirements checked
|
|
20
21
|
when a flow runs — importing this module never fails and never needs Node.
|
|
21
22
|
"""
|
|
22
23
|
|
|
@@ -73,7 +74,7 @@ def auth_context(config: PiConfig) -> tuple[Path, dict[str, str]]:
|
|
|
73
74
|
"""``(auth.json path, helper env)`` for running the login helper under ``config``.
|
|
74
75
|
|
|
75
76
|
The env is ``config.to_env()`` plus ``PI_NODE_MODULES`` (where pi's bundled
|
|
76
|
-
|
|
77
|
+
login code lives) and ``PI_AUTH_PATH`` (the isolated ``<agent_dir>/auth.json``
|
|
77
78
|
to read/write) — so the OAuth credentials land in the same isolated config the
|
|
78
79
|
driven agent reads. Raises :class:`PiInstallError` if ``agent_dir`` is unset or no
|
|
79
80
|
pi install can be located.
|
|
@@ -86,7 +87,7 @@ def auth_context(config: PiConfig) -> tuple[Path, dict[str, str]]:
|
|
|
86
87
|
node_modules = resolve_node_modules(config)
|
|
87
88
|
if node_modules is None:
|
|
88
89
|
raise PiInstallError(
|
|
89
|
-
"pi install not found — install pi (it carries
|
|
90
|
+
"pi install not found — install pi (it carries the login code), or run a job once "
|
|
90
91
|
"so pidriver installs it, before attempting OAuth login."
|
|
91
92
|
)
|
|
92
93
|
env = dict(config.to_env())
|
|
@@ -110,7 +110,7 @@ class PiConfig:
|
|
|
110
110
|
install_dir: Path | None = None # where to install/look for a managed pi
|
|
111
111
|
auto_install: bool = True # npm-install pi into install_dir if missing
|
|
112
112
|
npm_package: str = "@earendil-works/pi-coding-agent" # the npm package providing `pi`
|
|
113
|
-
pi_version: str = "
|
|
113
|
+
pi_version: str = "1.5.0" # known-good pin; overridable
|
|
114
114
|
|
|
115
115
|
# --- extensions / skills / packages ------------------------------------
|
|
116
116
|
# User-chosen add-ons installed into the (isolated) pi config dir via
|
|
@@ -177,7 +177,7 @@ class PiConfig:
|
|
|
177
177
|
"""Build the full command line: ``[binary, "--mode", mode, ...]``."""
|
|
178
178
|
argv: list[str] = [self.binary, "--mode", self.mode]
|
|
179
179
|
|
|
180
|
-
if self.provider:
|
|
180
|
+
if self.provider and self.model: # pi >= 1.0 refuses --provider without --model
|
|
181
181
|
argv += ["--provider", self.provider]
|
|
182
182
|
if self.model:
|
|
183
183
|
argv += ["--model", self.model]
|
|
@@ -128,6 +128,8 @@ class Usage(Event):
|
|
|
128
128
|
|
|
129
129
|
@dataclass(frozen=True, slots=True)
|
|
130
130
|
class AgentEnd(Event):
|
|
131
|
+
# The end of a whole turn: on upstream pi the session emits it at agent_settled, once retries,
|
|
132
|
+
# compaction recovery and queued work are over.
|
|
131
133
|
# completed | cancelled | error | limit (kept a free str for forward-compat)
|
|
132
134
|
reason: str = "completed"
|
|
133
135
|
final_text: str = ""
|
|
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import time
|
|
22
22
|
import uuid
|
|
23
23
|
from collections.abc import AsyncIterator, Callable
|
|
24
|
+
from dataclasses import replace
|
|
24
25
|
from typing import Any
|
|
25
26
|
|
|
26
27
|
from .events import (
|
|
@@ -78,6 +79,7 @@ class PiSession:
|
|
|
78
79
|
self._consumed = False
|
|
79
80
|
self._ended = False
|
|
80
81
|
self._closed = False
|
|
82
|
+
self._unsettled: AgentEnd | None = None
|
|
81
83
|
|
|
82
84
|
# ── ManagedSession surface ─────────────────────────────────────────────
|
|
83
85
|
|
|
@@ -144,6 +146,15 @@ class PiSession:
|
|
|
144
146
|
|
|
145
147
|
event = parse_event(record)
|
|
146
148
|
|
|
149
|
+
# Upstream pi's agent_end closes one low-level run: a retry, overflow recovery,
|
|
150
|
+
# compaction or queued work can still follow. The turn is over at agent_settled, so
|
|
151
|
+
# the run's end waits for it. omp's agent_end carries no willRetry and is final.
|
|
152
|
+
if isinstance(event, AgentEnd) and "willRetry" in record:
|
|
153
|
+
self._unsettled = _latest_end(self._unsettled, event)
|
|
154
|
+
continue
|
|
155
|
+
if rtype == "agent_settled":
|
|
156
|
+
event = self._settled_end(record)
|
|
157
|
+
|
|
147
158
|
if isinstance(event, AgentStart) and event.session_id:
|
|
148
159
|
self._pi_session_id = event.session_id
|
|
149
160
|
if isinstance(event, AgentEnd):
|
|
@@ -165,6 +176,16 @@ class PiSession:
|
|
|
165
176
|
fatal=False,
|
|
166
177
|
)
|
|
167
178
|
|
|
179
|
+
def _settled_end(self, record: dict[str, Any]) -> AgentEnd:
|
|
180
|
+
"""The :class:`AgentEnd` of a settled turn: its last run's end, ``cancelled`` if aborted.
|
|
181
|
+
|
|
182
|
+
Without a held end (a session attached after pi's ``agent_end`` was read by a previous
|
|
183
|
+
one) it still ends the turn, with no text — a turn left open would never finish.
|
|
184
|
+
"""
|
|
185
|
+
held, self._unsettled = self._unsettled, None
|
|
186
|
+
end = held or AgentEnd(type=str(record.get("type", "")), raw=record)
|
|
187
|
+
return replace(end, reason="cancelled") if record.get("aborted") else end
|
|
188
|
+
|
|
168
189
|
async def _on_interaction(self, request: InteractionRequest) -> None:
|
|
169
190
|
"""Let the handler try to auto-answer; otherwise mark it pending."""
|
|
170
191
|
response = await self._handler(request, self)
|
|
@@ -253,3 +274,10 @@ class PiSession:
|
|
|
253
274
|
|
|
254
275
|
def _opt_str(value: Any) -> str | None:
|
|
255
276
|
return None if value is None else str(value)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _latest_end(previous: AgentEnd | None, end: AgentEnd) -> AgentEnd:
|
|
280
|
+
"""The newer run's end, keeping the last text a run produced (a failed retry has none)."""
|
|
281
|
+
if previous is not None and not end.final_text:
|
|
282
|
+
return replace(end, final_text=previous.final_text)
|
|
283
|
+
return end
|
|
@@ -16,6 +16,13 @@ git repo, for use as test fixtures for the pidriver ↔ omp-rpc mapping.
|
|
|
16
16
|
> agent's **stdin**, so they do not appear on stdout. The two response fixtures under
|
|
17
17
|
> `frames/` are reconstructed from what the driver sent (id matches the paired request).
|
|
18
18
|
|
|
19
|
+
## Upstream pi
|
|
20
|
+
|
|
21
|
+
`pi-1.5.0/turns_and_abort.rpc.jsonl` is a real `pi --mode rpc` session of
|
|
22
|
+
`@earendil-works/pi-coding-agent@1.5.0` (OpenRouter, captured 2026-11-08, paths scrubbed): a turn with a
|
|
23
|
+
`write`, a follow-up turn with an edit, and a turn aborted during `bash`. Upstream pi ends each low-level
|
|
24
|
+
run with `agent_end` (`willRetry`) and the turn with `agent_settled` (`aborted` since 1.1.0).
|
|
25
|
+
|
|
19
26
|
## Layout
|
|
20
27
|
|
|
21
28
|
### `sessions/` — full real sessions (verbatim stdout)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Tests for pidriver.auth — OAuth context, code extraction, and node guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from pidriver import PiConfig
|
|
11
|
+
from pidriver.auth import (
|
|
12
|
+
PiOAuthLogin,
|
|
13
|
+
auth_context,
|
|
14
|
+
extract_oauth_code,
|
|
15
|
+
helper_script_path,
|
|
16
|
+
)
|
|
17
|
+
from pidriver.errors import PiInstallError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize(
|
|
21
|
+
("raw", "expected"),
|
|
22
|
+
[
|
|
23
|
+
("abc123", "abc123"),
|
|
24
|
+
(" abc123 ", "abc123"),
|
|
25
|
+
("http://localhost/callback?code=xyz&state=1", "xyz"),
|
|
26
|
+
("code=xyz", "xyz"),
|
|
27
|
+
("foo?code=deep", "deep"),
|
|
28
|
+
("", ""),
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
def test_extract_oauth_code(raw: str, expected: str) -> None:
|
|
32
|
+
assert extract_oauth_code(raw) == expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_helper_script_ships_and_is_readable() -> None:
|
|
36
|
+
path = helper_script_path()
|
|
37
|
+
assert path.name == "pi_login.mjs"
|
|
38
|
+
assert "AuthStorage" in path.read_text(encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _seed_install(tmp_path: Path) -> Path:
|
|
42
|
+
install_dir = tmp_path / "pi"
|
|
43
|
+
pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
|
|
44
|
+
pkg.mkdir(parents=True)
|
|
45
|
+
return install_dir
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_auth_context_builds_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
49
|
+
monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
|
|
50
|
+
install_dir = _seed_install(tmp_path)
|
|
51
|
+
agent_dir = tmp_path / "agent"
|
|
52
|
+
config = PiConfig(install_dir=install_dir, agent_dir=agent_dir)
|
|
53
|
+
|
|
54
|
+
auth_path, env = auth_context(config)
|
|
55
|
+
assert auth_path == agent_dir / "auth.json"
|
|
56
|
+
assert env["PI_NODE_MODULES"] == str(install_dir / "lib" / "node_modules")
|
|
57
|
+
assert env["PI_AUTH_PATH"] == str(agent_dir / "auth.json")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_auth_context_requires_agent_dir(tmp_path: Path) -> None:
|
|
61
|
+
with pytest.raises(PiInstallError):
|
|
62
|
+
auth_context(PiConfig(install_dir=_seed_install(tmp_path)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_auth_context_requires_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
66
|
+
monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
|
|
67
|
+
config = PiConfig(install_dir=tmp_path / "empty", agent_dir=tmp_path / "agent")
|
|
68
|
+
with pytest.raises(PiInstallError):
|
|
69
|
+
auth_context(config)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_start_without_node_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
73
|
+
monkeypatch.setattr("pidriver.auth.shutil.which", lambda name, **kw: None)
|
|
74
|
+
login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
|
|
75
|
+
with pytest.raises(PiInstallError):
|
|
76
|
+
await login.start("anthropic")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_submit_code_unknown_session(tmp_path: Path) -> None:
|
|
80
|
+
login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
|
|
81
|
+
with pytest.raises(KeyError):
|
|
82
|
+
await login.submit_code("nope", "code")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The helper against stand-ins for pi's two login APIs: ModelRuntime (pi 1.x), AuthStorage (before).
|
|
86
|
+
_MODEL_RUNTIME = """
|
|
87
|
+
export class ModelRuntime {
|
|
88
|
+
static async create() { return new ModelRuntime(); }
|
|
89
|
+
getProviders() {
|
|
90
|
+
return [{ id: "sub", name: "Sub", auth: { oauth: { name: "Sub login" } } },
|
|
91
|
+
{ id: "key", name: "Key", auth: { apiKey: { name: "Key" } } }];
|
|
92
|
+
}
|
|
93
|
+
async listCredentials() { return [{ providerId: "sub", type: "oauth" }]; }
|
|
94
|
+
async logout() {}
|
|
95
|
+
async login(id, type, interaction) {
|
|
96
|
+
if (id !== "sub" || type !== "oauth") throw new Error("wrong login: " + id + " " + type);
|
|
97
|
+
const method = await interaction.prompt({ type: "select", message: "How?", options: [
|
|
98
|
+
{ id: "device", label: "Device code" }, { id: "web", label: "Browser" }] });
|
|
99
|
+
if (method !== "web") throw new Error("wrong method: " + method);
|
|
100
|
+
interaction.notify({ type: "progress", message: "starting" });
|
|
101
|
+
interaction.notify({ type: "auth_url", url: "https://auth.test/go", instructions: "Open it" });
|
|
102
|
+
const code = await interaction.prompt({ type: "manual_code", message: "Paste it" });
|
|
103
|
+
if (code !== "abc") throw new Error("wrong code: " + code);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
_AUTH_STORAGE = """
|
|
109
|
+
export class AuthStorage {
|
|
110
|
+
static create() { return new AuthStorage(); }
|
|
111
|
+
getOAuthProviders() { return [{ id: "old", name: "Old" }]; }
|
|
112
|
+
getAll() { return { old: { type: "oauth" } }; }
|
|
113
|
+
logout() {}
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
_needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _login_with(tmp_path: Path, source: str) -> PiOAuthLogin:
|
|
121
|
+
install_dir = _seed_install(tmp_path)
|
|
122
|
+
pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
|
|
123
|
+
(pkg / "package.json").write_text('{"type": "module", "main": "./index.js"}')
|
|
124
|
+
(pkg / "index.js").write_text(source)
|
|
125
|
+
return PiOAuthLogin(PiConfig(install_dir=install_dir, agent_dir=tmp_path / "agent"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@_needs_node
|
|
129
|
+
async def test_helper_lists_oauth_providers_of_model_runtime(tmp_path: Path) -> None:
|
|
130
|
+
assert await _login_with(tmp_path, _MODEL_RUNTIME).list_providers() == {
|
|
131
|
+
"providers": [{"id": "sub", "name": "Sub login"}],
|
|
132
|
+
"status": {"sub": "oauth"},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@_needs_node
|
|
137
|
+
async def test_helper_logs_in_through_model_runtime(tmp_path: Path) -> None:
|
|
138
|
+
login = _login_with(tmp_path, _MODEL_RUNTIME)
|
|
139
|
+
info = await login.start("sub")
|
|
140
|
+
assert info["url"] == "https://auth.test/go"
|
|
141
|
+
assert info["instructions"] == "Open it"
|
|
142
|
+
await login.submit_code(info["session"], "http://localhost/?code=abc")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@_needs_node
|
|
146
|
+
async def test_helper_still_works_with_auth_storage(tmp_path: Path) -> None:
|
|
147
|
+
assert await _login_with(tmp_path, _AUTH_STORAGE).list_providers() == {
|
|
148
|
+
"providers": [{"id": "old", "name": "Old"}],
|
|
149
|
+
"status": {"old": "oauth"},
|
|
150
|
+
}
|
|
@@ -158,3 +158,9 @@ def test_offline_env() -> None:
|
|
|
158
158
|
def test_extra_env_wins_over_defaults() -> None:
|
|
159
159
|
env = PiConfig(extra_env={"PI_SKIP_VERSION_CHECK": "0"}).to_env(base_environ={})
|
|
160
160
|
assert env["PI_SKIP_VERSION_CHECK"] == "0"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_provider_is_passed_only_with_a_model() -> None:
|
|
164
|
+
assert "--provider" not in PiConfig(provider="openrouter").to_argv()
|
|
165
|
+
argv = PiConfig(provider="openrouter", model="deepseek/x").to_argv()
|
|
166
|
+
assert argv[argv.index("--provider") + 1] == "openrouter"
|
|
@@ -14,7 +14,7 @@ from pidriver.errors import PiInstallError, PiNotFoundError
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def make_runner(
|
|
17
|
-
install_dir: Path | None = None, *, version: str = "
|
|
17
|
+
install_dir: Path | None = None, *, version: str = "1.5.0", record: list | None = None
|
|
18
18
|
):
|
|
19
19
|
"""A subprocess.run stand-in covering: npm install (creates the pi shim), `pi --version`
|
|
20
20
|
(returns the version), and `pi install <source>` (records, succeeds)."""
|
|
@@ -53,20 +53,20 @@ def _npm_on_path(monkeypatch: pytest.MonkeyPatch):
|
|
|
53
53
|
|
|
54
54
|
def test_install_pi_runs_npm_and_verifies(tmp_path: Path) -> None:
|
|
55
55
|
rec: list = []
|
|
56
|
-
dest = install_pi(tmp_path / "pi", "
|
|
56
|
+
dest = install_pi(tmp_path / "pi", "1.5.0", runner=make_runner(tmp_path / "pi", record=rec))
|
|
57
57
|
assert dest == managed_binary_path(tmp_path / "pi")
|
|
58
58
|
assert dest.exists()
|
|
59
59
|
npm_cmd = next(c for c in rec if "--prefix" in c)
|
|
60
60
|
assert npm_cmd[:3] == ["/usr/bin/npm", "install", "-g"]
|
|
61
61
|
assert "--ignore-scripts" in npm_cmd and "--min-release-age=0" in npm_cmd
|
|
62
62
|
assert str(tmp_path / "pi") in npm_cmd
|
|
63
|
-
assert "@earendil-works/pi-coding-agent@
|
|
63
|
+
assert "@earendil-works/pi-coding-agent@1.5.0" in npm_cmd
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def test_install_pi_missing_npm_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
67
67
|
monkeypatch.setattr("pidriver._install.shutil.which", lambda name: None)
|
|
68
68
|
with pytest.raises(PiInstallError, match="npm not found"):
|
|
69
|
-
install_pi(tmp_path / "pi", "
|
|
69
|
+
install_pi(tmp_path / "pi", "1.5.0", runner=boom)
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
def test_install_pi_npm_failure_raises(tmp_path: Path) -> None:
|
|
@@ -74,13 +74,13 @@ def test_install_pi_npm_failure_raises(tmp_path: Path) -> None:
|
|
|
74
74
|
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="EACCES")
|
|
75
75
|
|
|
76
76
|
with pytest.raises(PiInstallError, match="npm install .* failed"):
|
|
77
|
-
install_pi(tmp_path / "pi", "
|
|
77
|
+
install_pi(tmp_path / "pi", "1.5.0", runner=runner)
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def test_install_pi_version_mismatch_raises(tmp_path: Path) -> None:
|
|
81
81
|
with pytest.raises(PiInstallError, match="unexpected version"):
|
|
82
82
|
install_pi(
|
|
83
|
-
tmp_path / "pi", "
|
|
83
|
+
tmp_path / "pi", "1.5.0",
|
|
84
84
|
runner=make_runner(tmp_path / "pi", version="0.1.0"),
|
|
85
85
|
)
|
|
86
86
|
|
|
@@ -130,12 +130,12 @@ def test_ensure_binary_upgrades_outdated_managed_copy(tmp_path: Path) -> None:
|
|
|
130
130
|
|
|
131
131
|
assert ensure_binary(cfg, runner=runner) == str(managed)
|
|
132
132
|
assert any("--prefix" in cmd for cmd in calls)
|
|
133
|
-
assert version == "
|
|
133
|
+
assert version == "1.5.0"
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
def test_ensure_binary_auto_installs_when_missing(tmp_path: Path) -> None:
|
|
137
137
|
install_dir = tmp_path / "pi"
|
|
138
|
-
cfg = PiConfig(binary="pi", install_dir=install_dir, auto_install=True, pi_version="
|
|
138
|
+
cfg = PiConfig(binary="pi", install_dir=install_dir, auto_install=True, pi_version="1.5.0")
|
|
139
139
|
resolved = ensure_binary(cfg, runner=make_runner(install_dir))
|
|
140
140
|
assert resolved == str(managed_binary_path(install_dir))
|
|
141
141
|
assert Path(resolved).exists()
|
|
@@ -137,3 +137,11 @@ async def test_session_replay_emits_text_and_tool_events():
|
|
|
137
137
|
final_text = event.final_text
|
|
138
138
|
assert {"ToolStart", "ToolEnd", "MessageDelta"} <= types
|
|
139
139
|
assert final_text # agent_end carried a final assistant message
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def test_real_pi_turns_end_when_settled():
|
|
143
|
+
"""pi 1.5.0: two finished turns and an aborted one, each one AgentEnd after agent_settled."""
|
|
144
|
+
frames = _load(FIXTURES / "pi-1.5.0" / "turns_and_abort.rpc.jsonl")
|
|
145
|
+
session = PiSession(ReplayTransport(frames))
|
|
146
|
+
ends = [(e.reason, e.final_text) async for e in session if isinstance(e, AgentEnd)]
|
|
147
|
+
assert ends == [("completed", "DONE-1"), ("completed", "DONE-2"), ("cancelled", "")]
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
10
|
from pidriver import (
|
|
11
|
+
AgentEnd,
|
|
11
12
|
AskHost,
|
|
12
13
|
AutoApprove,
|
|
13
14
|
Decision,
|
|
@@ -281,3 +282,69 @@ async def test_client_send_initial_false_skips_prompt():
|
|
|
281
282
|
client = PiClient(config=None, transport_factory=lambda cfg: transport)
|
|
282
283
|
await client.start("ignored", cwd="/proj", send_initial=False)
|
|
283
284
|
assert transport.sent == []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── End of a turn (upstream pi: agent_end, then agent_settled) ─────────────────
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _end(text: str = "", *, will_retry: bool = False) -> dict[str, Any]:
|
|
291
|
+
messages = [{"role": "assistant", "content": [{"type": "text", "text": text}]}] if text else []
|
|
292
|
+
return {"type": "agent_end", "messages": messages, "willRetry": will_retry}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _ends(records: list[dict[str, Any]]) -> tuple[PiSession, list[tuple[str, str]]]:
|
|
296
|
+
session = PiSession(FakeTransport(records))
|
|
297
|
+
ends = [(e.reason, e.final_text) async for e in session if isinstance(e, AgentEnd)]
|
|
298
|
+
return session, ends
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def test_turn_ends_at_settled_not_at_agent_end():
|
|
302
|
+
transport = FakeTransport([_end("done"), {"type": "agent_settled"}])
|
|
303
|
+
session = PiSession(transport)
|
|
304
|
+
ends = []
|
|
305
|
+
async for event in session:
|
|
306
|
+
if isinstance(event, AgentEnd):
|
|
307
|
+
assert not transport._records # agent_settled has already been read
|
|
308
|
+
ends.append((event.reason, event.final_text))
|
|
309
|
+
assert ends == [("completed", "done")]
|
|
310
|
+
assert session.ended
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def test_a_retry_is_one_turn_with_the_retrys_text():
|
|
314
|
+
_, ends = await _ends(
|
|
315
|
+
[_end(will_retry=True), {"type": "agent_start"}, _end("fixed"), {"type": "agent_settled"}]
|
|
316
|
+
)
|
|
317
|
+
assert ends == [("completed", "fixed")]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def test_the_last_text_survives_a_run_that_has_none():
|
|
321
|
+
_, ends = await _ends([_end("answer", will_retry=True), _end(), {"type": "agent_settled"}])
|
|
322
|
+
assert ends == [("completed", "answer")]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def test_two_turns_end_separately():
|
|
326
|
+
_, ends = await _ends(
|
|
327
|
+
[_end("one"), {"type": "agent_settled"}, _end("two"), {"type": "agent_settled"}]
|
|
328
|
+
)
|
|
329
|
+
assert ends == [("completed", "one"), ("completed", "two")]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def test_an_aborted_turn_is_cancelled():
|
|
333
|
+
_, ends = await _ends([_end("partial"), {"type": "agent_settled", "aborted": True}])
|
|
334
|
+
assert ends == [("cancelled", "partial")]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def test_settled_without_an_end_still_ends_the_turn():
|
|
338
|
+
_, ends = await _ends([{"type": "agent_settled"}])
|
|
339
|
+
assert ends == [("completed", "")]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def test_a_process_that_dies_before_settling_has_not_finished():
|
|
343
|
+
session, ends = await _ends([_end("half")])
|
|
344
|
+
assert ends == []
|
|
345
|
+
assert not session.ended
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def test_agent_end_without_will_retry_is_final(): # omp
|
|
349
|
+
_, ends = await _ends([{"type": "agent_end", "messages": []}])
|
|
350
|
+
assert ends == [("completed", "")]
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// pi OAuth/login helper for pidriver (driven by pidriver.auth).
|
|
3
|
-
//
|
|
4
|
-
// Reuses pi's own AuthStorage (public export of @earendil-works/pi-coding-agent, which bundles
|
|
5
|
-
// the pi-ai OAuth providers — so there is no version skew and nothing extra to install). Resolve
|
|
6
|
-
// it by running with NODE_PATH=<pi install_dir>/lib/node_modules. Credentials are read/written at
|
|
7
|
-
// PI_AUTH_PATH (the isolated <agent_dir>/auth.json).
|
|
8
|
-
//
|
|
9
|
-
// Commands:
|
|
10
|
-
// node pi_login.mjs list -> {"providers":[{id,name}], "status":{provider:type}}
|
|
11
|
-
// node pi_login.mjs logout <provider> -> {"ok":true}
|
|
12
|
-
// node pi_login.mjs login <provider> -> JSONL events on stdout; reads the auth code from stdin
|
|
13
|
-
//
|
|
14
|
-
// login protocol (one JSON object per line on stdout):
|
|
15
|
-
// {"event":"auth","url":"...","instructions":"..."} # open this URL in a browser
|
|
16
|
-
// {"event":"need_code","message":"...","placeholder":"..."} # then write the code as one stdin line
|
|
17
|
-
// {"event":"progress","message":"..."}
|
|
18
|
-
// {"event":"done"} # credentials saved to PI_AUTH_PATH
|
|
19
|
-
// {"event":"error","message":"..."}
|
|
20
|
-
|
|
21
|
-
import { readFileSync } from "node:fs";
|
|
22
|
-
import { join } from "node:path";
|
|
23
|
-
import readline from "node:readline";
|
|
24
|
-
import { pathToFileURL } from "node:url";
|
|
25
|
-
|
|
26
|
-
function emit(o) {
|
|
27
|
-
process.stdout.write(JSON.stringify(o) + "\n");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Import pi's bundled AuthStorage from the managed install (PI_NODE_MODULES = <install>/lib/node_modules).
|
|
31
|
-
// We import the package's entry by absolute path: its `exports` map only declares the ESM `import`
|
|
32
|
-
// condition, so require.resolve can't find it, but a direct file import resolves its nested pi-ai too.
|
|
33
|
-
async function loadAuthStorage() {
|
|
34
|
-
const nm = process.env.PI_NODE_MODULES;
|
|
35
|
-
if (!nm) throw new Error("PI_NODE_MODULES is not set");
|
|
36
|
-
const pkgDir = join(nm, "@earendil-works", "pi-coding-agent");
|
|
37
|
-
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
|
38
|
-
const rel = pkg?.exports?.["."]?.import || pkg.main || "./dist/index.js";
|
|
39
|
-
const mod = await import(pathToFileURL(join(pkgDir, rel)).href);
|
|
40
|
-
if (!mod.AuthStorage) throw new Error("AuthStorage not exported by pi-coding-agent");
|
|
41
|
-
return mod.AuthStorage;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function readLine() {
|
|
45
|
-
return await new Promise((resolve) => {
|
|
46
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
47
|
-
rl.once("line", (line) => {
|
|
48
|
-
rl.close();
|
|
49
|
-
resolve(String(line).trim());
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function selectLoginOption(prompt) {
|
|
55
|
-
const options = Array.isArray(prompt?.options) ? prompt.options : [];
|
|
56
|
-
const selected = options.find((option) => {
|
|
57
|
-
const haystack = String(`${option?.id || ""} ${option?.label || ""}`).toLowerCase();
|
|
58
|
-
return haystack.includes("browser") || haystack.includes("default");
|
|
59
|
-
}) || options[0];
|
|
60
|
-
emit({
|
|
61
|
-
event: "select",
|
|
62
|
-
message: prompt?.message || "Select login method",
|
|
63
|
-
options: options.map((option) => ({ id: option.id, label: option.label })),
|
|
64
|
-
selected: selected?.id || "",
|
|
65
|
-
});
|
|
66
|
-
return selected?.id;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function main() {
|
|
70
|
-
const AuthStorage = await loadAuthStorage();
|
|
71
|
-
const storage = AuthStorage.create(process.env.PI_AUTH_PATH);
|
|
72
|
-
const [cmd, providerId] = process.argv.slice(2);
|
|
73
|
-
|
|
74
|
-
if (cmd === "list") {
|
|
75
|
-
const providers = storage.getOAuthProviders().map((p) => ({ id: p.id, name: p.name }));
|
|
76
|
-
const all = storage.getAll() || {};
|
|
77
|
-
const status = {};
|
|
78
|
-
for (const [k, v] of Object.entries(all)) status[k] = v && v.type ? v.type : "unknown";
|
|
79
|
-
emit({ providers, status });
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (cmd === "logout") {
|
|
84
|
-
if (!providerId) throw new Error("logout requires a provider id");
|
|
85
|
-
storage.logout(providerId);
|
|
86
|
-
emit({ ok: true });
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (cmd === "login") {
|
|
91
|
-
if (!providerId) throw new Error("login requires a provider id");
|
|
92
|
-
const askCode = async (prompt) => {
|
|
93
|
-
emit({ event: "need_code", message: (prompt && prompt.message) || "Paste the authorization code",
|
|
94
|
-
placeholder: (prompt && prompt.placeholder) || "" });
|
|
95
|
-
return await readLine();
|
|
96
|
-
};
|
|
97
|
-
await storage.login(providerId, {
|
|
98
|
-
onAuth: (info) => emit({ event: "auth", url: info.url, instructions: info.instructions || "" }),
|
|
99
|
-
onDeviceCode: (info) => emit({ event: "device_code", ...info }),
|
|
100
|
-
onProgress: (m) => emit({ event: "progress", message: String(m) }),
|
|
101
|
-
onManualCodeInput: async () => askCode(),
|
|
102
|
-
onPrompt: async (prompt) => askCode(prompt),
|
|
103
|
-
onSelect: async (prompt) => selectLoginOption(prompt),
|
|
104
|
-
});
|
|
105
|
-
emit({ event: "done" });
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
throw new Error(`unknown command: ${cmd}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
main().catch((e) => {
|
|
113
|
-
emit({ event: "error", message: (e && e.message) || String(e) });
|
|
114
|
-
process.exit(1);
|
|
115
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Tests for pidriver.auth — OAuth context, code extraction, and node guard."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from pidriver import PiConfig
|
|
10
|
-
from pidriver.auth import (
|
|
11
|
-
PiOAuthLogin,
|
|
12
|
-
auth_context,
|
|
13
|
-
extract_oauth_code,
|
|
14
|
-
helper_script_path,
|
|
15
|
-
)
|
|
16
|
-
from pidriver.errors import PiInstallError
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@pytest.mark.parametrize(
|
|
20
|
-
("raw", "expected"),
|
|
21
|
-
[
|
|
22
|
-
("abc123", "abc123"),
|
|
23
|
-
(" abc123 ", "abc123"),
|
|
24
|
-
("http://localhost/callback?code=xyz&state=1", "xyz"),
|
|
25
|
-
("code=xyz", "xyz"),
|
|
26
|
-
("foo?code=deep", "deep"),
|
|
27
|
-
("", ""),
|
|
28
|
-
],
|
|
29
|
-
)
|
|
30
|
-
def test_extract_oauth_code(raw: str, expected: str) -> None:
|
|
31
|
-
assert extract_oauth_code(raw) == expected
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_helper_script_ships_and_is_readable() -> None:
|
|
35
|
-
path = helper_script_path()
|
|
36
|
-
assert path.name == "pi_login.mjs"
|
|
37
|
-
assert "AuthStorage" in path.read_text(encoding="utf-8")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _seed_install(tmp_path: Path) -> Path:
|
|
41
|
-
install_dir = tmp_path / "pi"
|
|
42
|
-
pkg = install_dir / "lib" / "node_modules" / "@earendil-works" / "pi-coding-agent"
|
|
43
|
-
pkg.mkdir(parents=True)
|
|
44
|
-
return install_dir
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_auth_context_builds_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
48
|
-
monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
|
|
49
|
-
install_dir = _seed_install(tmp_path)
|
|
50
|
-
agent_dir = tmp_path / "agent"
|
|
51
|
-
config = PiConfig(install_dir=install_dir, agent_dir=agent_dir)
|
|
52
|
-
|
|
53
|
-
auth_path, env = auth_context(config)
|
|
54
|
-
assert auth_path == agent_dir / "auth.json"
|
|
55
|
-
assert env["PI_NODE_MODULES"] == str(install_dir / "lib" / "node_modules")
|
|
56
|
-
assert env["PI_AUTH_PATH"] == str(agent_dir / "auth.json")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_auth_context_requires_agent_dir(tmp_path: Path) -> None:
|
|
60
|
-
with pytest.raises(PiInstallError):
|
|
61
|
-
auth_context(PiConfig(install_dir=_seed_install(tmp_path)))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_auth_context_requires_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
65
|
-
monkeypatch.setattr("pidriver._install.shutil.which", lambda name, **kw: None)
|
|
66
|
-
config = PiConfig(install_dir=tmp_path / "empty", agent_dir=tmp_path / "agent")
|
|
67
|
-
with pytest.raises(PiInstallError):
|
|
68
|
-
auth_context(config)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
async def test_start_without_node_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
|
-
monkeypatch.setattr("pidriver.auth.shutil.which", lambda name, **kw: None)
|
|
73
|
-
login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
|
|
74
|
-
with pytest.raises(PiInstallError):
|
|
75
|
-
await login.start("anthropic")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
async def test_submit_code_unknown_session(tmp_path: Path) -> None:
|
|
79
|
-
login = PiOAuthLogin(PiConfig(agent_dir=tmp_path / "agent"))
|
|
80
|
-
with pytest.raises(KeyError):
|
|
81
|
-
await login.submit_code("nope", "code")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.approval.json
RENAMED
|
File without changes
|
{pidriver-0.4.2 → pidriver-0.5.0}/tests/fixtures/frames/extension_ui_request.select.ask.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|