toolroll 0.9.57 → 0.9.58

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/action-ledger.js +0 -4
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/cli-http.d.ts +7 -0
  11. package/dist/cli-http.js +30 -4
  12. package/dist/cli.js +2 -1
  13. package/dist/coordinator.d.ts +2 -1
  14. package/dist/coordinator.js +22 -31
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/export.js +2 -2
  17. package/dist/mcp-http.js +5 -5
  18. package/dist/operate.js +2 -1
  19. package/dist/password-bearer.d.ts +27 -0
  20. package/dist/password-bearer.js +59 -0
  21. package/dist/remote-command.js +0 -2
  22. package/dist/remote-exec.d.ts +19 -0
  23. package/dist/remote-exec.js +9 -8
  24. package/dist/request-budget.d.ts +1 -1
  25. package/dist/scope.js +2 -1
  26. package/dist/serve.js +15 -0
  27. package/dist/server/edge-refusal.d.ts +2 -0
  28. package/dist/server/edge-refusal.js +8 -6
  29. package/dist/server/guards.js +5 -4
  30. package/dist/server/http.d.ts +2 -0
  31. package/dist/server/remote.js +6 -13
  32. package/dist/server/route-table.js +0 -3
  33. package/dist/session-cli.d.ts +38 -9
  34. package/dist/session-cli.js +141 -154
  35. package/dist/session-contract.js +2 -2
  36. package/dist/session-remote.d.ts +19 -0
  37. package/dist/session-remote.js +86 -0
  38. package/dist/store.d.ts +28 -6
  39. package/dist/store.js +259 -93
  40. package/dist/team-cli.d.ts +3 -0
  41. package/dist/team-cli.js +23 -3
  42. package/dist/toolroll-update.js +3 -1
  43. package/package.json +1 -1
  44. package/dist/session-http.d.ts +0 -24
  45. package/dist/session-http.js +0 -170
  46. package/dist/session-server.d.ts +0 -17
  47. package/dist/session-server.js +0 -39
@@ -0,0 +1,59 @@
1
+ import { PACKAGE_VERSION } from "./version.js";
2
+ /** The one replacement every machine-credential refusal names. */
3
+ export const TOKEN_REPLACEMENT = "Create an API token with `toolroll tokens create`";
4
+ /** The first release that refuses a password on a request: one release after this one (0.9.58) deprecated it. */
5
+ export const PASSWORD_BEARER_REFUSED_FROM = "0.9.59";
6
+ export const PASSWORD_BEARER_WARNING = `Signing in with a password on each request is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
7
+ /** What the CLI says on stderr when a saved connection signs in with a password instead of an API token. */
8
+ export const PASSWORD_PROFILE_WARNING = `Warning: this connection signs in with your password on each request, which is deprecated and stops working in Toolroll ${PASSWORD_BEARER_REFUSED_FROM}. ${TOKEN_REPLACEMENT}, then connect again with --token-stdin.`;
9
+ export const PASSWORD_BEARER_REFUSED = `Toolroll no longer accepts a password on a request. ${TOKEN_REPLACEMENT} and send it as the bearer instead.`;
10
+ /** Whether an Authorization header is a password bearer: a name and a secret, never an API token. */
11
+ export function isPasswordBearer(authorization) {
12
+ return authorization !== undefined && /^Bearer (.+):(.+)$/.test(authorization) && !/^Bearer so_/.test(authorization);
13
+ }
14
+ const parts = (version) => (/^(\d+)\.(\d+)\.(\d+)/.exec(version) ?? [0, 0, 0, 0]).slice(1).map(Number);
15
+ /** Whether this build still accepts a password bearer: only a release before PASSWORD_BEARER_REFUSED_FROM does. */
16
+ export function passwordBearerAccepted(version = PACKAGE_VERSION) {
17
+ const have = parts(version), limit = parts(PASSWORD_BEARER_REFUSED_FROM);
18
+ for (let index = 0; index < 3; index++)
19
+ if (have[index] !== limit[index])
20
+ return have[index] < limit[index];
21
+ return false;
22
+ }
23
+ /** A refusal's words for this request: unchanged, or with the replacement when it came with a password bearer. */
24
+ export function withReplacement(authorization, message) {
25
+ return !isPasswordBearer(authorization) || message.includes("toolroll tokens create") ? message : `${message} ${PASSWORD_BEARER_WARNING}`;
26
+ }
27
+ /**
28
+ * The refusal boundary for one password-bearer request, whichever adapter answers it: the deprecation is announced in
29
+ * headers (RFC 9745 Deprecation, and a Warning naming the replacement), and any refusal's words, plain text or a JSON
30
+ * `message`, gain the replacement. Successful answers are left byte for byte.
31
+ */
32
+ export function announcePasswordBearer(response) {
33
+ response.setHeader("Deprecation", "true");
34
+ response.setHeader("Warning", `299 toolroll "${PASSWORD_BEARER_WARNING.replace(/"/g, "'")}"`);
35
+ const end = response.end.bind(response);
36
+ response.end = ((...args) => {
37
+ const [chunk] = args;
38
+ if (response.statusCode >= 400 && typeof chunk === "string" && !chunk.includes("toolroll tokens create"))
39
+ args[0] = annotated(chunk);
40
+ return end(...args);
41
+ });
42
+ }
43
+ function annotated(body) {
44
+ if (/^\s*\{/.test(body)) {
45
+ try {
46
+ const value = JSON.parse(body);
47
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
48
+ const row = value;
49
+ if (typeof row["message"] === "string")
50
+ return JSON.stringify({ ...row, message: `${row["message"]} ${PASSWORD_BEARER_WARNING}` });
51
+ if (typeof row["error_description"] === "string")
52
+ return JSON.stringify({ ...row, error_description: `${row["error_description"]} ${PASSWORD_BEARER_WARNING}` });
53
+ }
54
+ }
55
+ catch { /* not JSON after all: plain text below */ }
56
+ return body;
57
+ }
58
+ return body.trimStart().startsWith("<") ? body : `${body.replace(/\s+$/, "")}\n${PASSWORD_BEARER_WARNING}`;
59
+ }
@@ -34,8 +34,6 @@ export const REMOTE_PATH_ARGUMENTS = [
34
34
  ...['link', 'unlink'].map(invocation => ({ invocation, argument: { flag: '--to' }, policy: 'refuse' })),
35
35
  ...['repos add', 'repos remove', 'pulls', 'graph', ''].map(invocation => ({ invocation, argument: { position: 0, name: 'path', rest: true }, policy: 'refuse' })),
36
36
  { invocation: 'project use', argument: { position: 0, name: 'checkout path' }, policy: 'refuse' },
37
- // These local HTTP clients read prompts themselves; they do not consume runOperateAs's files map.
38
- ...['session start', 'session send'].map(invocation => ({ invocation, argument: { flag: '--file' }, policy: 'refuse' })),
39
37
  ];
40
38
  /**
41
39
  * Parse once using declared arity, so a flag's value cannot be mistaken for another flag or a
@@ -1,4 +1,5 @@
1
1
  import { type TeamCliOptions } from './team-cli.js';
2
+ import { type CliRequest } from './cli-http.js';
2
3
  import { type RemoteCommandLookup } from './remote-command.js';
3
4
  export { contractRow, STEP_UP_MESSAGE, type RemoteMode } from './remote-command.js';
4
5
  export type RemoteExecOptions = TeamCliOptions & {
@@ -9,6 +10,24 @@ export type RemoteExecOptions = TeamCliOptions & {
9
10
  /** Injected by tests: the contract row lookup (default: COMMAND_GUIDE's `remote` field). */
10
11
  modeOf?: RemoteCommandLookup;
11
12
  };
13
+ type Answer = {
14
+ exitCode: number;
15
+ stdout: string;
16
+ stderr: string;
17
+ };
18
+ export type Outcome = {
19
+ kind: 'answer';
20
+ answer: Answer;
21
+ } | {
22
+ kind: 'refused';
23
+ status: number;
24
+ code: string;
25
+ message: string;
26
+ } | {
27
+ kind: 'unconfirmed';
28
+ };
29
+ /** One request, never retried. */
30
+ export declare function postCli(origin: string, token: string, request: CliRequest, options: RemoteExecOptions): Promise<Outcome>;
12
31
  /**
13
32
  * null: run locally with argv unchanged. `{ local }`: run locally with this argv (`--local` removed).
14
33
  * A number: the command ran remotely (or was refused before sending) and this is its exit code.
@@ -14,11 +14,12 @@ import { envelopeJson } from './envelope.js';
14
14
  import { COMMAND_GUIDE } from './surface.js';
15
15
  import { centralProfile, UsageError } from './team-cli.js';
16
16
  import { CLI_FILES_BYTES, CLI_WAIT_SECONDS } from './cli-http.js';
17
+ import { TOKEN_REPLACEMENT } from './password-bearer.js';
17
18
  import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
18
19
  export { contractRow, STEP_UP_MESSAGE } from './remote-command.js';
19
20
  const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
20
- /** Team commands own their own central path (team-cli.ts). */
21
- const TEAM_COMMANDS = new Set(['connect', 'lead', 'conversation', 'chat', 'brief']);
21
+ /** Team commands own their own central path (team-cli.ts); `session` sends its own checked request (session-cli.ts). */
22
+ const TEAM_COMMANDS = new Set(['connect', 'lead', 'conversation', 'chat', 'brief', 'session']);
22
23
  const RESPONSE_BYTES = 16 * 1024 * 1024;
23
24
  const REQUEST_TIMEOUT_MS = (CLI_WAIT_SECONDS + 35) * 1_000;
24
25
  async function bounded(response) {
@@ -45,7 +46,7 @@ async function bounded(response) {
45
46
  return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
46
47
  }
47
48
  /** One request, never retried. */
48
- async function post(origin, token, request, options) {
49
+ export async function postCli(origin, token, request, options) {
49
50
  try {
50
51
  const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
51
52
  const response = await (options.fetch ?? fetch)(`${origin}/api/cli`, {
@@ -152,7 +153,7 @@ export async function maybeRunRemoteCommand(argv, options = {}) {
152
153
  if (profile === null || !profile.apiToken) {
153
154
  if (!explicit)
154
155
  return null;
155
- return refuse('usage', 'This profile has no API token. Connect again with your so_ token (toolroll connect <origin> --token-stdin).', EXIT.usage);
156
+ return refuse('usage', `This profile has no API token. ${TOKEN_REPLACEMENT}, then connect again with it (toolroll connect <origin> --token-stdin).`, EXIT.usage);
156
157
  }
157
158
  if (row === null || mode === 'no') {
158
159
  return refuse('usage', `${command} runs only on this computer. Drop --profile to run it here.`, EXIT.usage);
@@ -192,7 +193,7 @@ export async function maybeRunRemoteCommand(argv, options = {}) {
192
193
  };
193
194
  if (row?.invocation === 'task wait')
194
195
  return waitRemotely(sent, files, profile, options, deliver);
195
- return deliver(await post(profile.origin, profile.token, { argv: sent, files }, options));
196
+ return deliver(await postCli(profile.origin, profile.token, { argv: sent, files }, options));
196
197
  }
197
198
  /**
198
199
  * `task wait` holds the server for at most CLI_WAIT_SECONDS per request; the client keeps asking within the caller's
@@ -203,7 +204,7 @@ async function waitRemotely(argv, files, profile, options, deliver) {
203
204
  const given = at < 0 ? null : Number(argv[at + 1]);
204
205
  const base = at < 0 ? argv : argv.filter((_one, index) => index !== at && index !== at + 1);
205
206
  if (given !== null && (!Number.isFinite(given) || given < 0))
206
- return deliver(await post(profile.origin, profile.token, { argv, files }, options));
207
+ return deliver(await postCli(profile.origin, profile.token, { argv, files }, options));
207
208
  const json = base.includes('--json');
208
209
  const probe = json ? base : [...base, '--json'];
209
210
  const started = Date.now();
@@ -212,7 +213,7 @@ async function waitRemotely(argv, files, profile, options, deliver) {
212
213
  while (true) {
213
214
  const remaining = given === null ? CLI_WAIT_SECONDS : Math.max(0, given - (Date.now() - started) / 1_000);
214
215
  const slice = Math.min(CLI_WAIT_SECONDS, Math.floor(remaining * 1_000) / 1_000);
215
- const outcome = await post(profile.origin, profile.token, { argv: [...probe, '--timeout', String(slice)], files }, options);
216
+ const outcome = await postCli(profile.origin, profile.token, { argv: [...probe, '--timeout', String(slice)], files }, options);
216
217
  if (outcome.kind !== 'answer')
217
218
  return deliver(outcome);
218
219
  let reason;
@@ -227,7 +228,7 @@ async function waitRemotely(argv, files, profile, options, deliver) {
227
228
  if (json)
228
229
  return deliver(outcome);
229
230
  // Settled (or out of time): the server renders the final answer in the person's own format.
230
- return deliver(await post(profile.origin, profile.token, { argv: [...base, '--timeout', '0'], files }, options));
231
+ return deliver(await postCli(profile.origin, profile.token, { argv: [...base, '--timeout', '0'], files }, options));
231
232
  }
232
233
  if (signal.aborted)
233
234
  return deliver(outcome);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Request budgets for person API tokens (v112): how many requests one `so_` token may make across all accepting routes.
3
- * One budget per token, keyed by its api_token row id (never the secret, its name or an address), shared by those
3
+ * One budget per token, keyed by its credential row id (a person's API token, v118) (never the secret, its name or an address), shared by those
4
4
  * routes. A read token gets 120 requests in any sliding minute, an act token 30, and either 10,000 in a rolling day;
5
5
  * an instance operator may override any of these for the installation or for one token in the console (step-up).
6
6
  *
package/dist/scope.js CHANGED
@@ -5,7 +5,8 @@ import { validateScopeText } from "./task-text.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { projectAuthority } from "./project-access.js";
7
7
  import { activeRemote } from "./remote-run.js";
8
- import { claimActor, currentActor, parseLeadToken } from "./actor.js";
8
+ import { claimActor, currentActor } from "./actor.js";
9
+ import { parseLeadToken } from "./api-tokens.js";
9
10
  /**
10
11
  * What a task is allowed to become, agreed before anything builds it.
11
12
  *
package/dist/serve.js CHANGED
@@ -16,6 +16,7 @@ import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
16
16
  import { projectAuthority } from "./project-access.js";
17
17
  import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG } from "./project-tools.js";
18
18
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
19
+ import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
19
20
  import { createSharedGuards } from "./server/guards.js";
20
21
  import { createHandlerRegistry } from "./server/handler-registry.js";
21
22
  import {} from "./server/remote-hooks.js";
@@ -606,6 +607,17 @@ export function createDecisionServer(options) {
606
607
  const matchedRoute = matchRoute(method, hook.pathname);
607
608
  const edgeRoute = matchedRoute?.stage === 'edge' ? matchedRoute : null;
608
609
  const wrongMethodEdge = edgeRoute === null ? ROUTES.find(row => row.stage === 'edge' && new RegExp(row.pattern).test(hook.pathname)) ?? null : null;
610
+ // D7: a password on a request still signs in this release, announced on every answer and named in every refusal;
611
+ // from the release that removes it, it is refused here before anything checks it.
612
+ if (isPasswordBearer(request.headers.authorization)) {
613
+ if (!(options.passwordBearerAccepted ?? passwordBearerAccepted())) {
614
+ request.resume();
615
+ return hook.pathname.startsWith('/api/')
616
+ ? respond(response, 401, 'application/json; charset=utf-8', JSON.stringify({ ...(hook.pathname.startsWith('/api/team') ? { version: 1 } : {}), ok: false, code: 'password-bearer-refused', message: PASSWORD_BEARER_REFUSED }))
617
+ : respond(response, 401, 'text/plain; charset=utf-8', PASSWORD_BEARER_REFUSED);
618
+ }
619
+ announcePasswordBearer(response);
620
+ }
609
621
  if (edgeRoute?.host === 'before')
610
622
  return dispatchEdge(edgeRoute, { url: hook, who: null, request, response, method });
611
623
  if (wrongMethodEdge?.host === 'before')
@@ -667,6 +679,9 @@ export function createDecisionServer(options) {
667
679
  if (edgeRoute?.domain === 'people')
668
680
  return provenPasswordAccount.run(passwordProof, () => dispatchEdge(edgeRoute, { url, who, request, response, method }));
669
681
  if (who === null) {
682
+ // A password on a request that did not sign in is a machine's refusal, never a login page.
683
+ if (isPasswordBearer(request.headers.authorization))
684
+ return respond(response, 401, "text/plain; charset=utf-8", "authenticate first");
670
685
  // A GET to an exact task or result (a phone's deep link) signs in and
671
686
  // comes back to it — the destination is a same-site path only.
672
687
  return method === "GET"
@@ -1,5 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { type RouteDeclaration } from './route-table.js';
3
+ /** What a request to the retired /api/sessions transport hears (D7). */
4
+ export declare const SESSIONS_MOVED: string;
3
5
  export declare function isEdgeAddress(path: string): boolean;
4
6
  /** Preserve each transport's refusal envelope without invoking an undeclared adapter. */
5
7
  export declare function refuseEdge(request: IncomingMessage, response: ServerResponse, path: string, declared: RouteDeclaration | null): void;
@@ -1,5 +1,7 @@
1
- import { sessionDescriptor } from '../session-contract.js';
1
+ import { TOKEN_REPLACEMENT } from '../password-bearer.js';
2
2
  import { ROUTES } from './route-table.js';
3
+ /** What a request to the retired /api/sessions transport hears (D7). */
4
+ export const SESSIONS_MOVED = `Session operations moved to /api/cli. Update toolroll, ${TOKEN_REPLACEMENT.charAt(0).toLowerCase()}${TOKEN_REPLACEMENT.slice(1)}, connect with it, and run toolroll session <operation>.`;
3
5
  export function isEdgeAddress(path) {
4
6
  return ROUTES.some(row => row.stage === 'edge' && new RegExp(row.pattern).test(path))
5
7
  || /^\/(?:oauth|hooks|api\/(?:sessions|team|cli))(?:\/|$)/.test(path)
@@ -15,13 +17,13 @@ export function refuseEdge(request, response, path, declared) {
15
17
  type = 'application/json';
16
18
  body = JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32000, message: 'the MCP gateway takes POST only — one JSON-RPC message per request' } });
17
19
  }
18
- else if (path.startsWith('/api/sessions/')) {
19
- const spec = sessionDescriptor(path.split('/')[3] ?? '');
20
+ else if (path === '/api/sessions' || path.startsWith('/api/sessions/')) {
21
+ // D7: session operations moved to /api/cli. An older client hears that nothing was sent, and what to use instead.
22
+ response.statusCode = 404;
20
23
  type = 'application/json';
21
24
  response.setHeader('X-Toolroll-Delivery', 'not-sent');
22
- if (spec)
23
- response.setHeader('allow', 'POST');
24
- body = JSON.stringify({ version: 1, ...(spec ? { operation: spec.operation } : {}), ok: false, status: 'rejected', delivery: 'not-sent', retry: spec?.mutation === false ? 'safe-read' : 'never', reason: spec ? 'method-not-allowed' : 'unsupported-operation', message: spec ? 'Session operations require POST.' : 'This session operation is not supported.', nextActions: [] });
25
+ response.setHeader('X-Standing-Orders-Session-Delivery', 'not-sent');
26
+ body = JSON.stringify({ version: 1, ok: false, status: 'rejected', delivery: 'not-sent', retry: 'never', reason: 'moved', message: SESSIONS_MOVED, nextActions: [] });
25
27
  }
26
28
  else if (path === '/api/cli' || path.startsWith('/api/team')) {
27
29
  type = 'application/json';
@@ -2,7 +2,7 @@ import { OWNER_ACTIONS, sharedActionPayload } from '../chat-actions.js';
2
2
  import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
3
3
  import { timingSafeEqual } from "node:crypto";
4
4
  import {} from "node:http";
5
- import { parseApiToken, secretMatches, tokenLive, tokenProjects } from "../api-tokens.js";
5
+ import { tokenLive, tokenProjects } from "../api-tokens.js";
6
6
  import { oauthProjects, oauthTokenAllowed } from "../mcp-oauth.js";
7
7
  import { REMOTE_MESSAGES, reproveRemote } from "../operate-remote.js";
8
8
  import { canonicalProject, rowVisible } from "../project.js";
@@ -264,9 +264,10 @@ export function createSharedGuards(runtime) {
264
264
  const source = runtime.joinSourceOf(request), at = Date.now();
265
265
  if (runtime.signInBudget.waitFor(source, at) > 0)
266
266
  return null;
267
- const parsed = parseApiToken(presented);
268
- const kept = parsed === null ? null : runtime.store.apiTokenSecret(parsed.id);
269
- if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash)) {
267
+ // The one credential check (api-tokens.ts): only a person's API token signs in here.
268
+ const found = runtime.store.presentedCredential(presented);
269
+ const kept = found?.kind === "person" ? runtime.store.apiTokenSecret(found.id) : null;
270
+ if (kept === null) {
270
271
  runtime.signInBudget.failed(source, at);
271
272
  return null;
272
273
  }
@@ -43,6 +43,8 @@ export type ServeOptions = {
43
43
  cliRunner?: RunOperateAs;
44
44
  /** Tests: remote command metadata until the shared contract declares it. */
45
45
  cliModeOf?: CliHttpOptions['modeOf'];
46
+ /** Tests: whether a password on a request is still accepted (default: this release's version, password-bearer.ts). */
47
+ passwordBearerAccepted?: boolean;
46
48
  /** Tests: the clock request budgets for API tokens count by (request-budget.ts). */
47
49
  requestBudgetClock?: () => number;
48
50
  /** Where repos.json lives — every enrollment locks exactly this file. */
@@ -9,7 +9,7 @@ import { reproveRemote } from "../operate-remote.js";
9
9
  import { rowVisible } from "../project.js";
10
10
  import { RequestBudget, SourceAdmission } from "../request-budget.js";
11
11
  import { authenticateAccount } from "../scope.js";
12
- import { createSessionEndpoint } from '../session-server.js';
12
+ import { createSessionRunner } from '../session-remote.js';
13
13
  import {} from "../store.js";
14
14
  import { handleTeamHttp, TEAM_PASSWORD_REVERIFY_MS } from '../team-http.js';
15
15
  import { handleTeamsHttp } from "../teams.js";
@@ -97,16 +97,17 @@ export function createRemoteHandlers(runtime) {
97
97
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
98
98
  },
99
99
  });
100
+ // D7: native session operations are remote commands too (`session …` over /api/cli), run by the session owner.
101
+ const sessionRunner = createSessionRunner({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo), clock });
100
102
  // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
101
103
  const cliEndpoint = (request, response) => handleCliHttp(request, response, {
102
104
  admit: principal => requestBudget.admit(principal.tokenId, "api"),
103
105
  authenticate: tokenPrincipalOf,
104
106
  run: async () => options.cliRunner ?? (await import("../operate.js")).runOperateAs ?? null,
105
107
  modeOf: options.cliModeOf,
108
+ session: sessionRunner,
106
109
  store,
107
110
  });
108
- const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo),
109
- admit: admitPasswordSource, admitAuthenticated: admitBearer });
110
111
  const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, ...(options.requestBudgetClock === undefined ? {} : { requestBudgetClock: options.requestBudgetClock }), signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
111
112
  resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
112
113
  enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
@@ -138,8 +139,8 @@ export function createRemoteHandlers(runtime) {
138
139
  return;
139
140
  return respond(response, 404, 'text/plain; charset=utf-8', 'No such address.');
140
141
  };
141
- // D5: native coding sessions and the central team service are deprecated (still served this release); each
142
- // answer says so in a Deprecation header (RFC 9745).
142
+ // D5: the central team service is deprecated (still served this release); each answer says so in a Deprecation
143
+ // header (RFC 9745).
143
144
  // Each protocol adapter keeps its own operation boundary; every row still names its own entry.
144
145
  const registrations = handlersOf("remote", {}, {
145
146
  "edge.telegram-hook": async ({ request, response }) => { await telegramHook(hookContext, request, response); },
@@ -152,14 +153,6 @@ export function createRemoteHandlers(runtime) {
152
153
  "edge.oauth-token": async ({ request, response, url }) => { await oauthHttp(request, response, url); },
153
154
  "edge.oauth-authorize": async ({ request, response, url }) => { await oauthHttp(request, response, url); },
154
155
  "edge.oauth-consent": async ({ request, response, url }) => { await oauthHttp(request, response, url); },
155
- "edge.sessions-list": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
156
- "edge.sessions-show": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
157
- "edge.sessions-changes": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
158
- "edge.sessions-start": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
159
- "edge.sessions-send": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
160
- "edge.sessions-stop": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
161
- "edge.sessions-resume": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
162
- "edge.sessions-recover": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await sessionEndpoint(request, response); },
163
156
  "edge.team-read": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await teamEndpoint(request, response); },
164
157
  "edge.team-send": async ({ request, response }) => { response.setHeader('Deprecation', 'true'); await teamEndpoint(request, response); },
165
158
  "edge.cli": async ({ request, response }) => { await cliEndpoint(request, response); },
@@ -79,9 +79,6 @@ edge("remote", "edge.oauth-authorize", "GET,HEAD", "/oauth/authorize");
79
79
  edge("remote", "edge.oauth-consent", "POST", "/oauth/authorize", { callers: ["cookie"], scope: "step-up", viewer: true, proof: "adapter" });
80
80
  edge("settings", "edge.google-callback", "GET", "/settings/google/callback", { callers: ["service"], proof: "adapter" });
81
81
  edge("settings", "edge.connect-callback", "GET", "/settings/tools/connected", { callers: ["service"], proof: "adapter" });
82
- for (const operation of ["list", "show", "changes", "start", "send", "stop", "resume", "recover"]) {
83
- edge("remote", `edge.sessions-${operation}`, "POST", `/api/sessions/${operation}`, { callers: ["bearer"], scope: ["list", "show", "changes"].includes(operation) ? "read" : "act", project: "adapter", proof: "adapter" });
84
- }
85
82
  edge("remote", "edge.team-read", "GET", "/api/team", { callers: ["cookie", "bearer"], scope: "read", project: "adapter", proof: "adapter" });
86
83
  edge("remote", "edge.team-send", "POST", "/api/team", { callers: ["cookie", "bearer"], scope: "read", project: "adapter", proof: "adapter" });
87
84
  edge("remote", "edge.cli", "POST", "/api/cli", { callers: ["bearer"], scope: "read", project: "adapter", proof: "adapter" });
@@ -1,13 +1,42 @@
1
- import { type SessionDescriptor, type SessionOperation } from './session-contract.js';
2
- export type SessionCliOptions = {
3
- fetch?: typeof fetch;
4
- env?: NodeJS.ProcessEnv;
1
+ import { type SessionDescriptor, type SessionRequest, type SessionResponse } from './session-contract.js';
2
+ import { type TeamCliOptions } from './team-cli.js';
3
+ /**
4
+ * `toolroll session …` (D7): native session operations run on the central server over `POST /api/cli`, signed in with
5
+ * the API token `toolroll connect` saved, like every other remote command. The client checks and sends the exact
6
+ * request; the server (session-remote.ts) runs it through the existing session owner and answers one session envelope.
7
+ */
8
+ export type SessionCliOptions = TeamCliOptions & {
5
9
  readStdin?: () => Promise<string>;
6
10
  stderr?: (line: string) => void;
7
11
  };
8
- export declare const SESSION_CLI_ACTIONS: readonly ["capabilities", ...SessionOperation[]];
9
- export declare function sessionCliFlags(spec: SessionDescriptor): Record<string, 'value' | 'flag'>;
10
- /** Credentials are never sent to a URL with embedded authority, an API path,
11
- * query, fragment or an insecure remote host. Fetch cannot follow redirects. */
12
- export declare function sessionServiceOrigin(raw: string): string;
12
+ /** The files key the prompt travels under: the server reads no paths, only what the request carries. */
13
+ export declare const SESSION_PROMPT_FILE = "prompt";
14
+ export declare const SESSION_CLI_ACTIONS: readonly ["capabilities", ...import("./session-contract.js").SessionOperation[]];
15
+ export declare const SESSION_TOKEN_REQUIRED = "Session commands use your saved API token. Create an API token with `toolroll tokens create`, then save it with `toolroll connect <origin> --as <account> --token-stdin`.";
16
+ /** The flags an operation takes: on this computer (where the prompt may come from a file or stdin, and a saved
17
+ * profile is chosen), or on the server (where the prompt is only ever the request's own file). */
18
+ export declare function sessionCliFlags(spec: SessionDescriptor, side?: 'client' | 'server'): Record<string, 'value' | 'flag'>;
19
+ export type SessionArguments = {
20
+ spec: SessionDescriptor;
21
+ flags: Record<string, string | true>;
22
+ positionals: string[];
23
+ };
24
+ /** One parser for both sides of /api/cli: `<operation> [<session-id>] --flag value …`. */
25
+ export declare function parseSessionArguments(argv: readonly string[], side: 'client' | 'server'): SessionArguments | {
26
+ problem: string;
27
+ };
28
+ /** The validated request the parsed arguments and prompt spell. */
29
+ export declare function sessionRequestOf({ spec, flags, positionals }: SessionArguments, prompt: string | undefined): {
30
+ ok: true;
31
+ request: SessionRequest;
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ };
36
+ /** The exact argv the server runs for a validated request: always --json, the prompt as the request's one file. */
37
+ export declare function sessionArgv(spec: SessionDescriptor, request: SessionRequest): string[];
38
+ /** The plain-text answer, the same on either side of the transport. */
39
+ export declare function renderSessionResponse(response: SessionResponse): string;
40
+ /** 0 delivered · 1 unknown (inspect first) · 3 refused. */
41
+ export declare const sessionExitCode: (response: SessionResponse) => number;
13
42
  export declare function runSessionCommand(argv: readonly string[], write: (line: string) => void, options?: SessionCliOptions): Promise<number>;