@jameslovespancakes/pi-plus 1.0.22 → 1.0.23
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 +17 -4
- package/package.json +1 -1
- package/src/core/env.ts +0 -2
- package/src/core/policy/openrouter.ts +54 -0
- package/src/core/policy/policy.ts +112 -133
- package/src/domains/claude-remote/index.ts +21 -14
- package/src/domains/claude-remote/picker.ts +12 -3
- package/src/domains/models/policy-gate.ts +50 -52
- package/src/domains/models/provider-picker.ts +19 -8
- package/src/domains/setup/index.ts +4 -5
- package/src/domains/workflows/runtime/agent-session-providers.ts +7 -2
package/README.md
CHANGED
|
@@ -37,9 +37,10 @@ the app's Stop button stops the agent. Your selected model still runs through pi
|
|
|
37
37
|
──────────────────────────────────────
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
**`/claude-remote`** toggles On/Off
|
|
41
|
-
|
|
42
|
-
is green when connected
|
|
40
|
+
**`/claude-remote`** toggles On/Off for this session only. Startup, new sessions,
|
|
41
|
+
resumes, forks, and reloads always start Off; old auto-start preferences are
|
|
42
|
+
ignored. Off disconnects immediately. The footer dot is green when connected
|
|
43
|
+
and red otherwise.
|
|
43
44
|
|
|
44
45
|
Off by default. Requires your primary Anthropic OAuth login via `/login`.
|
|
45
46
|
Experimental: text input and completed-message mirroring, not token streaming.
|
|
@@ -124,11 +125,23 @@ boundary, including workflow subagents—not just through prompt instructions.
|
|
|
124
125
|
──────────────────────────────────────
|
|
125
126
|
Providers
|
|
126
127
|
› ● Anthropic Allowed
|
|
127
|
-
● OpenRouter
|
|
128
|
+
● OpenRouter Off
|
|
128
129
|
──────────────────────────────────────
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
**`/provider`** opens the picker. Enter or Space toggles access in place.
|
|
133
|
+
OpenRouter cycles **Off → On → On (ZDR) → Off**. Grants are session-local;
|
|
134
|
+
explicit policy denials stay locked. Other providers keep their existing toggles.
|
|
135
|
+
|
|
136
|
+
**On (ZDR)** restricts OpenRouter inference to Zero Data Retention endpoints,
|
|
137
|
+
including workflow agents and compaction. No eligible endpoint means an error,
|
|
138
|
+
never a non-ZDR retry; fallback among ZDR endpoints is allowed. Native Chat
|
|
139
|
+
Completions and Anthropic Messages routes are supported; unsupported APIs or
|
|
140
|
+
custom endpoints fail closed. Ordinary On does not remove account-level privacy
|
|
141
|
+
rules. ZDR does not cover separately enabled search plugins or local session logs.
|
|
142
|
+
|
|
143
|
+
Commands: `/provider approve openrouter`, `/provider zdr openrouter`, and
|
|
144
|
+
`/provider remove openrouter`.
|
|
132
145
|
|
|
133
146
|
## Workflows
|
|
134
147
|
|
package/package.json
CHANGED
package/src/core/env.ts
CHANGED
|
@@ -15,7 +15,6 @@ export type EnvKey =
|
|
|
15
15
|
| "AGENT_BOARD_NAME"
|
|
16
16
|
| "AGENT_BOARD_MODE"
|
|
17
17
|
| "AGENT_BOARD_SSH"
|
|
18
|
-
| "PI_CLAUDE_REMOTE"
|
|
19
18
|
| "PI_CLAUDE_REMOTE_ALLOW_INBOUND"
|
|
20
19
|
| "CLAUDE_TRUSTED_DEVICE_TOKEN";
|
|
21
20
|
|
|
@@ -26,7 +25,6 @@ export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
|
|
|
26
25
|
{ key: "AGENT_BOARD_NAME", label: "Agent board display name", secret: false },
|
|
27
26
|
{ key: "AGENT_BOARD_MODE", label: "Agent board deployment (local|remote|external)", secret: false },
|
|
28
27
|
{ key: "AGENT_BOARD_SSH", label: "Agent board SSH host, when remote", secret: false },
|
|
29
|
-
{ key: "PI_CLAUDE_REMOTE", label: "Claude Remote auto-start (1|0)", secret: false },
|
|
30
28
|
{ key: "PI_CLAUDE_REMOTE_ALLOW_INBOUND", label: "Claude Remote input (1|0)", secret: false },
|
|
31
29
|
{ key: "CLAUDE_TRUSTED_DEVICE_TOKEN", label: "Claude trusted-device token (optional)", secret: true },
|
|
32
30
|
];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Api, Model, StreamOptions } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
function officialEndpoint(model: Model<Api>): boolean {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(model.baseUrl);
|
|
6
|
+
const path = model.api === "anthropic-messages" ? "/api" : "/api/v1";
|
|
7
|
+
return url.origin === "https://openrouter.ai" && url.pathname.replace(/\/+$/, "") === path
|
|
8
|
+
&& !url.username && !url.password && !url.search && !url.hash;
|
|
9
|
+
} catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Enforce through pi's own routing support, without replacing its provider or tools. */
|
|
13
|
+
export function withOpenRouterZdr(model: Model<Api>, options?: StreamOptions) {
|
|
14
|
+
if (!["openai-completions", "anthropic-messages"].includes(model.api) || !officialEndpoint(model)) {
|
|
15
|
+
throw new Error("OpenRouter On (ZDR) cannot enforce this API or endpoint. Use pi's native OpenRouter Chat Completions or Messages route.");
|
|
16
|
+
}
|
|
17
|
+
const preferences = model.compat && "openRouterRouting" in model.compat ? model.compat.openRouterRouting : undefined;
|
|
18
|
+
return {
|
|
19
|
+
model: model.api === "openai-completions" ? {
|
|
20
|
+
...model,
|
|
21
|
+
compat: {
|
|
22
|
+
...model.compat,
|
|
23
|
+
openRouterRouting: { ...preferences, zdr: true },
|
|
24
|
+
},
|
|
25
|
+
} : model,
|
|
26
|
+
options: {
|
|
27
|
+
...options,
|
|
28
|
+
// Run caller instrumentation first, then enforce the final request's
|
|
29
|
+
// privacy restriction. No retry without ZDR is allowed.
|
|
30
|
+
onPayload: async (payload: unknown, requestModel: Model<Api>) => {
|
|
31
|
+
const replacement = await options?.onPayload?.(payload, requestModel);
|
|
32
|
+
const body = replacement === undefined ? payload : replacement;
|
|
33
|
+
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)
|
|
34
|
+
|| "input" in body || "instructions" in body) {
|
|
35
|
+
throw new Error("OpenRouter On (ZDR) blocked an unsupported request payload.");
|
|
36
|
+
}
|
|
37
|
+
const routing = "provider" in body ? body.provider : undefined;
|
|
38
|
+
// Pi natively serializes openRouterRouting on Chat Completions. Its
|
|
39
|
+
// Anthropic adapter does not, although OpenRouter's Messages API accepts
|
|
40
|
+
// the same provider preferences. Add only that missing request field.
|
|
41
|
+
if (model.api === "anthropic-messages") {
|
|
42
|
+
if (routing !== undefined && (!routing || typeof routing !== "object" || Array.isArray(routing))) {
|
|
43
|
+
throw new Error("OpenRouter On (ZDR) blocked malformed provider preferences.");
|
|
44
|
+
}
|
|
45
|
+
return { ...body, provider: { ...preferences, ...routing, zdr: true } };
|
|
46
|
+
}
|
|
47
|
+
if (!routing || typeof routing !== "object" || !("zdr" in routing) || routing.zdr !== true) {
|
|
48
|
+
throw new Error("OpenRouter On (ZDR) blocked a request without provider.zdr=true.");
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -1,45 +1,29 @@
|
|
|
1
1
|
import { readConfig, updateConfig } from "../config.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Approval policy for model selection.
|
|
5
|
-
*
|
|
6
|
-
* Subscription providers are free to use. Metered providers (OpenRouter and
|
|
7
|
-
* anything else that bills per token) require an explicit approval before a
|
|
8
|
-
* request is allowed to leave the machine.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
3
|
export interface PolicyFile {
|
|
12
4
|
autoApprove: string[];
|
|
13
5
|
requireApproval: string[];
|
|
14
6
|
deny: string[];
|
|
15
7
|
}
|
|
16
8
|
|
|
17
|
-
const DEFAULT_POLICY: PolicyFile = {
|
|
18
|
-
// `google/*` is the metered Gemini API; `gemini/*` is the subscription,
|
|
19
|
-
// which the account's plan has already paid for.
|
|
20
|
-
autoApprove: ["anthropic/*", "openai-codex/*", "gemini/*"],
|
|
21
|
-
requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
|
|
22
|
-
deny: [],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
9
|
export type Decision =
|
|
26
10
|
| { allowed: true; reason: "auto" | "approved" }
|
|
27
11
|
| { allowed: false; reason: "denied" | "needs-approval"; message: string };
|
|
28
12
|
|
|
29
|
-
|
|
13
|
+
export type ProviderState = "auto" | "approved" | "blocked" | "denied" | "zdr";
|
|
14
|
+
|
|
15
|
+
export interface ProviderApproval {
|
|
16
|
+
provider: string;
|
|
17
|
+
approved: boolean;
|
|
18
|
+
until?: number;
|
|
19
|
+
}
|
|
30
20
|
|
|
31
21
|
export function loadPolicy(): PolicyFile {
|
|
32
22
|
return readConfig().policy;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
25
|
export function savePolicy(next: PolicyFile): void {
|
|
36
|
-
updateConfig((config) => {
|
|
37
|
-
config.policy = {
|
|
38
|
-
autoApprove: next.autoApprove ?? DEFAULT_POLICY.autoApprove,
|
|
39
|
-
requireApproval: next.requireApproval ?? DEFAULT_POLICY.requireApproval,
|
|
40
|
-
deny: next.deny ?? DEFAULT_POLICY.deny,
|
|
41
|
-
};
|
|
42
|
-
});
|
|
26
|
+
updateConfig((config) => { config.policy = next; });
|
|
43
27
|
}
|
|
44
28
|
|
|
45
29
|
function matches(pattern: string, value: string): boolean {
|
|
@@ -51,135 +35,130 @@ function matchesAny(patterns: string[], value: string): boolean {
|
|
|
51
35
|
return patterns.some((pattern) => matches(pattern, value));
|
|
52
36
|
}
|
|
53
37
|
|
|
54
|
-
/**
|
|
55
|
-
* Provider ids that require approval, derived from the policy patterns so the
|
|
56
|
-
* toggle list always reflects the configured file rather than a hardcoded set.
|
|
57
|
-
*/
|
|
38
|
+
/** Configured approval gates, independent of a session's live grants. */
|
|
58
39
|
export function gatedProviders(): string[] {
|
|
59
|
-
const names = loadPolicy().requireApproval
|
|
60
|
-
.map((pattern) => pattern.split("/")[0])
|
|
40
|
+
const names = loadPolicy().requireApproval.map((pattern) => pattern.split("/")[0])
|
|
61
41
|
.filter((name) => name && !name.includes("*"));
|
|
62
42
|
return [...new Set(names)].sort();
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
|
|
45
|
+
/** Runtime grants belong to an extension session, never a shared module or config file. */
|
|
46
|
+
export class ProviderPolicy {
|
|
47
|
+
private readonly approvals = new Map<string, number>();
|
|
48
|
+
private openRouterMode: "off" | "on" | "zdr" | undefined;
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
|
|
71
|
-
if (matchesAny(current.requireApproval, `${provider}/*`)) {
|
|
72
|
-
return isApproved(provider) ? "approved" : "blocked";
|
|
50
|
+
reset(): void {
|
|
51
|
+
this.approvals.clear();
|
|
52
|
+
this.openRouterMode = undefined;
|
|
73
53
|
}
|
|
74
|
-
return "auto";
|
|
75
|
-
}
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
* Auto-approved providers are moved into `requireApproval` so the switch is
|
|
81
|
-
* reversible; gated ones just gain or lose their session grant. Denied
|
|
82
|
-
* providers are left alone; `deny` is an explicit, deliberate block.
|
|
83
|
-
*/
|
|
84
|
-
export function toggleProvider(provider: string): ProviderState {
|
|
85
|
-
const state = providerState(provider);
|
|
86
|
-
if (state === "denied") return state;
|
|
87
|
-
|
|
88
|
-
if (state === "auto") {
|
|
89
|
-
const current = loadPolicy();
|
|
90
|
-
savePolicy({
|
|
91
|
-
...current,
|
|
92
|
-
autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`) && pattern !== `${provider}/*`),
|
|
93
|
-
requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
|
|
94
|
-
});
|
|
95
|
-
revoke(provider);
|
|
96
|
-
return "blocked";
|
|
55
|
+
openRouterZdrRequired(): boolean {
|
|
56
|
+
return this.openRouterMode === "zdr";
|
|
97
57
|
}
|
|
98
58
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
59
|
+
approveOpenRouterZdr(): void {
|
|
60
|
+
this.approve("openrouter");
|
|
61
|
+
this.openRouterMode = "zdr";
|
|
102
62
|
}
|
|
103
63
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
export function approve(provider: string, durationMs?: number): void {
|
|
110
|
-
approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function revoke(provider: string): void {
|
|
114
|
-
approvals.delete(provider);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function isApproved(provider: string): boolean {
|
|
118
|
-
const until = approvals.get(provider);
|
|
119
|
-
return until !== undefined && Date.now() < until;
|
|
120
|
-
}
|
|
64
|
+
/** No duration means a grant for this session only. */
|
|
65
|
+
approve(provider: string, durationMs?: number): void {
|
|
66
|
+
this.approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
|
|
67
|
+
if (provider === "openrouter") this.openRouterMode = "on";
|
|
68
|
+
}
|
|
121
69
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
revoke(provider);
|
|
126
|
-
return false;
|
|
70
|
+
revoke(provider: string): void {
|
|
71
|
+
this.approvals.delete(provider);
|
|
72
|
+
if (provider === "openrouter") this.openRouterMode = "off";
|
|
127
73
|
}
|
|
128
|
-
approve(provider);
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
74
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
75
|
+
isApproved(provider: string): boolean {
|
|
76
|
+
const until = this.approvals.get(provider);
|
|
77
|
+
return until !== undefined && Date.now() < until;
|
|
78
|
+
}
|
|
137
79
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
80
|
+
providerState(provider: string): ProviderState {
|
|
81
|
+
const current = loadPolicy();
|
|
82
|
+
if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
|
|
83
|
+
if (provider === "openrouter" && this.openRouterMode !== undefined) {
|
|
84
|
+
return this.isApproved(provider) ? (this.openRouterZdrRequired() ? "zdr" : "approved") : "blocked";
|
|
85
|
+
}
|
|
86
|
+
if (matchesAny(current.requireApproval, `${provider}/*`)) {
|
|
87
|
+
return this.isApproved(provider) ? "approved" : "blocked";
|
|
88
|
+
}
|
|
89
|
+
return "auto";
|
|
90
|
+
}
|
|
146
91
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
92
|
+
/** Only OpenRouter cycles through a third (ZDR-only) state. */
|
|
93
|
+
toggleProvider(provider: string): ProviderState {
|
|
94
|
+
const state = this.providerState(provider);
|
|
95
|
+
if (state === "denied") return state;
|
|
96
|
+
if (provider === "openrouter") {
|
|
97
|
+
if (state === "auto" || state === "approved") this.approveOpenRouterZdr();
|
|
98
|
+
else if (state === "zdr") this.revoke(provider);
|
|
99
|
+
else this.approve(provider);
|
|
100
|
+
return this.providerState(provider);
|
|
101
|
+
}
|
|
102
|
+
if (state === "auto") {
|
|
103
|
+
const current = loadPolicy();
|
|
104
|
+
savePolicy({
|
|
105
|
+
...current,
|
|
106
|
+
autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`)),
|
|
107
|
+
requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
|
|
108
|
+
});
|
|
109
|
+
this.revoke(provider);
|
|
110
|
+
return "blocked";
|
|
111
|
+
}
|
|
112
|
+
return this.toggleApproval(provider) ? "approved" : "blocked";
|
|
113
|
+
}
|
|
150
114
|
|
|
151
|
-
|
|
152
|
-
|
|
115
|
+
toggleApproval(provider: string): boolean {
|
|
116
|
+
if (this.isApproved(provider)) { this.revoke(provider); return false; }
|
|
117
|
+
this.approve(provider);
|
|
118
|
+
return true;
|
|
153
119
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
reason: "needs-approval",
|
|
163
|
-
message:
|
|
164
|
-
`${ref} is a metered (pay-per-token) model and is not approved in this session. `
|
|
165
|
-
+ `Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run `
|
|
166
|
-
+ `/provider approve ${provider}.`,
|
|
167
|
-
};
|
|
120
|
+
|
|
121
|
+
approvalStates(): ProviderApproval[] {
|
|
122
|
+
const ids = new Set([...gatedProviders(), ...this.approvals.keys()]);
|
|
123
|
+
return [...ids].sort().map((provider) => {
|
|
124
|
+
const until = this.approvals.get(provider);
|
|
125
|
+
const approved = this.isApproved(provider);
|
|
126
|
+
return { provider, approved, until: approved && until !== Number.MAX_SAFE_INTEGER ? until : undefined };
|
|
127
|
+
});
|
|
168
128
|
}
|
|
169
129
|
|
|
170
|
-
|
|
171
|
-
|
|
130
|
+
checkModel(provider: string, modelId: string): Decision {
|
|
131
|
+
const current = loadPolicy();
|
|
132
|
+
const ref = `${provider}/${modelId}`;
|
|
133
|
+
if (matchesAny(current.deny, ref) || matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) {
|
|
134
|
+
return { allowed: false, reason: "denied", message: `${ref} is denied by model policy.` };
|
|
135
|
+
}
|
|
136
|
+
// Explicit OpenRouter Off must override even an auto-approve wildcard.
|
|
137
|
+
const explicitOpenRouterMode = provider === "openrouter" && this.openRouterMode !== undefined;
|
|
138
|
+
if (!explicitOpenRouterMode && matchesAny(current.autoApprove, ref)) return { allowed: true, reason: "auto" };
|
|
139
|
+
if (!explicitOpenRouterMode && !matchesAny(current.requireApproval, ref)) return { allowed: true, reason: "auto" };
|
|
140
|
+
if (!this.isApproved(provider)) {
|
|
141
|
+
return {
|
|
142
|
+
allowed: false, reason: "needs-approval",
|
|
143
|
+
message: `${ref} is a metered (pay-per-token) model and is not approved in this session. `
|
|
144
|
+
+ "Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run "
|
|
145
|
+
+ `/provider approve ${provider}.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { allowed: true, reason: "approved" };
|
|
149
|
+
}
|
|
172
150
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
151
|
+
policySummary(): string {
|
|
152
|
+
const current = loadPolicy();
|
|
153
|
+
const active = this.approvalStates().filter((entry) => entry.approved).map((entry) =>
|
|
154
|
+
`${entry.provider}${entry.provider === "openrouter" && this.openRouterZdrRequired() ? " (ZDR)" : ""}`
|
|
155
|
+
+ (entry.until ? ` (until ${new Date(entry.until).toLocaleTimeString()})` : " (session)"));
|
|
156
|
+
return [
|
|
157
|
+
"Model approval policy",
|
|
158
|
+
` auto-approved: ${current.autoApprove.join(", ") || "none"}`,
|
|
159
|
+
` needs approval: ${current.requireApproval.join(", ") || "none"}`,
|
|
160
|
+
` denied: ${current.deny.join(", ") || "none"}`,
|
|
161
|
+
` approved now: ${active.join(", ") || "none"}`,
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
185
164
|
}
|
|
@@ -2,7 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { ClaudeRemoteBridge, type BridgeOptions } from "../../core/claude-remote/bridge.ts";
|
|
4
4
|
import { mirrorMessage } from "../../core/claude-remote/protocol.ts";
|
|
5
|
-
import { env
|
|
5
|
+
import { env } from "../../core/env.ts";
|
|
6
6
|
import { createTokenSource } from "./auth.ts";
|
|
7
7
|
import { remoteControlPicker } from "./picker.ts";
|
|
8
8
|
|
|
@@ -30,6 +30,7 @@ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies
|
|
|
30
30
|
let enabled = false;
|
|
31
31
|
let current: ExtensionContext | undefined;
|
|
32
32
|
let generation = 0;
|
|
33
|
+
let sessionEpoch = 0;
|
|
33
34
|
// Counts rather than a TTL: follow-ups can wait longer than 30 seconds.
|
|
34
35
|
const echoes: string[] = [];
|
|
35
36
|
|
|
@@ -48,6 +49,7 @@ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function stop(): void {
|
|
52
|
+
enabled = false;
|
|
51
53
|
++generation;
|
|
52
54
|
active?.stop();
|
|
53
55
|
active = undefined;
|
|
@@ -55,12 +57,18 @@ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies
|
|
|
55
57
|
setConnectionStatus("off");
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
function endSession(): void {
|
|
61
|
+
++sessionEpoch;
|
|
62
|
+
stop();
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
function start(ctx: ExtensionContext): void {
|
|
59
66
|
if (active) {
|
|
60
67
|
notify(ctx, `Claude Remote: ${status}. Open https://claude.ai/code`);
|
|
61
68
|
return;
|
|
62
69
|
}
|
|
63
70
|
current = ctx;
|
|
71
|
+
enabled = true;
|
|
64
72
|
const gen = ++generation;
|
|
65
73
|
const title = `pi: ${pi.getSessionName() || basename(ctx.cwd) || "session"}`.slice(0, 100);
|
|
66
74
|
setConnectionStatus("connecting");
|
|
@@ -115,14 +123,8 @@ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies
|
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
function setEnabled(next: boolean, ctx: ExtensionContext): boolean {
|
|
118
|
-
enabled = next;
|
|
119
|
-
const saved = setEnv("PI_CLAUDE_REMOTE", next ? "1" : "0");
|
|
120
126
|
if (next) start(ctx);
|
|
121
127
|
else stop();
|
|
122
|
-
if (!saved) notify(ctx, "Could not save preference; changed this session only.", true);
|
|
123
|
-
else if ((env("PI_CLAUDE_REMOTE") === "1") !== next) {
|
|
124
|
-
notify(ctx, "PI_CLAUDE_REMOTE overrides this preference after reload.", true);
|
|
125
|
-
}
|
|
126
128
|
return enabled;
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -132,28 +134,33 @@ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies
|
|
|
132
134
|
.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
|
|
133
135
|
handler: async (args, ctx) => {
|
|
134
136
|
const action = args.trim().toLowerCase();
|
|
137
|
+
const session = sessionEpoch;
|
|
135
138
|
if (!action && ctx.mode === "tui") {
|
|
136
139
|
await ctx.ui.custom((_tui, theme, _keys, done) => remoteControlPicker(
|
|
137
|
-
theme,
|
|
140
|
+
theme, () => session === sessionEpoch && enabled,
|
|
141
|
+
(next) => session === sessionEpoch && setEnabled(next, ctx), () => done(undefined),
|
|
138
142
|
));
|
|
139
143
|
} else if (action === "on" || action === "off") {
|
|
140
144
|
if (action === "on" && ctx.hasUI && !enabled && !await ctx.ui.confirm("Enable Remote Control?",
|
|
141
|
-
"Share
|
|
145
|
+
"Share this session with Anthropic and control it from the Claude app. New sessions and reloads start Off.")) return;
|
|
146
|
+
if (session !== sessionEpoch) return;
|
|
142
147
|
setEnabled(action === "on", ctx);
|
|
143
148
|
} else notify(ctx, "Usage: /claude-remote [on|off]", true);
|
|
144
149
|
},
|
|
145
150
|
});
|
|
146
151
|
|
|
152
|
+
// Stop before pi changes the active session, including cancelled switches.
|
|
153
|
+
pi.on("session_before_switch", () => { endSession(); });
|
|
154
|
+
pi.on("session_before_fork", () => { endSession(); });
|
|
147
155
|
pi.on("session_start", (_event, ctx) => {
|
|
148
|
-
|
|
156
|
+
endSession();
|
|
149
157
|
current = ctx;
|
|
150
158
|
setConnectionStatus("off");
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
if (ctx.mode === "tui" && enabled) start(ctx);
|
|
159
|
+
// Intentionally ignore legacy PI_CLAUDE_REMOTE preferences. Every session,
|
|
160
|
+
// including resumes, forks, reloads and workflow children, starts Off.
|
|
154
161
|
});
|
|
155
162
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
156
|
-
|
|
163
|
+
endSession();
|
|
157
164
|
if (ctx.hasUI) ctx.ui.setStatus("claude-remote", undefined);
|
|
158
165
|
current = undefined;
|
|
159
166
|
});
|
|
@@ -5,11 +5,11 @@ import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
|
|
|
5
5
|
/** Same dot, colors and in-place SettingsList toggle as /provider. */
|
|
6
6
|
export function remoteControlPicker(
|
|
7
7
|
theme: any,
|
|
8
|
-
|
|
8
|
+
readEnabled: () => boolean,
|
|
9
9
|
toggle: (enabled: boolean) => boolean,
|
|
10
10
|
done: () => void,
|
|
11
11
|
): Component {
|
|
12
|
-
let enabled =
|
|
12
|
+
let enabled = readEnabled();
|
|
13
13
|
const color = (value: boolean, text: string) => hasTruecolor()
|
|
14
14
|
? levelColor(value ? 100 : 0)(text) : theme.fg(value ? "success" : "error", text);
|
|
15
15
|
const label = () => `${color(enabled, "●")} Remote Control`;
|
|
@@ -19,13 +19,22 @@ export function remoteControlPicker(
|
|
|
19
19
|
values: [color(true, "On"), color(false, "Off")],
|
|
20
20
|
};
|
|
21
21
|
const list = new SettingsList([item], 1, settingsTheme(theme), () => {
|
|
22
|
-
enabled = toggle(!
|
|
22
|
+
enabled = toggle(!readEnabled());
|
|
23
23
|
item.label = label();
|
|
24
24
|
list.updateValue(item.id, value());
|
|
25
25
|
}, done, { enableSearch: false });
|
|
26
26
|
const frame = frameSettings(theme, list, "Remote Control");
|
|
27
27
|
return {
|
|
28
28
|
...frame,
|
|
29
|
+
render(width: number) {
|
|
30
|
+
const next = readEnabled();
|
|
31
|
+
if (next !== enabled) {
|
|
32
|
+
enabled = next;
|
|
33
|
+
item.label = label();
|
|
34
|
+
list.updateValue(item.id, value());
|
|
35
|
+
}
|
|
36
|
+
return frame.render(width);
|
|
37
|
+
},
|
|
29
38
|
invalidate() {
|
|
30
39
|
item.values = [color(true, "On"), color(false, "Off")];
|
|
31
40
|
item.label = label();
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
gatedProviders,
|
|
6
|
-
isApproved,
|
|
7
|
-
loadPolicy,
|
|
8
|
-
providerState,
|
|
9
|
-
revoke,
|
|
10
|
-
toggleProvider,
|
|
11
|
-
} from "../../core/policy/policy.ts";
|
|
12
|
-
import { openProviderPicker, STATE_TEXT, type ProviderRow, type StateKey } from "./provider-picker.ts";
|
|
2
|
+
import { gatedProviders, loadPolicy, ProviderPolicy } from "../../core/policy/policy.ts";
|
|
3
|
+
import { withOpenRouterZdr } from "../../core/policy/openrouter.ts";
|
|
4
|
+
import { openProviderPicker, providerStateText, type ProviderRow } from "./provider-picker.ts";
|
|
13
5
|
|
|
14
6
|
/**
|
|
15
7
|
* Enforces the approval policy at the provider boundary, so it also covers
|
|
@@ -20,7 +12,11 @@ class ModelPolicyError extends Error {
|
|
|
20
12
|
code = "MODEL_POLICY_BLOCKED";
|
|
21
13
|
}
|
|
22
14
|
|
|
23
|
-
|
|
15
|
+
// A workflow child inherits the host's live guard, not a second unapproved gate.
|
|
16
|
+
const POLICY_GUARD = Symbol.for("pi-plus.provider-policy");
|
|
17
|
+
|
|
18
|
+
/** Configured providers plus policy gates, re-read after authentication changes. */
|
|
19
|
+
async function providerRows(ctx: any, policy: ProviderPolicy): Promise<ProviderRow[]> {
|
|
24
20
|
const ids = new Set<string>();
|
|
25
21
|
try {
|
|
26
22
|
for (const model of await ctx.modelRegistry.getAvailable()) ids.add(model.provider);
|
|
@@ -47,16 +43,11 @@ async function providerRows(ctx: any): Promise<ProviderRow[]> {
|
|
|
47
43
|
id: provider,
|
|
48
44
|
provider,
|
|
49
45
|
display,
|
|
50
|
-
state: providerState(provider)
|
|
46
|
+
state: policy.providerState(provider),
|
|
51
47
|
};
|
|
52
48
|
});
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
/**
|
|
56
|
-
* Every provider the user actually has credentials for, plus any the policy
|
|
57
|
-
* gates. Derived at call time so a newly authenticated provider shows up
|
|
58
|
-
* without touching config.
|
|
59
|
-
*/
|
|
60
51
|
/**
|
|
61
52
|
* Providers are free to decorate their own name. The CortexKit package calls
|
|
62
53
|
* itself "Anthropic (CortexKit OAuth)". The implementation detail is noise in a
|
|
@@ -68,57 +59,59 @@ function cleanName(name: string): string {
|
|
|
68
59
|
|
|
69
60
|
|
|
70
61
|
export function registerPolicyGate(pi: ExtensionAPI): void {
|
|
62
|
+
const policy = new ProviderPolicy();
|
|
71
63
|
let wrapped = false;
|
|
72
64
|
|
|
73
65
|
const wrapProviders = (ctx: any) => {
|
|
74
66
|
if (wrapped) return;
|
|
75
|
-
|
|
67
|
+
// OpenRouter must be wrapped even when config auto-approves it: the picker
|
|
68
|
+
// can still select ZDR (or Off) without changing the installed catalogue.
|
|
69
|
+
for (const providerId of new Set([...gatedProviders(), "openrouter"])) {
|
|
76
70
|
const provider = ctx.modelRegistry.getProvider(providerId);
|
|
77
|
-
if (!provider) continue;
|
|
71
|
+
if (!provider || ctx.modelRegistry.getRegisteredNativeProvider?.(providerId)?.[POLICY_GUARD]) continue;
|
|
78
72
|
|
|
79
73
|
const guard = (model: any) => {
|
|
80
|
-
const decision = checkModel(providerId, model?.id ?? "unknown");
|
|
74
|
+
const decision = policy.checkModel(providerId, model?.id ?? "unknown");
|
|
81
75
|
if (!decision.allowed) throw new ModelPolicyError(decision.message);
|
|
82
76
|
};
|
|
83
77
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
const wrapStream = (stream: any) => (model: any, context: any, options: any) => {
|
|
79
|
+
guard(model);
|
|
80
|
+
if (providerId === "openrouter" && policy.openRouterZdrRequired()) {
|
|
81
|
+
const request = withOpenRouterZdr(model, options);
|
|
82
|
+
return stream.call(provider, request.model, context, request.options);
|
|
83
|
+
}
|
|
84
|
+
return stream.call(provider, model, context, options);
|
|
85
|
+
};
|
|
86
|
+
const guarded = {
|
|
88
87
|
...provider,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}),
|
|
95
|
-
...(originalStreamSimple && {
|
|
96
|
-
streamSimple: (model: any, ...rest: any[]) => {
|
|
97
|
-
guard(model);
|
|
98
|
-
return originalStreamSimple(model, ...rest);
|
|
99
|
-
},
|
|
100
|
-
}),
|
|
101
|
-
});
|
|
88
|
+
[POLICY_GUARD]: true,
|
|
89
|
+
...(provider.stream && { stream: wrapStream(provider.stream) }),
|
|
90
|
+
...(provider.streamSimple && { streamSimple: wrapStream(provider.streamSimple) }),
|
|
91
|
+
};
|
|
92
|
+
pi.registerProvider(guarded);
|
|
102
93
|
}
|
|
103
94
|
wrapped = true;
|
|
104
95
|
};
|
|
105
96
|
|
|
106
97
|
pi.on("session_start", async (_event, ctx) => {
|
|
98
|
+
policy.reset();
|
|
107
99
|
loadPolicy();
|
|
108
100
|
wrapProviders(ctx);
|
|
109
101
|
});
|
|
102
|
+
pi.on("session_shutdown", () => { policy.reset(); });
|
|
110
103
|
|
|
111
104
|
pi.registerCommand("provider", {
|
|
112
|
-
description: "Toggle
|
|
105
|
+
description: "Toggle providers (approve | remove <name>; zdr openrouter for ZDR-only routing)",
|
|
113
106
|
getArgumentCompletions: (prefix) => {
|
|
114
107
|
const [action, name = ""] = prefix.split(/\s+/);
|
|
115
108
|
if (!prefix.includes(" ")) {
|
|
116
|
-
return ["approve", "remove"]
|
|
109
|
+
return ["approve", "zdr", "remove"]
|
|
117
110
|
.filter((option) => option.startsWith(action))
|
|
118
111
|
.map((option) => ({ value: option, label: option }));
|
|
119
112
|
}
|
|
120
|
-
if (action !== "approve" && action !== "remove") return [];
|
|
121
|
-
return gatedProviders()
|
|
113
|
+
if (action !== "approve" && action !== "remove" && action !== "zdr") return [];
|
|
114
|
+
return (action === "zdr" ? ["openrouter"] : [...new Set([...gatedProviders(), "openrouter"])])
|
|
122
115
|
.filter((provider) => provider.startsWith(name))
|
|
123
116
|
.map((provider) => ({ value: `${action} ${provider}`, label: provider }));
|
|
124
117
|
},
|
|
@@ -126,43 +119,48 @@ export function registerPolicyGate(pi: ExtensionAPI): void {
|
|
|
126
119
|
const [action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
127
120
|
const name = rest.join(" ");
|
|
128
121
|
|
|
129
|
-
if (action === "approve" || action === "remove") {
|
|
122
|
+
if (action === "approve" || action === "remove" || action === "zdr") {
|
|
130
123
|
if (!name) {
|
|
131
124
|
ctx.ui.notify(`Usage: /provider ${action} <provider>`, "warning");
|
|
132
125
|
return;
|
|
133
126
|
}
|
|
134
|
-
if (
|
|
127
|
+
if (action === "zdr" && name !== "openrouter") {
|
|
128
|
+
ctx.ui.notify("ZDR mode is supported only for OpenRouter. Use: /provider zdr openrouter", "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (name !== "openrouter" && !gatedProviders().includes(name)) {
|
|
135
132
|
ctx.ui.notify(
|
|
136
133
|
`“${name}” is not a gated provider. Gated: ${gatedProviders().join(", ") || "none"}`,
|
|
137
134
|
"warning",
|
|
138
135
|
);
|
|
139
136
|
return;
|
|
140
137
|
}
|
|
141
|
-
if (action === "
|
|
142
|
-
else
|
|
143
|
-
|
|
138
|
+
if (action === "zdr") policy.approveOpenRouterZdr();
|
|
139
|
+
else if (action === "approve") policy.approve(name);
|
|
140
|
+
else policy.revoke(name);
|
|
141
|
+
ctx.ui.notify(`${name} is now ${providerStateText(name, policy.providerState(name))}.`, "info");
|
|
144
142
|
return;
|
|
145
143
|
}
|
|
146
144
|
|
|
147
145
|
if (action) {
|
|
148
|
-
ctx.ui.notify(`Unknown action “${action}”. Use: /provider [approve|remove <name>]`, "warning");
|
|
146
|
+
ctx.ui.notify(`Unknown action “${action}”. Use: /provider [approve|remove <name>] or /provider zdr openrouter`, "warning");
|
|
149
147
|
return;
|
|
150
148
|
}
|
|
151
149
|
|
|
152
|
-
const rows = await providerRows(ctx);
|
|
150
|
+
const rows = await providerRows(ctx, policy);
|
|
153
151
|
|
|
154
152
|
// Headless: plain text, no cursor to draw.
|
|
155
153
|
if (!ctx.hasUI) {
|
|
156
154
|
ctx.ui.notify(
|
|
157
|
-
rows.map((row) => `${row.state === "
|
|
155
|
+
rows.map((row) => `${row.state === "blocked" || row.state === "denied" ? "[off]" : "[on] "} ${row.display}: ${providerStateText(row.provider, row.state)}`).join("\n"),
|
|
158
156
|
"info",
|
|
159
157
|
);
|
|
160
158
|
return;
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
await openProviderPicker(ctx, {
|
|
164
|
-
rows: () => providerRows(ctx),
|
|
165
|
-
toggle: (provider) => toggleProvider(provider)
|
|
162
|
+
rows: () => providerRows(ctx, policy),
|
|
163
|
+
toggle: (provider) => policy.toggleProvider(provider),
|
|
166
164
|
});
|
|
167
165
|
},
|
|
168
166
|
});
|
|
@@ -22,12 +22,22 @@ export const STATE_TEXT = {
|
|
|
22
22
|
approved: "Approved",
|
|
23
23
|
blocked: "Needs Approval",
|
|
24
24
|
denied: "Denied",
|
|
25
|
+
zdr: "On (ZDR)",
|
|
25
26
|
} as const;
|
|
26
27
|
|
|
27
28
|
export type StateKey = keyof typeof STATE_TEXT;
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
export function providerToggleStates(provider: string): StateKey[] {
|
|
31
|
+
return provider === "openrouter" ? ["blocked", "approved", "zdr"] : ["auto", "blocked"];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function providerStateText(provider: string, state: StateKey): string {
|
|
35
|
+
if (provider === "openrouter") {
|
|
36
|
+
if (state === "auto" || state === "approved") return "On";
|
|
37
|
+
if (state === "blocked") return "Off";
|
|
38
|
+
}
|
|
39
|
+
return STATE_TEXT[state];
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
export interface ProviderRow {
|
|
33
43
|
/** Stable id used by SettingsList and by the toggle handler. */
|
|
@@ -52,6 +62,7 @@ const STATE_LEVEL: Record<StateKey, number> = {
|
|
|
52
62
|
approved: 100,
|
|
53
63
|
blocked: 45,
|
|
54
64
|
denied: 0,
|
|
65
|
+
zdr: 100,
|
|
55
66
|
};
|
|
56
67
|
|
|
57
68
|
/** Fallback for terminals without truecolor. */
|
|
@@ -60,6 +71,7 @@ const STATE_THEME_COLOUR: Record<StateKey, string> = {
|
|
|
60
71
|
approved: "success",
|
|
61
72
|
blocked: "warning",
|
|
62
73
|
denied: "error",
|
|
74
|
+
zdr: "success",
|
|
63
75
|
};
|
|
64
76
|
|
|
65
77
|
/**
|
|
@@ -120,13 +132,12 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
|
|
|
120
132
|
// `values` carries the COLOURED strings, not plain text. SettingsList shows
|
|
121
133
|
// whichever it cycles to immediately, so pre-colouring them means the new
|
|
122
134
|
// text arrives already in the right colour rather than flashing uncoloured.
|
|
123
|
-
const
|
|
124
|
-
|
|
135
|
+
const valueFor = (row: ProviderRow, state = row.state) => colourState(theme, state, providerStateText(row.provider, state));
|
|
125
136
|
const items = rows.map((row) => ({
|
|
126
137
|
id: row.id,
|
|
127
138
|
label: labelFor(theme, row),
|
|
128
|
-
values:
|
|
129
|
-
currentValue:
|
|
139
|
+
values: providerToggleStates(row.provider).map((state) => valueFor(row, state)),
|
|
140
|
+
currentValue: valueFor(row),
|
|
130
141
|
}));
|
|
131
142
|
|
|
132
143
|
// Synchronous throughout: label, dot and value all change in one render.
|
|
@@ -137,7 +148,7 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
|
|
|
137
148
|
|
|
138
149
|
if (row.state === "denied") {
|
|
139
150
|
// Undo the value SettingsList optimistically cycled to.
|
|
140
|
-
list?.updateValue(id,
|
|
151
|
+
list?.updateValue(id, valueFor(row));
|
|
141
152
|
ctx.ui.notify(`${row.display} is denied in policy. Edit pi-plus.json to change that.`, "warning");
|
|
142
153
|
return;
|
|
143
154
|
}
|
|
@@ -145,7 +156,7 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
|
|
|
145
156
|
const next = deps.toggle(row.provider);
|
|
146
157
|
row.state = next;
|
|
147
158
|
item.label = labelFor(theme, row);
|
|
148
|
-
list?.updateValue(id,
|
|
159
|
+
list?.updateValue(id, valueFor(row));
|
|
149
160
|
list?.invalidate?.();
|
|
150
161
|
};
|
|
151
162
|
|
|
@@ -94,7 +94,7 @@ async function inspect(ctx: any): Promise<Feature[]> {
|
|
|
94
94
|
name: "Providers",
|
|
95
95
|
ready: config.policy.requireApproval.length > 0,
|
|
96
96
|
detail: `${config.policy.requireApproval.length} gated pattern(s), ${config.policy.autoApprove.length} auto-approved`,
|
|
97
|
-
commands: ["/provider", "/provider
|
|
97
|
+
commands: ["/provider", "/provider approve <name>", "/provider zdr openrouter"],
|
|
98
98
|
open: "/provider",
|
|
99
99
|
});
|
|
100
100
|
|
|
@@ -131,12 +131,11 @@ async function inspect(ctx: any): Promise<Feature[]> {
|
|
|
131
131
|
open: "/remote setup",
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
/*
|
|
135
|
-
const claudeRemote = env("PI_CLAUDE_REMOTE") === "1";
|
|
134
|
+
/* No persisted auto-start preference: connection is opted into per session. */
|
|
136
135
|
features.push({
|
|
137
136
|
name: "Claude Remote",
|
|
138
|
-
ready:
|
|
139
|
-
detail:
|
|
137
|
+
ready: false,
|
|
138
|
+
detail: "session-only Claude app mirror; defaults Off; requires Anthropic OAuth",
|
|
140
139
|
commands: ["/claude-remote", "/claude-remote on", "/claude-remote off"],
|
|
141
140
|
setup: "/claude-remote",
|
|
142
141
|
open: "/claude-remote",
|
|
@@ -6,6 +6,7 @@ type HostProviderRegistry = Pick<
|
|
|
6
6
|
| "getApiKeyForProvider"
|
|
7
7
|
| "getProviderAuthStatus"
|
|
8
8
|
| "getRegisteredProviderConfig"
|
|
9
|
+
| "getRegisteredNativeProvider"
|
|
9
10
|
| "getRegisteredProviderIds"
|
|
10
11
|
| "isUsingOAuth"
|
|
11
12
|
>;
|
|
@@ -27,10 +28,14 @@ export async function synchronizeWorkflowModelRuntime(input: {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
for (const providerId of hostProviderIds) {
|
|
31
|
+
const native = host.getRegisteredNativeProvider(providerId);
|
|
30
32
|
const config = host.getRegisteredProviderConfig(providerId);
|
|
31
|
-
if (!config) continue;
|
|
33
|
+
if (!native && !config) continue;
|
|
32
34
|
child.unregisterProvider(providerId);
|
|
33
|
-
|
|
35
|
+
// Native registrations carry live auth/policy/stream wrappers. Copying only
|
|
36
|
+
// legacy config silently drops those guards in workflow child sessions.
|
|
37
|
+
if (native) child.registerNativeProvider(native);
|
|
38
|
+
else child.registerProvider(providerId, config!);
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const selectedProvider = selectedModel?.provider;
|