@timqi/pier 0.0.1 → 0.0.2
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 +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Relay one Pi provider-owned login interaction through short-lived in-memory state.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { logger } from "../log.js";
|
|
4
|
+
const log = logger("web.providers");
|
|
5
|
+
const FLOW_TTL_MS = 10 * 60_000;
|
|
6
|
+
const TERMINAL_TTL_MS = 60_000;
|
|
7
|
+
const MAX_EVENTS = 100;
|
|
8
|
+
const MAX_TEXT = 4_096;
|
|
9
|
+
const text = (value, max = MAX_TEXT) => typeof value === "string" ? value.slice(0, max) : "";
|
|
10
|
+
const scrub = (value, secrets, max = MAX_TEXT) => {
|
|
11
|
+
let visible = typeof value === "string" ? value : "";
|
|
12
|
+
for (const secret of secrets)
|
|
13
|
+
if (secret)
|
|
14
|
+
visible = visible.replaceAll(secret, "[redacted]");
|
|
15
|
+
return visible.slice(0, max);
|
|
16
|
+
};
|
|
17
|
+
function visibleEvent(event, secrets) {
|
|
18
|
+
if (event.type === "info") {
|
|
19
|
+
const message = scrub(event.message, secrets);
|
|
20
|
+
if (!message)
|
|
21
|
+
return null;
|
|
22
|
+
const links = event.links?.slice(0, 20).flatMap((link) => {
|
|
23
|
+
const url = scrub(link.url, secrets, 4_096);
|
|
24
|
+
return url ? [{ url, ...(link.label ? { label: scrub(link.label, secrets, 500) } : {}) }] : [];
|
|
25
|
+
});
|
|
26
|
+
return { type: "info", message, ...(links?.length ? { links } : {}) };
|
|
27
|
+
}
|
|
28
|
+
if (event.type === "auth_url") {
|
|
29
|
+
const url = scrub(event.url, secrets, 4_096);
|
|
30
|
+
return url ? {
|
|
31
|
+
type: "auth_url",
|
|
32
|
+
url,
|
|
33
|
+
...(event.instructions ? { instructions: scrub(event.instructions, secrets) } : {}),
|
|
34
|
+
} : null;
|
|
35
|
+
}
|
|
36
|
+
if (event.type === "device_code") {
|
|
37
|
+
const userCode = scrub(event.userCode, secrets, 500);
|
|
38
|
+
const verificationUri = scrub(event.verificationUri, secrets, 4_096);
|
|
39
|
+
return userCode && verificationUri ? {
|
|
40
|
+
type: "device_code",
|
|
41
|
+
userCode,
|
|
42
|
+
verificationUri,
|
|
43
|
+
...(typeof event.intervalSeconds === "number" ? { intervalSeconds: event.intervalSeconds } : {}),
|
|
44
|
+
...(typeof event.expiresInSeconds === "number" ? { expiresInSeconds: event.expiresInSeconds } : {}),
|
|
45
|
+
} : null;
|
|
46
|
+
}
|
|
47
|
+
const message = scrub(event.message, secrets);
|
|
48
|
+
return message ? { type: "progress", message } : null;
|
|
49
|
+
}
|
|
50
|
+
function visiblePrompt(prompt) {
|
|
51
|
+
const message = text(prompt.message);
|
|
52
|
+
if (!message)
|
|
53
|
+
throw new Error("provider returned an invalid prompt");
|
|
54
|
+
if (prompt.type === "select") {
|
|
55
|
+
const options = prompt.options.slice(0, 100).flatMap((option) => {
|
|
56
|
+
const id = text(option.id, 500);
|
|
57
|
+
const label = text(option.label, 500);
|
|
58
|
+
return id && label
|
|
59
|
+
? [{ id, label, ...(option.description ? { description: text(option.description, 1_000) } : {}) }]
|
|
60
|
+
: [];
|
|
61
|
+
});
|
|
62
|
+
if (!options.length)
|
|
63
|
+
throw new Error("provider returned an invalid select prompt");
|
|
64
|
+
return { type: "select", message, options };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: prompt.type,
|
|
68
|
+
message,
|
|
69
|
+
...(prompt.placeholder ? { placeholder: text(prompt.placeholder, 500) } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const snapshot = ({ controller: _controller, pending: _pending, redactions: _redactions, committing: _committing, run: _run, expires: _expires, ...flow }) => flow;
|
|
73
|
+
export class ProviderFlows {
|
|
74
|
+
providers;
|
|
75
|
+
#flows = new Map();
|
|
76
|
+
constructor(providers) {
|
|
77
|
+
this.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
async start(providerId, type, prepare = () => Promise.resolve(), complete = () => Promise.resolve()) {
|
|
80
|
+
if ([...this.#flows.values()].some((flow) => flow.providerId === providerId && flow.state === "running")) {
|
|
81
|
+
throw new Error(`authentication already running for ${providerId}`);
|
|
82
|
+
}
|
|
83
|
+
const flow = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
providerId,
|
|
86
|
+
type,
|
|
87
|
+
state: "running",
|
|
88
|
+
events: [],
|
|
89
|
+
controller: new AbortController(),
|
|
90
|
+
redactions: [],
|
|
91
|
+
committing: false,
|
|
92
|
+
};
|
|
93
|
+
flow.expires = setTimeout(() => this.#expire(flow), FLOW_TTL_MS);
|
|
94
|
+
flow.expires.unref();
|
|
95
|
+
this.#flows.set(flow.id, flow);
|
|
96
|
+
try {
|
|
97
|
+
await prepare();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
clearTimeout(flow.expires);
|
|
101
|
+
this.#flows.delete(flow.id);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
if (!this.#flows.has(flow.id))
|
|
105
|
+
throw new Error("authentication flow expired during setup");
|
|
106
|
+
flow.run = this.#run(flow, complete);
|
|
107
|
+
return snapshot(flow);
|
|
108
|
+
}
|
|
109
|
+
get(id) {
|
|
110
|
+
const flow = this.#flows.get(id);
|
|
111
|
+
return flow ? snapshot(flow) : undefined;
|
|
112
|
+
}
|
|
113
|
+
respond(id, promptId, value) {
|
|
114
|
+
const flow = this.#require(id);
|
|
115
|
+
if (flow.state !== "running" || !flow.pending || flow.pending.id !== promptId) {
|
|
116
|
+
throw new Error("prompt is no longer waiting for a response");
|
|
117
|
+
}
|
|
118
|
+
const pending = flow.pending;
|
|
119
|
+
flow.pending = undefined;
|
|
120
|
+
flow.prompt = undefined;
|
|
121
|
+
pending.cleanup();
|
|
122
|
+
if (value && pending.redact)
|
|
123
|
+
flow.redactions.push(value.slice(0, 64 * 1024));
|
|
124
|
+
pending.resolve(value);
|
|
125
|
+
}
|
|
126
|
+
async cancel(id) {
|
|
127
|
+
const flow = this.#require(id);
|
|
128
|
+
if (flow.state !== "running")
|
|
129
|
+
return snapshot(flow);
|
|
130
|
+
if (flow.committing)
|
|
131
|
+
return null;
|
|
132
|
+
flow.controller.abort();
|
|
133
|
+
this.#settlePrompt(flow, new Error("Login cancelled"));
|
|
134
|
+
// A provider that ignores its abort signal must not park this request —
|
|
135
|
+
// the flow then settles (or expires) in the background instead.
|
|
136
|
+
await Promise.race([flow.run, new Promise((r) => setTimeout(r, 2_000).unref())]);
|
|
137
|
+
return snapshot(flow);
|
|
138
|
+
}
|
|
139
|
+
async #run(flow, complete) {
|
|
140
|
+
let rollback;
|
|
141
|
+
try {
|
|
142
|
+
rollback = await this.providers.login(flow.providerId, flow.type, {
|
|
143
|
+
signal: flow.controller.signal,
|
|
144
|
+
prompt: (prompt) => this.#prompt(flow, prompt),
|
|
145
|
+
notify: (event) => {
|
|
146
|
+
const visible = visibleEvent(event, flow.redactions);
|
|
147
|
+
if (!visible)
|
|
148
|
+
return;
|
|
149
|
+
flow.events.push(visible);
|
|
150
|
+
if (flow.events.length > MAX_EVENTS)
|
|
151
|
+
flow.events.shift();
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
flow.controller.signal.throwIfAborted();
|
|
155
|
+
flow.committing = true;
|
|
156
|
+
await complete();
|
|
157
|
+
flow.committing = false;
|
|
158
|
+
this.#finish(flow, "succeeded");
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
flow.committing = false;
|
|
162
|
+
let failure = err;
|
|
163
|
+
if (rollback) {
|
|
164
|
+
try {
|
|
165
|
+
await rollback();
|
|
166
|
+
}
|
|
167
|
+
catch (rollbackError) {
|
|
168
|
+
failure = new AggregateError([err, rollbackError], "provider login rollback failed");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (flow.state !== "running")
|
|
172
|
+
return;
|
|
173
|
+
if (flow.controller.signal.aborted && failure === err)
|
|
174
|
+
this.#finish(flow, "cancelled");
|
|
175
|
+
else {
|
|
176
|
+
const error = this.#safeError(flow, failure);
|
|
177
|
+
log.warn(`provider login failed for ${flow.providerId}: ${error}`);
|
|
178
|
+
this.#finish(flow, "failed", error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
#prompt(flow, prompt) {
|
|
183
|
+
if (flow.controller.signal.aborted)
|
|
184
|
+
return Promise.reject(new Error("Login cancelled"));
|
|
185
|
+
if (flow.pending)
|
|
186
|
+
return Promise.reject(new Error("provider requested overlapping prompts"));
|
|
187
|
+
const visible = visiblePrompt(prompt);
|
|
188
|
+
const { signal } = prompt;
|
|
189
|
+
const id = randomUUID();
|
|
190
|
+
flow.prompt = { ...visible, id };
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const onAbort = () => {
|
|
193
|
+
if (flow.pending?.id !== id)
|
|
194
|
+
return;
|
|
195
|
+
flow.pending = undefined;
|
|
196
|
+
flow.prompt = undefined;
|
|
197
|
+
reject(new Error("Prompt cancelled"));
|
|
198
|
+
};
|
|
199
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
200
|
+
flow.pending = { id, redact: visible.type !== "select", resolve, reject, cleanup };
|
|
201
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
202
|
+
if (signal?.aborted)
|
|
203
|
+
onAbort();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
#settlePrompt(flow, error) {
|
|
207
|
+
const pending = flow.pending;
|
|
208
|
+
flow.pending = undefined;
|
|
209
|
+
flow.prompt = undefined;
|
|
210
|
+
pending?.cleanup();
|
|
211
|
+
pending?.reject(error);
|
|
212
|
+
}
|
|
213
|
+
#finish(flow, state, error) {
|
|
214
|
+
if (flow.state !== "running")
|
|
215
|
+
return;
|
|
216
|
+
clearTimeout(flow.expires);
|
|
217
|
+
this.#settlePrompt(flow, new Error("Login finished"));
|
|
218
|
+
flow.state = state;
|
|
219
|
+
flow.error = error;
|
|
220
|
+
flow.events = [];
|
|
221
|
+
flow.redactions = [];
|
|
222
|
+
flow.expires = setTimeout(() => this.#flows.delete(flow.id), TERMINAL_TTL_MS);
|
|
223
|
+
flow.expires.unref();
|
|
224
|
+
}
|
|
225
|
+
#safeError(flow, err) {
|
|
226
|
+
return scrub(String(err), flow.redactions);
|
|
227
|
+
}
|
|
228
|
+
#require(id) {
|
|
229
|
+
const flow = this.#flows.get(id);
|
|
230
|
+
if (!flow)
|
|
231
|
+
throw new Error("unknown authentication flow");
|
|
232
|
+
return flow;
|
|
233
|
+
}
|
|
234
|
+
#expire(flow) {
|
|
235
|
+
if (flow.committing) {
|
|
236
|
+
flow.expires = setTimeout(() => this.#expire(flow), 1_000);
|
|
237
|
+
flow.expires.unref();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (flow.state === "running") {
|
|
241
|
+
flow.controller.abort();
|
|
242
|
+
this.#settlePrompt(flow, new Error("Login expired"));
|
|
243
|
+
flow.state = "cancelled";
|
|
244
|
+
}
|
|
245
|
+
flow.events = [];
|
|
246
|
+
flow.redactions = [];
|
|
247
|
+
this.#flows.delete(flow.id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// HTTP boundary for provider configuration.
|
|
2
|
+
import { isProviderApi, validateProviderSetup } from "../core/types.js";
|
|
3
|
+
import { ProviderFlows } from "./provider-flows.js";
|
|
4
|
+
/** Unknown JSON into a trimmed, validated ProviderSetup. Shape lives here;
|
|
5
|
+
* the rules (id charset, endpoint safety, model limits) are the shared
|
|
6
|
+
* validateProviderSetup in core — web/ cannot import agent/ to reuse its
|
|
7
|
+
* copy, and must not re-implement them. */
|
|
8
|
+
function setupFrom(raw) {
|
|
9
|
+
if (typeof raw !== "object" || raw === null)
|
|
10
|
+
return null;
|
|
11
|
+
const input = raw;
|
|
12
|
+
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
13
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
14
|
+
const endpoint = typeof input.endpoint === "string" ? input.endpoint.trim() : "";
|
|
15
|
+
if (!id)
|
|
16
|
+
return null;
|
|
17
|
+
if (input.kind === "builtin")
|
|
18
|
+
return { kind: "builtin", id, ...(endpoint ? { endpoint } : {}) };
|
|
19
|
+
if (input.kind !== "custom" || !isProviderApi(input.api) || !Array.isArray(input.models))
|
|
20
|
+
return null;
|
|
21
|
+
const models = [];
|
|
22
|
+
for (const model of input.models) {
|
|
23
|
+
const modelId = typeof model === "object" && model !== null &&
|
|
24
|
+
typeof model.id === "string"
|
|
25
|
+
? model.id.trim()
|
|
26
|
+
: "";
|
|
27
|
+
if (!modelId)
|
|
28
|
+
return null;
|
|
29
|
+
models.push({ id: modelId, reasoning: model.reasoning === true });
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
kind: "custom",
|
|
33
|
+
id,
|
|
34
|
+
...(name ? { name } : {}),
|
|
35
|
+
endpoint,
|
|
36
|
+
api: input.api,
|
|
37
|
+
models,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function registerProviderRoutes(app, providers) {
|
|
41
|
+
const flows = new ProviderFlows(providers);
|
|
42
|
+
const configure = async (setup) => {
|
|
43
|
+
await providers.setup(setup);
|
|
44
|
+
const provider = (await providers.providers()).find((candidate) => candidate.id === setup.id);
|
|
45
|
+
if (!provider)
|
|
46
|
+
throw new Error(`provider did not load: ${setup.id}`);
|
|
47
|
+
return provider;
|
|
48
|
+
};
|
|
49
|
+
const requireMethod = (provider, type) => {
|
|
50
|
+
if (!provider.methods.some((method) => method.type === type)) {
|
|
51
|
+
throw new Error(`${type} login is not available for ${provider.id}`);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
app.get("/api/providers", async (c) => {
|
|
55
|
+
c.header("cache-control", "no-store");
|
|
56
|
+
return c.json(await providers.providers());
|
|
57
|
+
});
|
|
58
|
+
app.post("/api/providers/setup", async (c) => {
|
|
59
|
+
const body = await c.req.json().catch(() => null);
|
|
60
|
+
const setup = setupFrom(body?.setup);
|
|
61
|
+
const authType = body?.authType;
|
|
62
|
+
if (!setup || (authType !== null && authType !== "api_key" && authType !== "oauth")) {
|
|
63
|
+
return c.json({ error: "valid setup and authType required" }, 400);
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
validateProviderSetup(setup); // before any flow starts — the throw is the 400
|
|
67
|
+
if (setup.kind === "custom" && authType === "oauth") {
|
|
68
|
+
return c.json({ error: "custom providers support API-key authentication" }, 400);
|
|
69
|
+
}
|
|
70
|
+
if (authType === null)
|
|
71
|
+
return c.json({ ok: true, provider: await configure(setup) });
|
|
72
|
+
const before = (await providers.providers()).find((candidate) => candidate.id === setup.id);
|
|
73
|
+
if (setup.kind === "builtin" && !before?.builtin) {
|
|
74
|
+
return c.json({ error: "unknown built-in provider" }, 400);
|
|
75
|
+
}
|
|
76
|
+
if (before)
|
|
77
|
+
requireMethod(before, authType);
|
|
78
|
+
const flow = await flows.start(setup.id, authType, async () => {
|
|
79
|
+
if (!before)
|
|
80
|
+
requireMethod(await configure(setup), authType);
|
|
81
|
+
}, async () => {
|
|
82
|
+
if (before)
|
|
83
|
+
await configure(setup);
|
|
84
|
+
});
|
|
85
|
+
return c.json(flow, 202);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return c.json({ error: String(err) }, 400);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
app.get("/api/providers/flows/:id", (c) => {
|
|
92
|
+
c.header("cache-control", "no-store");
|
|
93
|
+
const flow = flows.get(c.req.param("id"));
|
|
94
|
+
return flow ? c.json(flow) : c.json({ error: "unknown authentication flow" }, 404);
|
|
95
|
+
});
|
|
96
|
+
app.post("/api/providers/flows/:id/respond", async (c) => {
|
|
97
|
+
const body = await c.req.json().catch(() => null);
|
|
98
|
+
if (typeof body?.promptId !== "string" || typeof body?.value !== "string" ||
|
|
99
|
+
body.value.length > 64 * 1024)
|
|
100
|
+
return c.json({ error: "promptId and value required (64 KiB maximum)" }, 400);
|
|
101
|
+
try {
|
|
102
|
+
flows.respond(c.req.param("id"), body.promptId, body.value);
|
|
103
|
+
return c.body(null, 204);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return c.json({ error: String(err) }, 409);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
app.post("/api/providers/flows/:id/cancel", async (c) => {
|
|
110
|
+
try {
|
|
111
|
+
const flow = await flows.cancel(c.req.param("id"));
|
|
112
|
+
if (!flow)
|
|
113
|
+
return c.json({ error: "authentication is finishing" }, 409);
|
|
114
|
+
return flow.state === "failed" ? c.json(flow, 409) : c.json(flow);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return c.json({ error: String(err) }, 404);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
app.post("/api/providers/:provider/logout", async (c) => {
|
|
121
|
+
try {
|
|
122
|
+
await providers.logout(c.req.param("provider"));
|
|
123
|
+
return c.json({ ok: true });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return c.json({ error: String(err) }, 400);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|