@av-pi-studio/server 0.0.92 → 0.0.93

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.
@@ -30,4 +30,20 @@ export declare function resolvePiAgentDir(config: PersistedConfig): string | und
30
30
  * single Pi-Studio setting redirects the bundled Pi CLI's entire `~/.pi/agent` tree (models.json,
31
31
  * auth.json, settings.json, sessions/, …) to a custom directory. */
32
32
  export declare function piHomeEnv(config: PersistedConfig): Record<string, string>;
33
+ /** Resolved `auth.json`/`models.json` paths; `undefined` fields let Pi's own defaults decide.
34
+ * Mirrors {@link PiAuthPaths} in `packages/cli/src/auth-runtime.ts` — this is the daemon-side
35
+ * sibling, deliberately not a shared import (that module belongs to a different package). */
36
+ export interface PiAuthPaths {
37
+ authPath?: string;
38
+ modelsPath?: string;
39
+ }
40
+ /**
41
+ * Derive `auth.json`/`models.json` from {@link resolvePiAgentDir} — the single intentional
42
+ * coupling point between the provider-auth RPC family and the spawn path
43
+ * (features/provider-auth-rpc.md § New/changed files). A credential written at this path MUST be
44
+ * the one a daemon-spawned `pi --mode rpc` child reads via `piHomeEnv()`'s
45
+ * `PI_CODING_AGENT_DIR`/`PI_CODING_AGENT_SESSION_DIR`, which is why this derives from the same
46
+ * `resolvePiAgentDir` rather than re-deriving the precedence independently.
47
+ */
48
+ export declare function resolvePiAuthPaths(config: PersistedConfig): PiAuthPaths;
33
49
  //# sourceMappingURL=pi-home.d.ts.map
@@ -46,4 +46,21 @@ export function piHomeEnv(config) {
46
46
  PI_CODING_AGENT_SESSION_DIR: join(agentDir, "sessions"),
47
47
  };
48
48
  }
49
+ /**
50
+ * Derive `auth.json`/`models.json` from {@link resolvePiAgentDir} — the single intentional
51
+ * coupling point between the provider-auth RPC family and the spawn path
52
+ * (features/provider-auth-rpc.md § New/changed files). A credential written at this path MUST be
53
+ * the one a daemon-spawned `pi --mode rpc` child reads via `piHomeEnv()`'s
54
+ * `PI_CODING_AGENT_DIR`/`PI_CODING_AGENT_SESSION_DIR`, which is why this derives from the same
55
+ * `resolvePiAgentDir` rather than re-deriving the precedence independently.
56
+ */
57
+ export function resolvePiAuthPaths(config) {
58
+ const agentDir = resolvePiAgentDir(config);
59
+ if (!agentDir)
60
+ return {};
61
+ return {
62
+ authPath: join(agentDir, "auth.json"),
63
+ modelsPath: join(agentDir, "models.json"),
64
+ };
65
+ }
49
66
  //# sourceMappingURL=pi-home.js.map
@@ -0,0 +1,123 @@
1
+ import type { PiAuthPaths } from "../pi-home.js";
2
+ /**
3
+ * Daemon-side seam onto Pi's `ModelRuntime` auth engine (swe/features/provider-auth-rpc.md § New/
4
+ * changed files, § Behavior & Algorithms — runtime bullet). This is the daemon's sibling of
5
+ * `packages/cli/src/auth-runtime.ts`'s `AuthRuntime` — deliberately not shared (different package,
6
+ * different owner this sprint) but structurally the same idea: hide `ModelRuntime` behind a small
7
+ * interface so the flow service (task-003) and its tests never import a Pi type directly.
8
+ *
9
+ * **Lazy `import()` here is not a startup optimization.** The daemon already statically imports
10
+ * `@earendil-works/pi-coding-agent` (`agent/providers/pi/session-hydration.ts` imports
11
+ * `SessionManager`), so the module graph is already paid for. It is lazy so a daemon whose Pi auth
12
+ * runtime cannot be constructed (e.g. a corrupt `auth.json`) still boots and serves every other
13
+ * RPC, failing only this family — and so construction is *retried* on the next call rather than
14
+ * poisoning the service for the daemon's lifetime.
15
+ */
16
+ export interface AuthPromptTextLike {
17
+ signal?: AbortSignal;
18
+ type: "text";
19
+ message: string;
20
+ placeholder?: string;
21
+ }
22
+ export interface AuthPromptSecretLike {
23
+ signal?: AbortSignal;
24
+ type: "secret";
25
+ message: string;
26
+ placeholder?: string;
27
+ }
28
+ export interface AuthPromptSelectLike {
29
+ signal?: AbortSignal;
30
+ type: "select";
31
+ message: string;
32
+ options: readonly {
33
+ id: string;
34
+ label: string;
35
+ description?: string;
36
+ }[];
37
+ }
38
+ export interface AuthPromptManualCodeLike {
39
+ signal?: AbortSignal;
40
+ type: "manual_code";
41
+ message: string;
42
+ placeholder?: string;
43
+ }
44
+ export type AuthPromptLike = AuthPromptTextLike | AuthPromptSecretLike | AuthPromptSelectLike | AuthPromptManualCodeLike;
45
+ export interface AuthEventInfoLike {
46
+ type: "info";
47
+ message: string;
48
+ links?: readonly {
49
+ url: string;
50
+ label?: string;
51
+ }[];
52
+ }
53
+ export interface AuthEventAuthUrlLike {
54
+ type: "auth_url";
55
+ url: string;
56
+ instructions?: string;
57
+ }
58
+ export interface AuthEventDeviceCodeLike {
59
+ type: "device_code";
60
+ userCode: string;
61
+ verificationUri: string;
62
+ intervalSeconds?: number;
63
+ expiresInSeconds?: number;
64
+ }
65
+ export interface AuthEventProgressLike {
66
+ type: "progress";
67
+ message: string;
68
+ }
69
+ export type AuthEventLike = AuthEventInfoLike | AuthEventAuthUrlLike | AuthEventDeviceCodeLike | AuthEventProgressLike;
70
+ export interface AuthInteractionLike {
71
+ signal?: AbortSignal;
72
+ prompt(prompt: AuthPromptLike): Promise<string>;
73
+ notify(event: AuthEventLike): void;
74
+ }
75
+ /** A login-capable provider, before any auth-state check (that is {@link PiAuthCheckResult}, a
76
+ * separate bounded call — `listProviders()` never blocks on it). */
77
+ export interface PiAuthProviderInfo {
78
+ id: string;
79
+ name: string;
80
+ authTypes: ("api_key" | "oauth")[];
81
+ oauthLoginLabel?: string;
82
+ oauthIsSubscription?: boolean;
83
+ }
84
+ /** `configured: "unknown"` means the bounded probe exceeded its timeout (sprint-054's
85
+ * `checkAuthBounded` precedent — some ambient checks, e.g. an AWS-profile or ADC-file probe, can
86
+ * hang). Never conflate `"unknown"` with `false`: the caller must not report a credential absent
87
+ * when it simply could not be confirmed in time. */
88
+ export interface PiAuthCheckResult {
89
+ configured: boolean | "unknown";
90
+ type?: "api_key" | "oauth";
91
+ source?: string;
92
+ }
93
+ export interface PiAuthRuntime {
94
+ listProviders(): Promise<PiAuthProviderInfo[]>;
95
+ checkAuth(providerId: string): Promise<PiAuthCheckResult>;
96
+ /** `signal` is the caller's flow-wide `AbortController.signal` (task-003) — merged onto the
97
+ * interaction so Pi's own `interaction.signal` race (login rejects with Pi's generic
98
+ * `AbortError` on abort) works whether or not the caller-supplied `interaction` already carries
99
+ * one. */
100
+ login(providerId: string, authType: "api_key" | "oauth", interaction: AuthInteractionLike, signal?: AbortSignal): Promise<{
101
+ type: "api_key" | "oauth";
102
+ }>;
103
+ /** Re-checks after removal so the caller can report an ambient credential (e.g. an env var)
104
+ * surviving the logout, rather than silently claiming success. */
105
+ logout(providerId: string): Promise<{
106
+ stillConfigured: boolean;
107
+ }>;
108
+ /** Resolved `auth.json` path, for status/log messages — never the credential itself. */
109
+ authPathLabel(): string;
110
+ }
111
+ /** Bound for {@link PiAuthRuntime.checkAuth}; overridable per-instance for tests (fake timers). */
112
+ export declare const DEFAULT_CHECK_AUTH_TIMEOUT_MS = 3000;
113
+ export interface CreatePiAuthRuntimeOptions {
114
+ checkAuthTimeoutMs?: number;
115
+ }
116
+ /**
117
+ * Production `PiAuthRuntime`. Creates Pi's `ModelRuntime` once, lazily, on the first method call —
118
+ * never at module load or construction time — and caches the promise so repeated calls reuse the
119
+ * same instance. On a failed construction the cached promise is cleared so the *next* call retries
120
+ * rather than the daemon staying poisoned until restart.
121
+ */
122
+ export declare function createPiAuthRuntime(paths: PiAuthPaths, opts?: CreatePiAuthRuntimeOptions): PiAuthRuntime;
123
+ //# sourceMappingURL=pi-auth-runtime.d.ts.map
@@ -0,0 +1,103 @@
1
+ /** Bound for {@link PiAuthRuntime.checkAuth}; overridable per-instance for tests (fake timers). */
2
+ export const DEFAULT_CHECK_AUTH_TIMEOUT_MS = 3000;
3
+ /**
4
+ * Production `PiAuthRuntime`. Creates Pi's `ModelRuntime` once, lazily, on the first method call —
5
+ * never at module load or construction time — and caches the promise so repeated calls reuse the
6
+ * same instance. On a failed construction the cached promise is cleared so the *next* call retries
7
+ * rather than the daemon staying poisoned until restart.
8
+ */
9
+ export function createPiAuthRuntime(paths, opts) {
10
+ const checkAuthTimeoutMs = opts?.checkAuthTimeoutMs ?? DEFAULT_CHECK_AUTH_TIMEOUT_MS;
11
+ let runtimePromise = null;
12
+ // Deliberate `await import()`, not a static import — see the module-level doc comment.
13
+ function getRuntime() {
14
+ if (!runtimePromise) {
15
+ const promise = (async () => {
16
+ // Deliberate `await import()`, not a static import: deferring past module load lets a
17
+ // daemon whose Pi auth runtime cannot construct still boot and serve every other RPC (see
18
+ // the module-level doc comment) — the module specifier is fixed, but the *timing* is the
19
+ // point, not a runtime-selected path.
20
+ const { ModelRuntime: ModelRuntimeCtor } = await import("@earendil-works/pi-coding-agent");
21
+ return ModelRuntimeCtor.create({
22
+ authPath: paths.authPath,
23
+ modelsPath: paths.modelsPath,
24
+ refreshOnCreate: false,
25
+ });
26
+ })();
27
+ runtimePromise = promise;
28
+ // A transient failure (e.g. a momentarily-locked auth.json) must not poison every later
29
+ // call — clear the cache so the next getRuntime() retries construction from scratch.
30
+ promise.catch(() => {
31
+ if (runtimePromise === promise)
32
+ runtimePromise = null;
33
+ });
34
+ }
35
+ return runtimePromise;
36
+ }
37
+ async function checkAuth(providerId) {
38
+ const runtime = await getRuntime();
39
+ const { promise: timeout, resolve: resolveTimeout } = Promise.withResolvers();
40
+ const timer = setTimeout(() => resolveTimeout("unknown"), checkAuthTimeoutMs);
41
+ try {
42
+ const result = await Promise.race([runtime.checkAuth(providerId), timeout]);
43
+ if (result === "unknown")
44
+ return { configured: "unknown" };
45
+ if (!result)
46
+ return { configured: false };
47
+ return { configured: true, type: result.type, source: result.source };
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+ return {
54
+ async listProviders() {
55
+ const runtime = await getRuntime();
56
+ const out = [];
57
+ for (const p of runtime.getProviders()) {
58
+ try {
59
+ const canApiKeyLogin = p.auth?.apiKey?.login !== undefined;
60
+ const canOAuthLogin = p.auth?.oauth !== undefined;
61
+ if (!canApiKeyLogin && !canOAuthLogin)
62
+ continue;
63
+ const authTypes = [];
64
+ if (canApiKeyLogin)
65
+ authTypes.push("api_key");
66
+ if (canOAuthLogin)
67
+ authTypes.push("oauth");
68
+ out.push({
69
+ id: p.id,
70
+ name: p.name,
71
+ authTypes,
72
+ oauthLoginLabel: p.auth?.oauth?.loginLabel,
73
+ oauthIsSubscription: p.auth?.oauth?.isSubscription,
74
+ });
75
+ }
76
+ catch {
77
+ // Malformed provider entry (unexpected shape) — skip it, never let one bad provider
78
+ // take down the whole listing.
79
+ }
80
+ }
81
+ return out;
82
+ },
83
+ checkAuth,
84
+ async login(providerId, authType, interaction, signal) {
85
+ const runtime = await getRuntime();
86
+ const credential = await runtime.login(providerId, authType, {
87
+ ...interaction,
88
+ signal: signal ?? interaction.signal,
89
+ });
90
+ return { type: credential.type };
91
+ },
92
+ async logout(providerId) {
93
+ const runtime = await getRuntime();
94
+ await runtime.logout(providerId);
95
+ const recheck = await checkAuth(providerId);
96
+ return { stillConfigured: recheck.configured === true };
97
+ },
98
+ authPathLabel() {
99
+ return paths.authPath ?? "<default Pi auth path>";
100
+ },
101
+ };
102
+ }
103
+ //# sourceMappingURL=pi-auth-runtime.js.map
@@ -0,0 +1,29 @@
1
+ import type { Logger } from "../../logging/logger.js";
2
+ import type { HandlerRegistry } from "../../ws/router.js";
3
+ import type { ProviderAuthService } from "./provider-auth-service.js";
4
+ /**
5
+ * Wires the five `provider_auth_*` RPCs (swe/features/provider-auth-rpc.md § Public Contract) onto
6
+ * `ProviderAuthService`. Modelled directly on `registerFileWatchHandlers`/
7
+ * `registerGitCheckoutHandlers`: a thin adapter that stamps no policy of its own — ownership,
8
+ * idempotency, and every error code already live in the service (task-003). This module's only
9
+ * job is coercing wire input defensively, since the router never validates a session message's
10
+ * shape before dispatch (see `ws/router.ts`'s `routeTextFrame`).
11
+ *
12
+ * Unlike its two siblings, this module does **not** touch `SessionSubscriptions` itself —
13
+ * `ProviderAuthService` owns that entry directly (constructed with a `subscriptions` dep in
14
+ * `bootstrap.ts`). See `provider-auth-service.ts`'s class doc comment for why: the RPC layer
15
+ * deciding whether to `subscriptions.add()` based on the *result* of an awaited `login()` call has
16
+ * a real race against the fire-and-forget flow settling before that await returns, which can leave
17
+ * a stale disposer that's added after the flow it names already ended. Registering and clearing the
18
+ * subscription inside `login()`/`settleFlow()`'s own synchronous stretches removes that race by
19
+ * construction.
20
+ *
21
+ * Production-bootstrap only (`daemon/bootstrap.ts`) — `dev-bootstrap.ts` must never call this; the
22
+ * dev daemon's minimal handler set answers `unknown_message_type` for all five types instead.
23
+ */
24
+ export interface ProviderAuthRpcDeps {
25
+ providerAuthService: ProviderAuthService;
26
+ logger?: Logger;
27
+ }
28
+ export declare function registerProviderAuthHandlers(registry: HandlerRegistry, deps: ProviderAuthRpcDeps): void;
29
+ //# sourceMappingURL=provider-auth-rpc.d.ts.map
@@ -0,0 +1,43 @@
1
+ function isProviderAuthType(value) {
2
+ return value === "api_key" || value === "oauth";
3
+ }
4
+ export function registerProviderAuthHandlers(registry, deps) {
5
+ const { providerAuthService, logger } = deps;
6
+ registry.register("provider_auth_list_request", async () => {
7
+ const payload = await providerAuthService.listProviders();
8
+ return { type: "provider_auth_list_response", payload };
9
+ });
10
+ registry.register("provider_auth_login_request", async (ctx) => {
11
+ const provider = String(ctx.message.provider ?? "");
12
+ const authType = ctx.message.authType;
13
+ if (!isProviderAuthType(authType)) {
14
+ logger?.debug({ provider, authType }, "provider-auth: login request with unsupported authType");
15
+ return {
16
+ type: "provider_auth_login_response",
17
+ payload: { ok: false, error: "unsupported_auth_type" },
18
+ };
19
+ }
20
+ // Provider existence is the service's own call (`unknown_provider`) — this handler adds no
21
+ // second opinion on top of it.
22
+ const payload = await providerAuthService.login(ctx.session, provider, authType);
23
+ return { type: "provider_auth_login_response", payload };
24
+ });
25
+ registry.register("provider_auth_respond_request", (ctx) => {
26
+ const flowId = String(ctx.message.flowId ?? "");
27
+ const promptId = String(ctx.message.promptId ?? "");
28
+ const value = typeof ctx.message.value === "string" ? ctx.message.value : "";
29
+ const payload = providerAuthService.respond(ctx.session, flowId, promptId, value);
30
+ return { type: "provider_auth_respond_response", payload };
31
+ });
32
+ registry.register("provider_auth_cancel_request", (ctx) => {
33
+ const flowId = String(ctx.message.flowId ?? "");
34
+ const payload = providerAuthService.cancel(ctx.session, flowId);
35
+ return { type: "provider_auth_cancel_response", payload };
36
+ });
37
+ registry.register("provider_auth_logout_request", async (ctx) => {
38
+ const provider = String(ctx.message.provider ?? "");
39
+ const payload = await providerAuthService.logout(provider);
40
+ return { type: "provider_auth_logout_response", payload };
41
+ });
42
+ }
43
+ //# sourceMappingURL=provider-auth-rpc.js.map
@@ -0,0 +1,80 @@
1
+ import type { ProviderAuthInfo, ProviderAuthType } from "@av-pi-studio/protocol";
2
+ import type { Logger } from "../../logging/logger.js";
3
+ import type { SessionSubscriptions } from "../../ws/session-subscriptions.js";
4
+ import type { Session } from "../../ws/session.js";
5
+ import type { PiAuthRuntime } from "./pi-auth-runtime.js";
6
+ /** Namespaced like `file_watch:`/`checkout_status:` — `SessionSubscriptions` is domain-agnostic
7
+ * and shared across families. */
8
+ export declare const PROVIDER_AUTH_FLOW_KEY_PREFIX = "provider_auth_flow:";
9
+ export interface ProviderAuthServiceDeps {
10
+ runtime: PiAuthRuntime;
11
+ logger?: Logger;
12
+ /** Flow time-to-live before an auto-cancel with `error: "timeout"`. Defaults to 10 minutes. */
13
+ ttlMs?: number;
14
+ /** Timer seams so tests use fake timers instead of wall-clock waits. */
15
+ setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
16
+ clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
17
+ /** Per-session subscription registry. When provided, `login` registers a
18
+ * `provider_auth_flow:<flowId>` disposer (`() => this.cancel(session, flowId)`) synchronously
19
+ * in the same tick the flow is created, and `settleFlow` removes it — see the class doc comment
20
+ * for why both live here instead of split across this service and the RPC layer. Omit only in
21
+ * tests that don't care about disconnect-cancellation or subscription bookkeeping. */
22
+ subscriptions?: SessionSubscriptions;
23
+ }
24
+ export declare class ProviderAuthService {
25
+ private readonly runtime;
26
+ private readonly logger;
27
+ private readonly ttlMs;
28
+ private readonly setTimer;
29
+ private readonly clearTimer;
30
+ private readonly subscriptions;
31
+ private readonly flows;
32
+ private readonly flowIdBySession;
33
+ constructor(deps: ProviderAuthServiceDeps);
34
+ /** `getProviders()` composed with a bounded per-provider `checkAuth()` (already bounded inside
35
+ * `PiAuthRuntime` — one bound suffices). A hung/failed single provider degrades that row to
36
+ * `configured: "unknown"`, never the whole list. */
37
+ listProviders(): Promise<{
38
+ ok: boolean;
39
+ providers: ProviderAuthInfo[];
40
+ error?: string;
41
+ }>;
42
+ login(session: Session, provider: string, authType: ProviderAuthType): Promise<{
43
+ ok: boolean;
44
+ flowId?: string;
45
+ error?: string;
46
+ }>;
47
+ respond(session: Session, flowId: string, promptId: string, value: string): {
48
+ ok: boolean;
49
+ error?: string;
50
+ };
51
+ /** Unconditionally idempotent — see the class doc comment on why this never reports `not_found`
52
+ * the way `respond` does. */
53
+ cancel(session: Session, flowId: string): {
54
+ ok: true;
55
+ };
56
+ logout(provider: string): Promise<{
57
+ ok: boolean;
58
+ stillConfigured?: boolean;
59
+ error?: string;
60
+ }>;
61
+ private runFlow;
62
+ private buildInteraction;
63
+ private handlePrompt;
64
+ /** Abort the flow's controller and settle it with a terminal `done ok:false`. Used by explicit
65
+ * `cancel`, TTL expiry, and — via the `SessionSubscriptions` disposer `login` registers, which
66
+ * fires on socket disconnect — session close. A no-op if the flow already reached a terminal
67
+ * state. */
68
+ private abortFlow;
69
+ /** The single place a flow ends: compare-and-set on `terminal` (so exactly one `done` is ever
70
+ * emitted even when a cancel races the runtime settling), clears the TTL timer, rejects any
71
+ * pending prompt, unregisters the flow, emits the terminal event, and drops the
72
+ * `SessionSubscriptions` entry `login` registered. Removing it here — not just from an explicit
73
+ * `cancel` — is what guarantees a flow that completes or times out on its own leaves no stale
74
+ * disposer behind. `SessionSubscriptions.remove` re-invokes the disposer it finds (which calls
75
+ * `cancel()` again); that's a harmless no-op re-entry by this point, since `this.flows` no
76
+ * longer has an entry for `flow.flowId` (deleted a few lines above). */
77
+ private settleFlow;
78
+ private sendFlowEvent;
79
+ }
80
+ //# sourceMappingURL=provider-auth-service.d.ts.map
@@ -0,0 +1,259 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** Namespaced like `file_watch:`/`checkout_status:` — `SessionSubscriptions` is domain-agnostic
3
+ * and shared across families. */
4
+ export const PROVIDER_AUTH_FLOW_KEY_PREFIX = "provider_auth_flow:";
5
+ const DEFAULT_FLOW_TTL_MS = 600_000;
6
+ export class ProviderAuthService {
7
+ runtime;
8
+ logger;
9
+ ttlMs;
10
+ setTimer;
11
+ clearTimer;
12
+ subscriptions;
13
+ flows = new Map();
14
+ flowIdBySession = new WeakMap();
15
+ constructor(deps) {
16
+ this.runtime = deps.runtime;
17
+ this.logger = deps.logger;
18
+ this.ttlMs = deps.ttlMs ?? DEFAULT_FLOW_TTL_MS;
19
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
20
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
21
+ this.subscriptions = deps.subscriptions;
22
+ }
23
+ /** `getProviders()` composed with a bounded per-provider `checkAuth()` (already bounded inside
24
+ * `PiAuthRuntime` — one bound suffices). A hung/failed single provider degrades that row to
25
+ * `configured: "unknown"`, never the whole list. */
26
+ async listProviders() {
27
+ let infos;
28
+ try {
29
+ infos = await this.runtime.listProviders();
30
+ }
31
+ catch {
32
+ return { ok: false, providers: [] };
33
+ }
34
+ const providers = await Promise.all(infos.map(async (info) => {
35
+ const check = await this.runtime
36
+ .checkAuth(info.id)
37
+ .catch(() => ({ configured: "unknown" }));
38
+ return {
39
+ id: info.id,
40
+ name: info.name,
41
+ authTypes: info.authTypes,
42
+ oauthLoginLabel: info.oauthLoginLabel,
43
+ oauthIsSubscription: info.oauthIsSubscription,
44
+ configured: check.configured,
45
+ configuredType: "type" in check ? check.type : undefined,
46
+ configuredSource: "source" in check ? check.source : undefined,
47
+ };
48
+ }));
49
+ return { ok: true, providers };
50
+ }
51
+ async login(session, provider, authType) {
52
+ let infos;
53
+ try {
54
+ infos = await this.runtime.listProviders();
55
+ }
56
+ catch {
57
+ return { ok: false, error: "provider_auth_unavailable" };
58
+ }
59
+ const info = infos.find((p) => p.id === provider);
60
+ if (!info)
61
+ return { ok: false, error: "unknown_provider" };
62
+ if (!info.authTypes.includes(authType))
63
+ return { ok: false, error: "unsupported_auth_type" };
64
+ // One active flow per session — a second login cancels the first (its own terminal `done`
65
+ // fires from `abortFlow` below).
66
+ const existingFlowId = this.flowIdBySession.get(session);
67
+ if (existingFlowId) {
68
+ const existing = this.flows.get(existingFlowId);
69
+ if (existing)
70
+ this.abortFlow(existing, "cancelled");
71
+ }
72
+ const flowId = randomUUID();
73
+ const abort = new AbortController();
74
+ const flow = {
75
+ flowId,
76
+ provider,
77
+ session,
78
+ abort,
79
+ timer: this.setTimer(() => this.abortFlow(flow, "timeout"), this.ttlMs),
80
+ terminal: false,
81
+ };
82
+ this.flows.set(flowId, flow);
83
+ this.flowIdBySession.set(session, flowId);
84
+ // Synchronous, same tick as flow creation — no window where `runFlow` (started right below)
85
+ // could settle before this entry exists. See the class doc comment for why this can't safely
86
+ // live one `await` away in the RPC layer instead.
87
+ this.subscriptions?.add(session, `${PROVIDER_AUTH_FLOW_KEY_PREFIX}${flowId}`, () => this.cancel(session, flowId));
88
+ this.logger?.debug({ flowId, provider, authType }, "provider-auth: flow started");
89
+ void this.runFlow(flow, authType);
90
+ return { ok: true, flowId };
91
+ }
92
+ respond(session, flowId, promptId, value) {
93
+ const flow = this.flows.get(flowId);
94
+ if (!flow ||
95
+ flow.session !== session ||
96
+ !flow.pendingPrompt ||
97
+ flow.pendingPrompt.promptId !== promptId) {
98
+ return { ok: false, error: "not_found" };
99
+ }
100
+ const pending = flow.pendingPrompt;
101
+ flow.pendingPrompt = undefined;
102
+ this.logger?.debug({ flowId, provider: flow.provider, promptId }, "provider-auth: prompt answered");
103
+ pending.resolve(value);
104
+ return { ok: true };
105
+ }
106
+ /** Unconditionally idempotent — see the class doc comment on why this never reports `not_found`
107
+ * the way `respond` does. */
108
+ cancel(session, flowId) {
109
+ const flow = this.flows.get(flowId);
110
+ if (flow && flow.session === session)
111
+ this.abortFlow(flow, "cancelled");
112
+ return { ok: true };
113
+ }
114
+ async logout(provider) {
115
+ try {
116
+ const result = await this.runtime.logout(provider);
117
+ return { ok: true, stillConfigured: result.stillConfigured };
118
+ }
119
+ catch (err) {
120
+ return { ok: false, error: sanitizeError(err) };
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Internals
125
+ // ---------------------------------------------------------------------------
126
+ async runFlow(flow, authType) {
127
+ try {
128
+ await this.runtime.login(flow.provider, authType, this.buildInteraction(flow), flow.abort.signal);
129
+ this.settleFlow(flow, { ok: true });
130
+ }
131
+ catch (err) {
132
+ // Pi's own `runtime.login()` races `interaction.signal` and rejects with its own generic
133
+ // `AbortError` (sprint-054/task-004) — never infer cancellation from the error type. Our own
134
+ // `abort.signal.aborted` is authoritative; in practice `abortFlow` has usually already
135
+ // settled this flow by the time we get here (the compare-and-set in `settleFlow` makes this
136
+ // call a no-op), but the check stays correct even if `runtime.login()` settles first.
137
+ const error = flow.abort.signal.aborted ? "cancelled" : sanitizeError(err);
138
+ this.settleFlow(flow, { ok: false, error });
139
+ }
140
+ }
141
+ buildInteraction(flow) {
142
+ return {
143
+ signal: flow.abort.signal,
144
+ notify: (event) => {
145
+ // Must be synchronous and must never throw into Pi.
146
+ try {
147
+ this.sendFlowEvent(flow, toFlowEventPayload(event));
148
+ }
149
+ catch (err) {
150
+ this.logger?.warn({ flowId: flow.flowId, provider: flow.provider, err: errorMessage(err) }, "provider-auth: notify handler failed");
151
+ }
152
+ },
153
+ prompt: (p) => this.handlePrompt(flow, p),
154
+ };
155
+ }
156
+ handlePrompt(flow, p) {
157
+ if (flow.pendingPrompt) {
158
+ // Pi is sequential — a second concurrent prompt is a protocol violation, not something to
159
+ // silently drop. Fail the whole flow rather than guess which prompt the client meant.
160
+ const reason = "provider_auth: concurrent prompt (protocol violation)";
161
+ this.abortFlow(flow, "cancelled");
162
+ return Promise.reject(new Error(reason));
163
+ }
164
+ const promptId = randomUUID();
165
+ const { promise, resolve, reject } = Promise.withResolvers();
166
+ const onPromptAbort = () => {
167
+ if (flow.pendingPrompt?.promptId !== promptId)
168
+ return;
169
+ flow.pendingPrompt = undefined;
170
+ this.sendFlowEvent(flow, { kind: "prompt_cancelled", promptId });
171
+ reject(new Error("prompt_cancelled"));
172
+ };
173
+ p.signal?.addEventListener("abort", onPromptAbort, { once: true });
174
+ flow.pendingPrompt = {
175
+ promptId,
176
+ resolve: (value) => {
177
+ p.signal?.removeEventListener("abort", onPromptAbort);
178
+ resolve(value);
179
+ },
180
+ reject: (reason) => {
181
+ p.signal?.removeEventListener("abort", onPromptAbort);
182
+ reject(reason);
183
+ },
184
+ };
185
+ this.logger?.debug({ flowId: flow.flowId, provider: flow.provider, promptId, promptKind: p.type }, "provider-auth: prompt");
186
+ this.sendFlowEvent(flow, {
187
+ kind: "prompt",
188
+ promptId,
189
+ promptKind: p.type,
190
+ message: p.message,
191
+ placeholder: "placeholder" in p ? p.placeholder : undefined,
192
+ options: "options" in p ? p.options : undefined,
193
+ });
194
+ return promise;
195
+ }
196
+ /** Abort the flow's controller and settle it with a terminal `done ok:false`. Used by explicit
197
+ * `cancel`, TTL expiry, and — via the `SessionSubscriptions` disposer `login` registers, which
198
+ * fires on socket disconnect — session close. A no-op if the flow already reached a terminal
199
+ * state. */
200
+ abortFlow(flow, reason) {
201
+ if (flow.terminal)
202
+ return;
203
+ flow.abort.abort();
204
+ this.settleFlow(flow, { ok: false, error: reason });
205
+ }
206
+ /** The single place a flow ends: compare-and-set on `terminal` (so exactly one `done` is ever
207
+ * emitted even when a cancel races the runtime settling), clears the TTL timer, rejects any
208
+ * pending prompt, unregisters the flow, emits the terminal event, and drops the
209
+ * `SessionSubscriptions` entry `login` registered. Removing it here — not just from an explicit
210
+ * `cancel` — is what guarantees a flow that completes or times out on its own leaves no stale
211
+ * disposer behind. `SessionSubscriptions.remove` re-invokes the disposer it finds (which calls
212
+ * `cancel()` again); that's a harmless no-op re-entry by this point, since `this.flows` no
213
+ * longer has an entry for `flow.flowId` (deleted a few lines above). */
214
+ settleFlow(flow, result) {
215
+ if (flow.terminal)
216
+ return;
217
+ flow.terminal = true;
218
+ this.clearTimer(flow.timer);
219
+ if (flow.pendingPrompt) {
220
+ const pending = flow.pendingPrompt;
221
+ flow.pendingPrompt = undefined;
222
+ pending.reject(new Error(result.error ?? "flow_ended"));
223
+ }
224
+ this.flows.delete(flow.flowId);
225
+ if (this.flowIdBySession.get(flow.session) === flow.flowId) {
226
+ this.flowIdBySession.delete(flow.session);
227
+ }
228
+ this.logger?.debug({ flowId: flow.flowId, provider: flow.provider, ok: result.ok, error: result.error }, "provider-auth: flow ended");
229
+ this.sendFlowEvent(flow, { kind: "done", ok: result.ok, error: result.error });
230
+ this.subscriptions?.remove(flow.session, `${PROVIDER_AUTH_FLOW_KEY_PREFIX}${flow.flowId}`);
231
+ }
232
+ sendFlowEvent(flow, event) {
233
+ try {
234
+ flow.session.send({
235
+ type: "session",
236
+ message: { type: "provider_auth_flow_event", flowId: flow.flowId, event },
237
+ });
238
+ }
239
+ catch (err) {
240
+ this.logger?.warn({ flowId: flow.flowId, provider: flow.provider, err: errorMessage(err) }, "provider-auth: failed to send flow event");
241
+ }
242
+ }
243
+ }
244
+ /** `AuthEventLike`'s `type` discriminant maps 1:1 onto the flow event's `kind` — everything else
245
+ * (message/links, url/instructions, userCode/verificationUri/expiresInSeconds, message) forwards
246
+ * verbatim. Never a prompt value; events carry no secrets by construction. */
247
+ function toFlowEventPayload(event) {
248
+ const { type, ...rest } = event;
249
+ return { kind: type, ...rest };
250
+ }
251
+ function errorMessage(err) {
252
+ return err instanceof Error ? err.message : String(err);
253
+ }
254
+ /** Provider messages are safe to relay verbatim; this exists so a future caller has one place to
255
+ * redact from, never to interpolate a prompt value into an error (callers never pass one in). */
256
+ function sanitizeError(err) {
257
+ return errorMessage(err);
258
+ }
259
+ //# sourceMappingURL=provider-auth-service.js.map
@@ -32,6 +32,10 @@ import { SlashCommandOperationsService } from "../agent/slash-command-operations
32
32
  import { registerTimelineHandler } from "../agent/timeline-rpc.js";
33
33
  import { PermissionService } from "../agent/permissions.js";
34
34
  import { ProviderRegistry, resolveProviderClient } from "../agent/provider-registry.js";
35
+ import { resolvePiAuthPaths } from "../agent/pi-home.js";
36
+ import { createPiAuthRuntime } from "../agent/provider-auth/pi-auth-runtime.js";
37
+ import { ProviderAuthService } from "../agent/provider-auth/provider-auth-service.js";
38
+ import { registerProviderAuthHandlers } from "../agent/provider-auth/provider-auth-rpc.js";
35
39
  import { saveAgent, loadAllAgents } from "../persistence/entity-stores.js";
36
40
  import { FileExplorerService } from "../files/file-explorer.js";
37
41
  import { FileTransferService } from "../files/file-transfer.js";
@@ -393,6 +397,15 @@ export function startDaemon(opts) {
393
397
  const fileWatchService = new FileWatchService({ logger });
394
398
  registerFileWatchHandlers(registry, { fileWatchService, subscriptions, logger });
395
399
  registerExtensionsHandlers(registry, { service: extensionsService, logger: extensionsLogger });
400
+ // ── Provider auth: remote-driven Pi login flows (sprint-055) ─────────────────
401
+ const providerAuthLogger = logger.child({ component: "provider-auth" });
402
+ const providerAuthRuntime = createPiAuthRuntime(resolvePiAuthPaths(config));
403
+ const providerAuthService = new ProviderAuthService({
404
+ runtime: providerAuthRuntime,
405
+ logger: providerAuthLogger,
406
+ subscriptions,
407
+ });
408
+ registerProviderAuthHandlers(registry, { providerAuthService, logger: providerAuthLogger });
396
409
  // Simple file diff RPC for the POC UI (returns unified diff for a single file). Untracked
397
410
  // (brand-new) files have no git-tracked "before" state, so a plain `git diff` against them is
398
411
  // always empty — git only diffs a path once it's in the index or committed. Fall back to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@av-pi-studio/server",
3
- "version": "0.0.92",
3
+ "version": "0.0.93",
4
4
  "bin": {
5
5
  "pi-studio-daemon": "dist/daemon/main.js"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  "clean": "rm -rf dist *.tsbuildinfo"
25
25
  },
26
26
  "dependencies": {
27
- "@av-pi-studio/highlight": "^0.0.92",
28
- "@av-pi-studio/protocol": "^0.0.92",
29
- "@av-pi-studio/relay": "^0.0.92",
27
+ "@av-pi-studio/highlight": "^0.0.93",
28
+ "@av-pi-studio/protocol": "^0.0.93",
29
+ "@av-pi-studio/relay": "^0.0.93",
30
30
  "@earendil-works/pi-coding-agent": "^0.84.1",
31
31
  "@xterm/headless": "^6.0.0",
32
32
  "bcryptjs": "^3.0.3",