pendingyou 0.16.0 → 0.18.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/dist/herdr.js ADDED
@@ -0,0 +1,243 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { configDir, readJson, writeWhole } from "./files.js";
4
+ import { cut, redactText } from "./redact.js";
5
+ export const HERDR_SOURCE = 'pendingyou';
6
+ export const HERDR_TIMEOUT_MS = 1000;
7
+ export const TOKEN_TTL_MS = 15 * 60_000;
8
+ export const HERDR_EVERY_MS = 5 * 60_000;
9
+ export const VALUE_MAX = 80;
10
+ export const WAITING_LABEL = 'waiting on you';
11
+ export const TOKENS = {
12
+ app: 'py_app',
13
+ agent: 'py_agent',
14
+ waiting: 'py_waiting',
15
+ card: 'py_card',
16
+ cards: 'py_cards',
17
+ urgency: 'py_urgency',
18
+ asked: 'py_asked',
19
+ };
20
+ export const TOKEN_NAMES = Object.values(TOKENS);
21
+ export const TOKEN_NAME = /^[A-Za-z0-9_-]{1,32}$/;
22
+ export const PANE_ID = /^[A-Za-z0-9]{1,16}:[A-Za-z0-9]{1,16}$/;
23
+ const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
24
+ export const HERDR_AGENTS = {
25
+ 'claude-code': 'claude',
26
+ codex: 'codex',
27
+ opencode: 'opencode',
28
+ pi: 'pi',
29
+ };
30
+ export const URGENCIES = ['now', 'today', 'whenever'];
31
+ export const PRESSING = ['blocking', ...URGENCIES];
32
+ export const isUrgency = (value) => typeof value === 'string' && URGENCIES.includes(value);
33
+ export function herdrBin(env) {
34
+ const bin = env.HERDR_BIN_PATH ?? '';
35
+ if (env.HERDR_ENV !== '1' || !bin || !isAbsolute(bin) || /[\0\r\n]/.test(bin))
36
+ return null;
37
+ return bin;
38
+ }
39
+ export function herdrPane(env) {
40
+ const bin = herdrBin(env);
41
+ const pane = env.HERDR_PANE_ID ?? '';
42
+ if (!bin || !PANE_ID.test(pane))
43
+ return null;
44
+ return { bin, pane };
45
+ }
46
+ export function herdrServer(env, host) {
47
+ const socket = env.HERDR_SOCKET_PATH ?? '';
48
+ if (!socket || !isAbsolute(socket) || /[\0\r\n]/.test(socket))
49
+ return null;
50
+ return createHash('sha256').update(`${host}${socket}`).digest('hex').slice(0, 16);
51
+ }
52
+ export function herdrTerminal(env, host) {
53
+ const pane = herdrPane(env);
54
+ if (!pane)
55
+ return null;
56
+ const server = herdrServer(env, host);
57
+ return { app: 'herdr', paneId: pane.pane, ...(server ? { server } : {}) };
58
+ }
59
+ const settingsPath = (io) => join(configDir(io), 'herdr.json');
60
+ export async function badgesOn(io) {
61
+ const settings = await readJson(settingsPath(io)).catch(() => null);
62
+ return settings?.badges !== false;
63
+ }
64
+ export async function setBadges(io, on) {
65
+ await writeWhole(settingsPath(io), `${JSON.stringify({ version: 1, badges: on }, null, 2)}\n`, {
66
+ secret: true,
67
+ });
68
+ }
69
+ export async function herdrTarget(io) {
70
+ const pane = herdrPane(io.env);
71
+ if (!pane)
72
+ return null;
73
+ return (await badgesOn(io).catch(() => true)) ? pane : null;
74
+ }
75
+ const oneLine = (text) => [...text]
76
+ .map((char) => (/[\p{Cc}\p{Cf}]/u.test(char) ? ' ' : char))
77
+ .join('')
78
+ .replace(/\s+/g, ' ')
79
+ .trim();
80
+ export const cleanValue = (text, max = VALUE_MAX) => cut(oneLine(text), max);
81
+ export const cardTitle = (title) => redactText(oneLine(title), VALUE_MAX);
82
+ export function idsToken(ids) {
83
+ let out = '';
84
+ for (const id of ids) {
85
+ const next = out ? `${out},${id}` : id;
86
+ if (next.length > VALUE_MAX)
87
+ break;
88
+ out = next;
89
+ }
90
+ return out;
91
+ }
92
+ const rank = (card) => [
93
+ card.blocking ? 0 : 1,
94
+ card.urgency ? URGENCIES.indexOf(card.urgency) : 1,
95
+ card.at,
96
+ ];
97
+ export function mostPressing(cards) {
98
+ return [...cards].sort((a, b) => {
99
+ const [x, y] = [rank(a), rank(b)];
100
+ for (let index = 0; index < x.length; index++)
101
+ if (x[index] !== y[index])
102
+ return x[index] - y[index];
103
+ return a.requestId < b.requestId ? -1 : a.requestId > b.requestId ? 1 : 0;
104
+ });
105
+ }
106
+ export function pressingOf(cards) {
107
+ if (cards.some((card) => card.blocking))
108
+ return 'blocking';
109
+ const given = cards.flatMap((card) => (card.urgency ? [URGENCIES.indexOf(card.urgency)] : []));
110
+ return given.length ? URGENCIES[Math.min(...given)] : null;
111
+ }
112
+ const seqOf = (at) => String(Math.max(0, Math.floor(Number.isFinite(at) ? at : 0)));
113
+ export function reportArgs(pane, report) {
114
+ const set = [];
115
+ const clear = [];
116
+ const token = (name, value) => {
117
+ if (value === undefined || !TOKEN_NAME.test(name))
118
+ return;
119
+ if (value === null || value === '')
120
+ clear.push('--clear-token', name);
121
+ else
122
+ set.push('--token', `${name}=${value}`);
123
+ };
124
+ token(TOKENS.app, report.app);
125
+ if (report.agent !== undefined)
126
+ token(TOKENS.agent, report.agent === null ? null : cleanValue(report.agent));
127
+ const labels = [];
128
+ if (report.cards !== undefined) {
129
+ const valid = report.cards.filter((card) => REQUEST_ID.test(card.requestId));
130
+ const cards = mostPressing(valid);
131
+ const first = cards[0];
132
+ token(TOKENS.waiting, cards.length ? String(cards.length) : null);
133
+ token(TOKENS.card, first?.title ? cardTitle(first.title) : null);
134
+ token(TOKENS.cards, cards.length ? idsToken(cards.map((card) => card.requestId)) : null);
135
+ token(TOKENS.urgency, pressingOf(cards));
136
+ token(TOKENS.asked, idsToken(cards.filter((card) => card.askedFirst).map((c) => c.requestId)));
137
+ if (cards.length)
138
+ labels.push('--agent', HERDR_AGENTS[report.app], '--state-label', `idle=${WAITING_LABEL}`, '--state-label', `done=${WAITING_LABEL}`);
139
+ else
140
+ labels.push('--clear-state-labels');
141
+ }
142
+ return [
143
+ 'pane',
144
+ 'report-metadata',
145
+ pane,
146
+ '--source',
147
+ HERDR_SOURCE,
148
+ ...set,
149
+ ...clear,
150
+ ...labels,
151
+ '--ttl-ms',
152
+ String(TOKEN_TTL_MS),
153
+ '--seq',
154
+ seqOf(report.at),
155
+ ];
156
+ }
157
+ export function clearArgs(pane, at) {
158
+ return [
159
+ 'pane',
160
+ 'report-metadata',
161
+ pane,
162
+ '--source',
163
+ HERDR_SOURCE,
164
+ ...TOKEN_NAMES.flatMap((name) => ['--clear-token', name]),
165
+ '--clear-state-labels',
166
+ '--seq',
167
+ seqOf(at),
168
+ ];
169
+ }
170
+ export async function runHerdr(io, pane, args) {
171
+ try {
172
+ const result = await io.run(pane.bin, args, HERDR_TIMEOUT_MS);
173
+ if (result.code === 0)
174
+ return 'written';
175
+ if (result.code === 124)
176
+ return 'timeout';
177
+ if (result.code === 127)
178
+ return 'missing';
179
+ return 'refused';
180
+ }
181
+ catch {
182
+ return 'failed';
183
+ }
184
+ }
185
+ export async function writeHerdr(io, report, pane) {
186
+ const target = pane === undefined ? await herdrTarget(io) : pane;
187
+ if (!target)
188
+ return 'off';
189
+ return runHerdr(io, target, reportArgs(target.pane, report));
190
+ }
191
+ export async function clearHerdr(io, at, pane) {
192
+ const target = pane === undefined ? await herdrTarget(io) : pane;
193
+ if (!target)
194
+ return 'off';
195
+ return runHerdr(io, target, clearArgs(target.pane, at));
196
+ }
197
+ export function reportCommand(app, fields) {
198
+ return [
199
+ 'herdr',
200
+ 'report',
201
+ '--app',
202
+ app,
203
+ ...(fields.session ? ['--session', fields.session] : []),
204
+ ...(fields.transcript ? ['--transcript', fields.transcript] : []),
205
+ ...(fields.closed ? ['--state', 'closed'] : []),
206
+ '--at',
207
+ seqOf(fields.at),
208
+ ];
209
+ }
210
+ export function readPayload(text) {
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(text);
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
219
+ return null;
220
+ const given = parsed;
221
+ if (!Array.isArray(given.cards))
222
+ return null;
223
+ const cards = [];
224
+ for (const card of given.cards.slice(0, 50)) {
225
+ if (typeof card !== 'object' || card === null)
226
+ continue;
227
+ const each = card;
228
+ if (typeof each.requestId !== 'string' || !REQUEST_ID.test(each.requestId))
229
+ continue;
230
+ cards.push({
231
+ requestId: each.requestId,
232
+ ...(typeof each.title === 'string' && each.title.trim()
233
+ ? { title: each.title.slice(0, 1000) }
234
+ : {}),
235
+ ...(isUrgency(each.urgency) ? { urgency: each.urgency } : {}),
236
+ ...(each.blocking === true ? { blocking: true } : {}),
237
+ ...(each.askedFirst === true ? { askedFirst: true } : {}),
238
+ at: typeof each.at === 'number' && Number.isFinite(each.at) ? each.at : 0,
239
+ });
240
+ }
241
+ const name = typeof given.name === 'string' && given.name.trim() ? given.name.trim() : null;
242
+ return { name, cards };
243
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { getJson, SignInNeeded, Unavailable } from "./api.js";
2
2
  export { DEFAULT_ORIGIN, parseArgs } from "./args.js";
3
3
  export { readCredential, updateCredential } from "./credentials.js";
4
+ export { attest, ensureMachine, machinePath, proveConnections, readMachine, thumbprintOf, } from "./machine.js";
4
5
  export { main } from "./main.js";
5
6
  export { CLIENT_NAME, deviceLink, discover, register, revoke, SCOPE, signIn, signInWithDevice, startDevice, waitForDevices, } from "./oauth.js";
6
7
  export { qrCode, qrLines } from "./qr.js";
package/dist/init.js CHANGED
@@ -5,6 +5,7 @@ import { hookCommand } from "./claude.js";
5
5
  import { readCredential, saveConnectionClient } from "./credentials.js";
6
6
  import { hookLine, originArgs } from "./hooks.js";
7
7
  import { installCli, pruneCli, removeCli } from "./install.js";
8
+ import { keyForSignIn, proveConnections } from "./machine.js";
8
9
  import { headlessReason, helperCommand, removeHelper, writeHelper } from "./remote.js";
9
10
  import { FINISH_SAY } from "./setup.js";
10
11
  import { removeShim, shimPath, writeShim } from "./shim.js";
@@ -145,12 +146,16 @@ export async function init(io, options) {
145
146
  if (options.login) {
146
147
  if (ctx.headless && !options.device)
147
148
  io.out(`No browser here (${ctx.headless}), so you approve with a code on your phone.\n`);
149
+ const key = await keyForSignIn(io);
148
150
  signed = await signInApps(io, going.map((each) => ({ app: each.app.id, kind: each.signIn })), {
149
151
  origin: options.origin,
150
152
  device: options.device,
151
153
  browser: options.browser,
152
154
  ...(options.machine ? { machine: options.machine } : {}),
155
+ key,
153
156
  });
157
+ if (key)
158
+ await proveConnections(io, options.origin, [...signed].flatMap(([app, how]) => (how === 'already' ? [app] : [])), key);
154
159
  }
155
160
  else
156
161
  io.out(going.length === 1
package/dist/io.js CHANGED
@@ -155,6 +155,65 @@ async function openBrowser(url) {
155
155
  const result = await runProgram(command, args, 10_000);
156
156
  return result.code === 0;
157
157
  }
158
+ function keys(signal) {
159
+ return {
160
+ [Symbol.asyncIterator]() {
161
+ const stdin = process.stdin;
162
+ const queue = [];
163
+ let waiting = null;
164
+ let done = false;
165
+ const raw = stdin.isTTY === true;
166
+ const onData = (chunk) => {
167
+ const resolve = waiting;
168
+ waiting = null;
169
+ if (resolve)
170
+ resolve({ value: chunk, done: false });
171
+ else
172
+ queue.push(chunk);
173
+ };
174
+ const end = () => {
175
+ if (done)
176
+ return;
177
+ done = true;
178
+ stdin.off('data', onData);
179
+ stdin.off('end', end);
180
+ signal.removeEventListener('abort', end);
181
+ if (raw)
182
+ stdin.setRawMode(false);
183
+ stdin.pause();
184
+ const resolve = waiting;
185
+ waiting = null;
186
+ resolve?.({ value: undefined, done: true });
187
+ };
188
+ if (raw)
189
+ stdin.setRawMode(true);
190
+ stdin.setEncoding('utf8');
191
+ stdin.on('data', onData);
192
+ stdin.on('end', end);
193
+ stdin.resume();
194
+ if (signal.aborted)
195
+ end();
196
+ else
197
+ signal.addEventListener('abort', end, { once: true });
198
+ return {
199
+ next: () => {
200
+ const chunk = queue.shift();
201
+ if (chunk !== undefined)
202
+ return Promise.resolve({ value: chunk, done: false });
203
+ if (done)
204
+ return Promise.resolve({ value: undefined, done: true });
205
+ return new Promise((resolve) => {
206
+ waiting = resolve;
207
+ });
208
+ },
209
+ return: () => {
210
+ end();
211
+ return Promise.resolve({ value: undefined, done: true });
212
+ },
213
+ };
214
+ },
215
+ };
216
+ }
158
217
  function background(script, args) {
159
218
  if (!script)
160
219
  return;
@@ -202,5 +261,7 @@ export function realIo() {
202
261
  uptime: () => process.uptime() * 1000,
203
262
  background: (args) => background(process.argv[1] ?? '', args),
204
263
  ppid: process.ppid,
264
+ keys: () => keys(controller.signal),
265
+ columns: () => process.stdout.columns || 80,
205
266
  };
206
267
  }
@@ -0,0 +1,101 @@
1
+ import { APP_NAMES } from "./apps/ids.js";
2
+ import { readCredential, signedInSlots } from "./credentials.js";
3
+ import { originArgs } from "./hooks.js";
4
+ import { attest, clockOffset, machinePath, readMachine, rotateMachine } from "./machine.js";
5
+ import { computerName } from "./remote.js";
6
+ import { checkSignIn } from "./signin.js";
7
+ export async function machineStatus(io, origin) {
8
+ const flag = originArgs(origin);
9
+ const machine = await readMachine(io);
10
+ if (!machine) {
11
+ io.out(`This computer has no key yet. npx -y pendingyou@latest init${flag} makes one, and proves it to Pending You.\n`);
12
+ return 1;
13
+ }
14
+ const lines = [
15
+ `This computer’s key: ${machinePath(io)} (made ${machine.createdAt.slice(0, 10)})`,
16
+ `Its fingerprint: ${machine.thumbprint}`,
17
+ ];
18
+ let ready = true;
19
+ const apps = [];
20
+ for (const slot of await signedInSlots(io))
21
+ if (slot.origin === origin &&
22
+ (await readCredential(io, origin, slot.app))?.kind === 'connection')
23
+ apps.push(slot.app);
24
+ lines.push(apps.length
25
+ ? `At ${origin}:`
26
+ : `No agent here is signed in to ${origin} with a connection of its own, so there’s nothing to prove there.`);
27
+ for (const app of apps) {
28
+ const check = await checkSignIn(io, origin, app);
29
+ const name = APP_NAMES[app];
30
+ if (check.state !== 'ok') {
31
+ ready = false;
32
+ lines.push(check.state === 'unreachable'
33
+ ? ` ${name}: Pending You couldn’t be reached.`
34
+ : ` ${name}: not signed in; run npx -y pendingyou@latest init${flag}.`);
35
+ continue;
36
+ }
37
+ const key = check.for?.machineKey;
38
+ if (key === machine.thumbprint) {
39
+ lines.push(` ${name}: known by this key${check.for?.machine ? ` (as ${check.for.machine})` : ''}.`);
40
+ continue;
41
+ }
42
+ ready = false;
43
+ lines.push(key
44
+ ? ` ${name}: known by another key, this computer’s before it was replaced. Once that computer is removed from your Pending You account, npx -y pendingyou@latest init${flag} proves this one.`
45
+ : ` ${name}: not yet. npx -y pendingyou@latest init${flag} proves this key, with nothing to approve.`);
46
+ }
47
+ io.out(`${lines.join('\n')}\n`);
48
+ return ready ? 0 : 1;
49
+ }
50
+ export async function machineAttest(io, command) {
51
+ const machine = await readMachine(io);
52
+ if (!machine) {
53
+ io.err(`pendingyou: This computer has no key yet. Run npx -y pendingyou@latest init${originArgs(command.origin)} first.\n`);
54
+ return 1;
55
+ }
56
+ const name = command.machine ?? (await computerName(io)).name;
57
+ const offset = await clockOffset(io, command.origin);
58
+ const attestation = await attest(machine, {
59
+ aud: `${command.origin}/oauth/device`,
60
+ client_id: command.clientId,
61
+ name,
62
+ ...(command.cnf ? { cnf: { jkt: command.cnf } } : {}),
63
+ }, io.now() + offset);
64
+ io.out(`${attestation}\n`);
65
+ return 0;
66
+ }
67
+ export async function machineRotate(io, command) {
68
+ const flag = originArgs(command.origin);
69
+ const had = await readMachine(io).then((machine) => machine !== null, () => true);
70
+ if (had && !command.yes) {
71
+ if (!io.interactive) {
72
+ io.err('pendingyou: This replaces this computer’s key, and Pending You will know it as a new computer. Run it again with --yes to go ahead.\n');
73
+ return 1;
74
+ }
75
+ const answer = await io.ask('Replace this computer’s key? Pending You will know it as a new computer. [y/N] ');
76
+ if (!/^y(es)?$/i.test(answer.trim())) {
77
+ io.out('Kept this computer’s key.\n');
78
+ return 1;
79
+ }
80
+ }
81
+ const { before, machine } = await rotateMachine(io);
82
+ const lines = [
83
+ `New key for this computer: ${machinePath(io)}`,
84
+ `Its fingerprint: ${machine.thumbprint}${before ? ` (it was ${before})` : ''}`,
85
+ before
86
+ ? `Pending You knows this computer’s connections by the old key until that computer is removed from your account; then npx -y pendingyou@latest init${flag} proves this one for them.`
87
+ : `npx -y pendingyou@latest init${flag} proves it to Pending You.`,
88
+ ];
89
+ io.out(`${lines.join('\n')}\n`);
90
+ return 0;
91
+ }
92
+ export function machine(io, command) {
93
+ switch (command.sub) {
94
+ case 'status':
95
+ return machineStatus(io, command.origin);
96
+ case 'attest':
97
+ return machineAttest(io, command);
98
+ case 'rotate':
99
+ return machineRotate(io, command);
100
+ }
101
+ }
@@ -0,0 +1,151 @@
1
+ import { createHash, randomBytes, webcrypto } from 'node:crypto';
2
+ import { chmod, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { sendJson } from "./api.js";
5
+ import { connectionMachine, ignoreFolder, readCredential } from "./credentials.js";
6
+ import { PlainError } from "./errors.js";
7
+ import { configDir, readJson, withLock, writeWhole } from "./files.js";
8
+ import { discover } from "./oauth.js";
9
+ import { machineOf } from "./remote.js";
10
+ export const ATTESTATION_TYPE = 'pendingyou-machine+jwt';
11
+ const ATTESTATION_SECONDS = 300;
12
+ export const MACHINE_PATH = '/mcp/cli/machine';
13
+ export const machinePath = (io) => join(configDir(io), 'machine.json');
14
+ const base64url = (bytes) => Buffer.from(bytes).toString('base64url');
15
+ const isCoordinate = (value) => typeof value === 'string' &&
16
+ /^[A-Za-z0-9_-]{43}$/.test(value) &&
17
+ Buffer.from(value, 'base64url').length === 32;
18
+ export function thumbprintOf(jwk) {
19
+ return createHash('sha256')
20
+ .update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }))
21
+ .digest('base64url');
22
+ }
23
+ function keyOf(value) {
24
+ if (typeof value !== 'object' || value === null)
25
+ return null;
26
+ const { kty, crv, x, y, d } = value;
27
+ if (kty !== 'EC' || crv !== 'P-256' || !isCoordinate(x) || !isCoordinate(y) || !isCoordinate(d))
28
+ return null;
29
+ return { kty, crv, x, y, d };
30
+ }
31
+ async function loaded(key, createdAt) {
32
+ const signer = await webcrypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
33
+ const jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
34
+ return { thumbprint: thumbprintOf(jwk), createdAt, jwk, signer };
35
+ }
36
+ const unreadable = (path) => new PlainError(`This computer’s key (${path}) can’t be read. Run npx pendingyou machine rotate to make a new one.`);
37
+ export async function readMachine(io) {
38
+ const path = machinePath(io);
39
+ let file;
40
+ try {
41
+ file = await readJson(path);
42
+ }
43
+ catch {
44
+ throw unreadable(path);
45
+ }
46
+ if (file === null)
47
+ return null;
48
+ const key = file?.version === 1 && typeof file.createdAt === 'string' ? keyOf(file.key) : null;
49
+ if (!key || typeof file?.createdAt !== 'string')
50
+ throw unreadable(path);
51
+ if ((await stat(path)).mode & 0o077)
52
+ await chmod(path, 0o600);
53
+ try {
54
+ return await loaded(key, file.createdAt);
55
+ }
56
+ catch {
57
+ throw unreadable(path);
58
+ }
59
+ }
60
+ async function writeNew(io) {
61
+ const pair = await webcrypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
62
+ 'sign',
63
+ 'verify',
64
+ ]);
65
+ const key = keyOf(await webcrypto.subtle.exportKey('jwk', pair.privateKey));
66
+ if (!key)
67
+ throw new PlainError('This computer’s key couldn’t be made.');
68
+ const createdAt = new Date(io.now()).toISOString();
69
+ const file = {
70
+ version: 1,
71
+ createdAt,
72
+ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y, d: key.d },
73
+ };
74
+ await writeWhole(machinePath(io), `${JSON.stringify(file, null, 2)}\n`, { secret: true });
75
+ await ignoreFolder(io).catch(() => { });
76
+ return loaded(key, createdAt);
77
+ }
78
+ export async function ensureMachine(io) {
79
+ return withLock(machinePath(io), io, async () => {
80
+ const machine = await readMachine(io);
81
+ return machine ? { machine, made: false } : { machine: await writeNew(io), made: true };
82
+ });
83
+ }
84
+ export async function rotateMachine(io) {
85
+ return withLock(machinePath(io), io, async () => {
86
+ const before = await readMachine(io).catch(() => null);
87
+ return { before: before?.thumbprint ?? null, machine: await writeNew(io) };
88
+ });
89
+ }
90
+ export async function attest(machine, claims, now) {
91
+ const iat = Math.floor(now / 1000);
92
+ const header = { typ: ATTESTATION_TYPE, alg: 'ES256', jwk: machine.jwk };
93
+ const payload = {
94
+ ...claims,
95
+ iat,
96
+ exp: iat + ATTESTATION_SECONDS,
97
+ jti: randomBytes(16).toString('base64url'),
98
+ };
99
+ const signing = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
100
+ const signature = await webcrypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, machine.signer, Buffer.from(signing));
101
+ return `${signing}.${base64url(new Uint8Array(signature))}`;
102
+ }
103
+ export async function keyForSignIn(io) {
104
+ try {
105
+ const { machine, made } = await ensureMachine(io);
106
+ if (made)
107
+ io.out(`Made this computer’s key for Pending You (${machinePath(io)}): it stays on this computer, and Pending You knows the computer by it.\n`);
108
+ return machine;
109
+ }
110
+ catch (error) {
111
+ io.err(`pendingyou: ${error instanceof PlainError ? error.message : 'This computer’s key couldn’t be made.'} Signing in without it.\n`);
112
+ return null;
113
+ }
114
+ }
115
+ export async function clockOffset(io, origin) {
116
+ return discover(io, origin, 5000).then((metadata) => metadata.clockOffsetMs ?? 0, () => 0);
117
+ }
118
+ export async function proveConnections(io, origin, apps, machine) {
119
+ const results = new Map();
120
+ const connected = [];
121
+ for (const app of apps) {
122
+ const credential = await readCredential(io, origin, app);
123
+ if (credential?.kind === 'connection')
124
+ connected.push({ app, clientId: credential.clientId });
125
+ }
126
+ if (connected.length === 0)
127
+ return results;
128
+ const offset = await clockOffset(io, origin);
129
+ for (const { app, clientId } of connected) {
130
+ try {
131
+ const attestation = await attest(machine, {
132
+ aud: `${origin}${MACHINE_PATH}`,
133
+ client_id: clientId,
134
+ name: (await connectionMachine(io, origin, app)) ?? machineOf(io.host),
135
+ }, io.now() + offset);
136
+ const { status, body } = await sendJson(io, origin, MACHINE_PATH, { attestation }, 15_000, { app });
137
+ const said = body.connection;
138
+ results.set(app, status === 200 && (said === 'joined' || said === 'already' || said === 'elsewhere')
139
+ ? said
140
+ : status === 404
141
+ ? 'absent'
142
+ : status >= 400 && status < 500
143
+ ? 'refused'
144
+ : 'failed');
145
+ }
146
+ catch {
147
+ results.set(app, 'failed');
148
+ }
149
+ }
150
+ return results;
151
+ }
package/dist/main.js CHANGED
@@ -5,8 +5,10 @@ import { appModule } from "./apps/registry.js";
5
5
  import { commandOf, HOOK_COMMANDS, parseArgs, USAGE, UsageError } from "./args.js";
6
6
  import { bridge } from "./bridge.js";
7
7
  import { PlainError } from "./errors.js";
8
+ import { herdr } from "./herdr/command.js";
8
9
  import { hold } from "./hold.js";
9
10
  import { init, status, uninstall } from "./init.js";
11
+ import { machine } from "./machine-command.js";
10
12
  import { OAuthError } from "./oauth.js";
11
13
  import { isPromptApp, permissionHook, permissionWorker } from "./permission.js";
12
14
  import { pickup } from "./pickup.js";
@@ -92,6 +94,10 @@ async function run(io, command, hook = { fallback: '' }) {
92
94
  return mcpHeaders(io, command);
93
95
  case 'mcp':
94
96
  return bridge(io, command);
97
+ case 'herdr':
98
+ return herdr(io, command);
99
+ case 'machine':
100
+ return machine(io, command);
95
101
  }
96
102
  }
97
103
  export const HOOK_DEADLINE_MS = 3000;
package/dist/oauth.js CHANGED
@@ -14,6 +14,7 @@ export class OAuthError extends Error {
14
14
  this.code = code;
15
15
  }
16
16
  }
17
+ export const serverNow = (io, metadata) => io.now() + (metadata.clockOffsetMs ?? 0);
17
18
  const base64url = (bytes) => bytes.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
18
19
  export function pkce() {
19
20
  const verifier = base64url(randomBytes(32));
@@ -28,7 +29,11 @@ export async function discover(io, origin, timeoutMs = 15_000) {
28
29
  });
29
30
  if (!response.ok)
30
31
  throw new OAuthError('discovery', `Pending You at ${origin} didn’t answer (${response.status}).`);
31
- const metadata = (await response.json());
32
+ const said = Date.parse(response.headers.get('date') ?? '');
33
+ const metadata = {
34
+ ...(await response.json()),
35
+ ...(io.now && Number.isFinite(said) ? { clockOffsetMs: said - io.now() } : {}),
36
+ };
32
37
  const sameOrigin = (url) => {
33
38
  try {
34
39
  return url !== undefined && new URL(url).origin === origin;
@@ -218,17 +223,23 @@ export async function revoke(io, origin, clientId, refreshToken) {
218
223
  export async function startDevice(io, options) {
219
224
  const metadata = await discover(io, options.origin);
220
225
  const assistant = options.assistant ?? (options.connection ? 'claude-code' : undefined);
221
- const begin = (client) => io.fetch(`${options.origin}/oauth/device`, {
222
- method: 'POST',
223
- headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
224
- body: new URLSearchParams({
225
- client_id: client,
226
- scope: SCOPE,
227
- ...(options.machine ? { machine: options.machine } : {}),
228
- ...(assistant ? { assistant } : {}),
229
- }).toString(),
230
- signal: AbortSignal.timeout(15_000),
231
- });
226
+ const begin = async (client) => {
227
+ const attestation = options.attest
228
+ ? await options.attest(client, serverNow(io, metadata))
229
+ : null;
230
+ return io.fetch(`${options.origin}/oauth/device`, {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
233
+ body: new URLSearchParams({
234
+ client_id: client,
235
+ scope: SCOPE,
236
+ ...(options.machine ? { machine: options.machine } : {}),
237
+ ...(assistant ? { assistant } : {}),
238
+ ...(attestation ? { machine_attestation: attestation } : {}),
239
+ }).toString(),
240
+ signal: AbortSignal.timeout(15_000),
241
+ });
242
+ };
232
243
  let clientId = options.clientId || (await register(io, metadata));
233
244
  let started = await begin(clientId);
234
245
  if (started.status === 401 && options.clientId) {