@patimweb/pi-teams 1.7.3 → 1.9.0
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 +54 -645
- package/docs/architecture.md +59 -0
- package/docs/configuration.md +187 -0
- package/docs/entra-app-registration.md +16 -2
- package/docs/listen-mode.md +123 -0
- package/docs/security.md +116 -0
- package/docs/sign-in.md +110 -0
- package/docs/tools.md +150 -0
- package/docs/troubleshooting.md +55 -0
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +23 -0
- package/src/config/index.ts +23 -0
- package/src/extension/index.ts +40 -6
- package/src/graph/client.ts +55 -1
- package/src/graph/media.ts +46 -2
- package/src/status.ts +9 -6
- package/src/tools/teams-download-files.ts +47 -3
- package/src/tools/teams-watch.ts +5 -2
- package/src/utils/errors.ts +29 -0
- package/src/watch/loop.ts +36 -4
- package/src/watch/pin.ts +109 -0
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* returns the paths, so a reader can open them.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
11
12
|
import { Type } from "typebox";
|
|
12
13
|
import { getAgentDir } from "../config/index.ts";
|
|
13
14
|
import { saveMessageFiles, type SavedFile, type SkippedFile } from "../graph/media.ts";
|
|
@@ -47,7 +48,13 @@ export const teamsDownloadFilesTool = {
|
|
|
47
48
|
limit: Type.Optional(
|
|
48
49
|
Type.Number({ description: "How many recent messages to scan (default 10, max 50)" }),
|
|
49
50
|
),
|
|
50
|
-
dir: Type.Optional(
|
|
51
|
+
dir: Type.Optional(
|
|
52
|
+
Type.String({
|
|
53
|
+
description:
|
|
54
|
+
"Directory to save into. Must be inside the working directory, the pi agent directory, or the " +
|
|
55
|
+
"configured downloadDir. Defaults to the agent directory.",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
51
58
|
account: AccountParam,
|
|
52
59
|
tenant: TenantParam,
|
|
53
60
|
}),
|
|
@@ -106,7 +113,7 @@ export const teamsDownloadFilesTool = {
|
|
|
106
113
|
: await listChannelMessages(conn, channel, { max: scanLimit, signal });
|
|
107
114
|
}
|
|
108
115
|
|
|
109
|
-
const dir = params.dir
|
|
116
|
+
const dir = resolveDownloadDir(params.dir, join(getAgentDir(), "pi-teams-files", slug), conn.downloadDir);
|
|
110
117
|
|
|
111
118
|
const saved: SavedFile[] = [];
|
|
112
119
|
const skipped: SkippedFile[] = [];
|
|
@@ -143,3 +150,40 @@ async function one(message: MessageSummary | undefined, messageId: string): Prom
|
|
|
143
150
|
if (!message) throw new TargetError(`No message ${messageId} in this conversation.`);
|
|
144
151
|
return [message];
|
|
145
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where the download may land.
|
|
156
|
+
*
|
|
157
|
+
* The directory is a tool parameter, so it is chosen by the model — and the
|
|
158
|
+
* model is reading messages other people wrote. Confining it keeps a download
|
|
159
|
+
* from landing in `~/.ssh` or a shell profile: the working directory and the
|
|
160
|
+
* agent directory are always allowed, and a user who wants somewhere else says
|
|
161
|
+
* so once in the configuration rather than per call.
|
|
162
|
+
*
|
|
163
|
+
* `~` is expanded first, because a path that silently means a literal `./~`
|
|
164
|
+
* would be its own small surprise.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveDownloadDir(
|
|
167
|
+
requested: string | undefined,
|
|
168
|
+
fallback: string,
|
|
169
|
+
configured: string | undefined,
|
|
170
|
+
): string {
|
|
171
|
+
if (!requested) return configured ? resolve(expandHome(configured)) : fallback;
|
|
172
|
+
|
|
173
|
+
const target = resolve(expandHome(requested));
|
|
174
|
+
const roots = [resolve(process.cwd()), resolve(getAgentDir())];
|
|
175
|
+
if (configured) roots.push(resolve(expandHome(configured)));
|
|
176
|
+
|
|
177
|
+
if (roots.some((root) => target === root || target.startsWith(root + sep))) return target;
|
|
178
|
+
|
|
179
|
+
throw new TargetError(
|
|
180
|
+
`Refusing to write downloads to "${target}". Allowed: ${roots.join(", ")}. ` +
|
|
181
|
+
`Set "downloadDir" in pi-teams.json to allow another location.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function expandHome(value: string): string {
|
|
186
|
+
if (value === "~") return homedir();
|
|
187
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return join(homedir(), value.slice(2));
|
|
188
|
+
return isAbsolute(value) ? value : value;
|
|
189
|
+
}
|
package/src/tools/teams-watch.ts
CHANGED
|
@@ -53,6 +53,7 @@ const CURSOR_NOTES = [
|
|
|
53
53
|
/** Renders the effective settings the same way the config file would. */
|
|
54
54
|
function describe(watch: {
|
|
55
55
|
enabled: boolean;
|
|
56
|
+
autoStart: boolean;
|
|
56
57
|
intervalSeconds: number;
|
|
57
58
|
chats: string[];
|
|
58
59
|
from: string[];
|
|
@@ -62,6 +63,7 @@ function describe(watch: {
|
|
|
62
63
|
}): string {
|
|
63
64
|
return [
|
|
64
65
|
`- enabled: ${watch.enabled}`,
|
|
66
|
+
`- starts with a session: ${watch.autoStart}`,
|
|
65
67
|
`- poll every: ${watch.intervalSeconds} s`,
|
|
66
68
|
`- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
|
|
67
69
|
`- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
|
|
@@ -82,8 +84,9 @@ function runtimeLines(runtime: WatchRuntimeStatus | undefined, conn: TeamsConnec
|
|
|
82
84
|
return [
|
|
83
85
|
"Nothing is polling in this session.",
|
|
84
86
|
"",
|
|
85
|
-
"Listen mode
|
|
86
|
-
+ "
|
|
87
|
+
"Listen mode starts only when it is switched on here — `/teams-listen on`, or "
|
|
88
|
+
+ "`teams_watch action: enable`. A stored `enabled: true` does not start it by itself: "
|
|
89
|
+
+ "set `watch.autoStart` if a session is meant to start listening on its own.",
|
|
87
90
|
];
|
|
88
91
|
}
|
|
89
92
|
|
package/src/utils/errors.ts
CHANGED
|
@@ -23,6 +23,34 @@ export class GraphError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Raised when something asks us to send the access token somewhere it must not go.
|
|
28
|
+
*
|
|
29
|
+
* Message content is attacker-controlled — a file attachment's `contentUrl` and
|
|
30
|
+
* an `<img src>` in the body are both written by whoever sent the message — so a
|
|
31
|
+
* download URL that is not the tenant's own Graph endpoint is refused rather
|
|
32
|
+
* than fetched with an `Authorization` header.
|
|
33
|
+
*/
|
|
34
|
+
export class UntrustedHostError extends Error {
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly expectedOrigin: string;
|
|
37
|
+
constructor(url: string, expectedOrigin: string) {
|
|
38
|
+
let host = url;
|
|
39
|
+
try {
|
|
40
|
+
host = new URL(url).host;
|
|
41
|
+
} catch {
|
|
42
|
+
/* keep the raw string */
|
|
43
|
+
}
|
|
44
|
+
super(
|
|
45
|
+
`Refusing to send your Microsoft Graph token to "${host}". ` +
|
|
46
|
+
`Only ${expectedOrigin} is trusted with it.`,
|
|
47
|
+
);
|
|
48
|
+
this.name = "UntrustedHostError";
|
|
49
|
+
this.url = url;
|
|
50
|
+
this.expectedOrigin = expectedOrigin;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
/** Raised when a scope rule blocks an operation. */
|
|
27
55
|
export class ScopeDeniedError extends Error {
|
|
28
56
|
readonly target: string;
|
|
@@ -49,6 +77,7 @@ const CONSENT_HINTS: Record<string, string> = {
|
|
|
49
77
|
/** Normalize any thrown value into a readable, actionable message. */
|
|
50
78
|
export function formatGraphError(error: unknown): string {
|
|
51
79
|
if (error instanceof ScopeDeniedError) return error.message;
|
|
80
|
+
if (error instanceof UntrustedHostError) return error.message;
|
|
52
81
|
|
|
53
82
|
if (error instanceof GraphError) {
|
|
54
83
|
switch (error.code) {
|
package/src/watch/loop.ts
CHANGED
|
@@ -328,6 +328,15 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
328
328
|
|
|
329
329
|
let stopped = false;
|
|
330
330
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
331
|
+
/**
|
|
332
|
+
* The tick currently running, if any.
|
|
333
|
+
*
|
|
334
|
+
* Every tick schedules the next one when it finishes, so two ticks running at
|
|
335
|
+
* once would leave two chains behind and quietly double the polling rate for
|
|
336
|
+
* the rest of the session. `poll()` therefore joins the tick in flight
|
|
337
|
+
* instead of starting a second one.
|
|
338
|
+
*/
|
|
339
|
+
let inFlight: Promise<void> | undefined;
|
|
331
340
|
let me: SignedInUser | undefined;
|
|
332
341
|
let meResolved = false;
|
|
333
342
|
let failureCount = 0;
|
|
@@ -343,7 +352,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
343
352
|
|
|
344
353
|
const schedule = (delayMs: number) => {
|
|
345
354
|
if (stopped) return;
|
|
346
|
-
timer = setTimeout(() => void
|
|
355
|
+
timer = setTimeout(() => void runTick(), delayMs);
|
|
347
356
|
// Do not hold the process open for a poll that can wait.
|
|
348
357
|
timer.unref?.();
|
|
349
358
|
};
|
|
@@ -406,14 +415,31 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
406
415
|
options.onError?.(status.lastError);
|
|
407
416
|
}
|
|
408
417
|
|
|
409
|
-
|
|
418
|
+
// Outside the try above on purpose, and guarded on its own: a throwing
|
|
419
|
+
// status callback must not become an unhandled rejection that takes the
|
|
420
|
+
// watcher down without a word.
|
|
421
|
+
try {
|
|
422
|
+
options.onTick?.(status);
|
|
423
|
+
} catch {
|
|
424
|
+
/* a footer that cannot render is not worth stopping the loop for */
|
|
425
|
+
}
|
|
426
|
+
|
|
410
427
|
schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
|
|
411
428
|
}
|
|
412
429
|
|
|
430
|
+
/** Run a tick unless one is already running; either way, resolve with it. */
|
|
431
|
+
function runTick(): Promise<void> {
|
|
432
|
+
if (inFlight) return inFlight;
|
|
433
|
+
inFlight = tick().finally(() => {
|
|
434
|
+
inFlight = undefined;
|
|
435
|
+
});
|
|
436
|
+
return inFlight;
|
|
437
|
+
}
|
|
438
|
+
|
|
413
439
|
// The first tick is immediate: it answers what is still open since the last
|
|
414
440
|
// run, and a user who switched listen mode on should not wait a full interval
|
|
415
441
|
// to see it alive.
|
|
416
|
-
void
|
|
442
|
+
void runTick();
|
|
417
443
|
|
|
418
444
|
return {
|
|
419
445
|
stop() {
|
|
@@ -424,9 +450,15 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
|
|
|
424
450
|
},
|
|
425
451
|
status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
|
|
426
452
|
async poll() {
|
|
453
|
+
// Join a tick already in flight rather than racing it; only start one
|
|
454
|
+
// when nothing is running.
|
|
455
|
+
if (inFlight) {
|
|
456
|
+
await inFlight;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
427
459
|
if (timer) clearTimeout(timer);
|
|
428
460
|
timer = undefined;
|
|
429
|
-
await
|
|
461
|
+
await runTick();
|
|
430
462
|
},
|
|
431
463
|
};
|
|
432
464
|
}
|
package/src/watch/pin.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — pinning the answer to the chat that asked for it.
|
|
3
|
+
*
|
|
4
|
+
* A wake puts somebody else's words into pi's prompt, and pi holds tools that
|
|
5
|
+
* write in the user's name. The prompt asks it to answer only the chat it was
|
|
6
|
+
* woken for, but an instruction inside a message is exactly the thing that can
|
|
7
|
+
* talk a model out of following an instruction in a prompt. So the rule is
|
|
8
|
+
* enforced here instead: while an answer is pending, sending anywhere other
|
|
9
|
+
* than the chat that woke pi is refused by the safety interceptor.
|
|
10
|
+
*
|
|
11
|
+
* This is deliberately narrow. It gates outgoing messages only — the tools that
|
|
12
|
+
* speak to other people — and not reading, presence or the calendar, because a
|
|
13
|
+
* pin that blocks unrelated work would be a bug the user meets far more often
|
|
14
|
+
* than an attack.
|
|
15
|
+
*
|
|
16
|
+
* Module state rather than a parameter: the interceptor sees a tool call, not a
|
|
17
|
+
* turn, and one session runs one watcher.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long a pin can survive without the turn ending.
|
|
22
|
+
*
|
|
23
|
+
* The pin is normally cleared when the agent settles. The timeout is the
|
|
24
|
+
* backstop for the case where that never arrives — a crash, a cancelled run —
|
|
25
|
+
* so a stuck pin cannot lock the user out of their own tools.
|
|
26
|
+
*/
|
|
27
|
+
export const PIN_TTL_MS = 15 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
interface Pin {
|
|
30
|
+
chatId: string;
|
|
31
|
+
/** Chat label, for the refusal message */
|
|
32
|
+
label: string;
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let pin: Pin | undefined;
|
|
37
|
+
|
|
38
|
+
/** Pin the answer of the turn that is about to start to one chat. */
|
|
39
|
+
export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
|
|
40
|
+
pin = { chatId, label, at: now };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Release the pin. Safe to call when there is none. */
|
|
44
|
+
export function clearWakeTarget(): void {
|
|
45
|
+
pin = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The chat an answer is currently owed to, if any. */
|
|
49
|
+
export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
|
|
50
|
+
if (!pin) return undefined;
|
|
51
|
+
if (now - pin.at > PIN_TTL_MS) {
|
|
52
|
+
pin = undefined;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { chatId: pin.chatId, label: pin.label };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tools that speak to people, and must therefore stay inside the pinned chat.
|
|
60
|
+
*
|
|
61
|
+
* Reading, presence and calendar tools are absent on purpose: they do not put
|
|
62
|
+
* words in anyone's inbox, and gating them would break ordinary work for no
|
|
63
|
+
* security gain.
|
|
64
|
+
*/
|
|
65
|
+
const OUTGOING_TOOLS = new Set<string>([
|
|
66
|
+
"teams_send_chat_message",
|
|
67
|
+
"teams_send_channel_message",
|
|
68
|
+
"teams_reply_channel_message",
|
|
69
|
+
"teams_create_chat",
|
|
70
|
+
"teams_chat_members",
|
|
71
|
+
"teams_update_message",
|
|
72
|
+
"teams_react",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Why this call must not go through while an answer is pinned, or undefined.
|
|
77
|
+
*
|
|
78
|
+
* The chat is matched on its ID, because that is what the wake prompt hands the
|
|
79
|
+
* model and the only identifier that cannot be talked into meaning a different
|
|
80
|
+
* conversation.
|
|
81
|
+
*/
|
|
82
|
+
export function pinViolation(
|
|
83
|
+
toolName: string,
|
|
84
|
+
params: Record<string, unknown>,
|
|
85
|
+
now = Date.now(),
|
|
86
|
+
): string | undefined {
|
|
87
|
+
const target = getWakeTarget(now);
|
|
88
|
+
if (!target) return undefined;
|
|
89
|
+
if (!OUTGOING_TOOLS.has(toolName)) return undefined;
|
|
90
|
+
|
|
91
|
+
const explain = (what: string) =>
|
|
92
|
+
`Listen mode is answering "${target.label}". ${what} Only that chat may be written to until the ` +
|
|
93
|
+
`answer is done — a message can ask for anything, and this is the part that does not take its word for it.`;
|
|
94
|
+
|
|
95
|
+
if (toolName === "teams_send_channel_message" || toolName === "teams_reply_channel_message") {
|
|
96
|
+
return explain("This would post in a channel.");
|
|
97
|
+
}
|
|
98
|
+
if (toolName === "teams_create_chat") {
|
|
99
|
+
return explain("This would start a different conversation.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const chat = typeof params.chat === "string" ? params.chat : undefined;
|
|
103
|
+
if (!chat) return explain("This does not name the chat it would write to.");
|
|
104
|
+
if (chat !== target.chatId) {
|
|
105
|
+
return explain(`This would write to "${chat}" instead.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|