@xfey/tutti 0.1.2 → 0.1.3

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 CHANGED
@@ -43,6 +43,7 @@
43
43
  - packaged / production `tutti launch` 默认连接 `https://tutti.now`;`TUTTI_RELAY_URL` 可覆盖 Relay URL,本地开发 dev runner 默认使用 `http://127.0.0.1:4370`。
44
44
  - host 注册使用 machine-local `host_registration_secret`;该 secret 明文只可写入受限权限的 machine-local secret store,不写入目标项目 repo、Git local config 项目身份、SQLite 协作真相或日志,`binding.json` 只保存引用。
45
45
  - Relay registration 成功后只把 `relay_project_ref` 写回 machine-local binding;join URL 只在 host 本机终端或当前 host-local control 内存状态中展示,不写入 binding、runtime endpoint、SQLite 协作真相或日志。
46
+ - 普通 `tutti launch` 的前台 stdout 只展示运行提示、setup URL 和 join URL;Host HTTP request log 与 Control Plane / Run Pipeline runtime log 写入 `TUTTI_HOME/logs/host.log`,不写入目标项目 repo 或常规 CLI stdout。
46
47
  - Reference 和 Skills 上传的最终写入由 Host Project API 完成;Host 只通过 Relay host-control resolve 获取短期 R2 URL 下载 staged object,不接收浏览器 base64 文件正文,也不持久化 presigned URL。
47
48
  - Host Project API command 的 browser session context 只信任 Relay tunnel metadata 中的 `relay_session_context`;route handler 不读取浏览器身份 header,也不接受 payload 内身份字段。
48
49
  - Fastify request log、debug log、SSE payload、activity event 和 run result 都必须经过 redaction,不得泄露 provider secret、host registration secret、host connection token、join token、cookie、host 绝对路径或 run workspace path。
@@ -44,7 +44,7 @@ try {
44
44
  yes: args.yes,
45
45
  confirm: confirmFromTty,
46
46
  });
47
- process.stdout.write(`${formatLaunchLifecycleResult(result)}\n`);
47
+ process.stdout.write(`${formatLaunchLifecycleResult(result, { hyperlinks: process.stdout.isTTY })}\n`);
48
48
  if (result.kind === "hosting") {
49
49
  await waitForForegroundHostShutdown(result.host);
50
50
  }
@@ -39,7 +39,10 @@ export type StartLaunchProjectOptions = LaunchPreparationOptions & {
39
39
  registerRelayHostConnection?: RelayHostConnectionRegistrar;
40
40
  shutdownWaitMs?: number;
41
41
  };
42
+ export type FormatLaunchLifecycleResultOptions = {
43
+ hyperlinks?: boolean;
44
+ };
42
45
  export declare function startLaunchProject(options: StartLaunchProjectOptions): Promise<StartLaunchProjectResult>;
43
- export declare function formatLaunchLifecycleResult(result: StartLaunchProjectResult): string;
46
+ export declare function formatLaunchLifecycleResult(result: StartLaunchProjectResult, options?: FormatLaunchLifecycleResultOptions): string;
44
47
  export declare function waitForForegroundHostShutdown(handle: HostServerHandle, processLike?: NodeJS.Process): Promise<void>;
45
48
  //# sourceMappingURL=host-lifecycle.d.ts.map
@@ -1,5 +1,4 @@
1
1
  import { resolve } from "node:path";
2
- import { redactText } from "@tutti/shared/utils";
3
2
  import { connectRelayHostTunnel, RelayHostControlClientError, refreshRelayJoinToken as refreshRelayJoinTokenWithRelay, registerRelayHostConnection as registerRelayHostConnectionWithRelay, } from "@tutti/relay-client";
4
3
  import { createHostServer } from "../http/create-server.js";
5
4
  import { createHostProjectApiTunnelRequestHandler, createHostProjectApiTunnelStreamRequestHandler, } from "../http/host-tunnel.js";
@@ -13,6 +12,26 @@ import { prepareLaunchProject, resolveLaunchLocalContext, } from "./launch.js";
13
12
  import { registerHostWithRelay, relayErrorToLaunchError, } from "./relay-registration.js";
14
13
  export { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
15
14
  const DEFAULT_SHUTDOWN_WAIT_MS = 2_000;
15
+ const OSC8_START = "\u001B]8;;";
16
+ const OSC8_END = "\u001B]8;;\u001B\\";
17
+ function isTerminalSafeHttpUrl(value) {
18
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
19
+ return false;
20
+ }
21
+ return Array.from(value).every((character) => {
22
+ const codePoint = character.codePointAt(0);
23
+ return codePoint !== undefined && codePoint > 0x1f && codePoint !== 0x7f;
24
+ });
25
+ }
26
+ function terminalLink(value, options) {
27
+ if (options.hyperlinks !== true || !isTerminalSafeHttpUrl(value)) {
28
+ return value;
29
+ }
30
+ return `${OSC8_START}${value}\u001B\\${value}${OSC8_END}`;
31
+ }
32
+ function urlLine(label, value, options) {
33
+ return `${label}: ${terminalLink(value, options)}`;
34
+ }
16
35
  function endpointWithoutToken(endpoint) {
17
36
  return {
18
37
  project_id: endpoint.project_id,
@@ -246,28 +265,18 @@ export async function startLaunchProject(options) {
246
265
  }),
247
266
  };
248
267
  }
249
- export function formatLaunchLifecycleResult(result) {
268
+ export function formatLaunchLifecycleResult(result, options = {}) {
250
269
  const summary = result.summary;
251
270
  const fields = [
252
271
  result.kind === "connected"
253
- ? "Tutti project host is already running"
254
- : "Tutti project host is running",
255
- "",
256
- `Status: ${summary.status}`,
257
- `Workspace: ${redactText(summary.workspace_root)}`,
258
- `Project: ${summary.project_id}`,
259
- `Branch: ${summary.branch}`,
260
- `Relay: ${summary.relay_url}`,
272
+ ? "Tutti is already running. Stop it from the original host terminal."
273
+ : "Tutti is running. Press Ctrl-C to stop.",
261
274
  ];
262
- if (summary.provider_status !== undefined) {
263
- fields.push(`Provider: ${summary.provider_status}`);
264
- }
265
- fields.push(`Local diagnostic endpoint: ${summary.local_diagnostic_endpoint}`);
266
275
  if (summary.setup_url !== undefined) {
267
- fields.push(`Setup URL: ${summary.setup_url}`);
276
+ fields.push(urlLine("Setup URL", summary.setup_url, options));
268
277
  }
269
278
  if (summary.relay?.join_url !== undefined) {
270
- fields.push(`Join URL: ${summary.relay.join_url}`);
279
+ fields.push(urlLine("Join URL", summary.relay.join_url, options));
271
280
  }
272
281
  else if (summary.relay?.join_url_visibility === "not_recoverable") {
273
282
  fields.push("Join URL: not recoverable; rotate a fresh invite from the host.");
@@ -275,10 +284,6 @@ export function formatLaunchLifecycleResult(result) {
275
284
  else {
276
285
  fields.push("Join URL: unavailable from this local host process");
277
286
  }
278
- fields.push("");
279
- fields.push(result.kind === "connected"
280
- ? "An existing host server is handling this workspace; no second server was started."
281
- : "This terminal is hosting the project server. Press Ctrl-C to stop.");
282
287
  return fields.join("\n");
283
288
  }
284
289
  export async function waitForForegroundHostShutdown(handle, processLike = process) {
@@ -12,6 +12,7 @@ export type HostServerFactory = typeof createHostServer;
12
12
  export type HostServerHandle = {
13
13
  app: FastifyInstance;
14
14
  store: HostProjectStore;
15
+ log_file_path: string;
15
16
  runtime_endpoint: MachineRuntimeEndpointRecord;
16
17
  trusted_relay_metadata_store: TrustedRelaySessionMetadataStore;
17
18
  attachRelayTunnel: (tunnel: RelayHostTunnelHandle) => void;
@@ -20,7 +20,7 @@ import { WorkspaceEventBus } from "../http/workspace-events.js";
20
20
  import { createTrustedRelaySessionMetadataStore, } from "../session/relay-session-context.js";
21
21
  import { LaunchError } from "./errors.js";
22
22
  import { relayStateFromLaunchStatus } from "./host-relay-status.js";
23
- import { readHostRegistrationSecret, createMachineRuntimeEndpointToken, deleteMachineRuntimeEndpoint, writeMachineRuntimeEndpoint, } from "./machine-local.js";
23
+ import { appendHostLogLine, readHostRegistrationSecret, createMachineRuntimeEndpointToken, deleteMachineRuntimeEndpoint, ensureHostLogFile, getHostLogFilePath, writeMachineRuntimeEndpoint, } from "./machine-local.js";
24
24
  import { readProjectDisplayName, writeProjectDisplayName } from "./project-identity.js";
25
25
  export const HOST_SERVER_VERSION = "0.0.0";
26
26
  function createHostRuntimeLogger(options) {
@@ -34,7 +34,7 @@ function createHostRuntimeLogger(options) {
34
34
  fields,
35
35
  now: options.now,
36
36
  });
37
- process.stdout.write(`${line}\n`);
37
+ appendHostLogLine(options.logFilePath, line);
38
38
  },
39
39
  };
40
40
  }
@@ -125,6 +125,8 @@ class HostLocalProviderConfigureError extends Error {
125
125
  export async function startForegroundHostServer(options) {
126
126
  const token = createMachineRuntimeEndpointToken();
127
127
  const now = options.now ?? (() => new Date());
128
+ const logFilePath = getHostLogFilePath(options.preparation.tutti_home);
129
+ ensureHostLogFile(logFilePath);
128
130
  const startedAt = now().toISOString();
129
131
  const trustedMetadataStore = createTrustedRelaySessionMetadataStore();
130
132
  const agentContextTokenRegistry = new AgentContextTokenRegistry({ now });
@@ -183,10 +185,12 @@ export async function startForegroundHostServer(options) {
183
185
  };
184
186
  const controlPlaneLogger = createHostRuntimeLogger({
185
187
  component: "control-plane",
188
+ logFilePath,
186
189
  now,
187
190
  });
188
191
  const runPipelineLogger = createHostRuntimeLogger({
189
192
  component: "run-pipeline",
193
+ logFilePath,
190
194
  now,
191
195
  });
192
196
  const approvalManager = new ApprovalRequestManager({
@@ -313,6 +317,7 @@ export async function startForegroundHostServer(options) {
313
317
  store: () => "ready",
314
318
  },
315
319
  },
320
+ logger: { file: logFilePath },
316
321
  localControl,
317
322
  now,
318
323
  projectApi: {
@@ -428,6 +433,7 @@ export async function startForegroundHostServer(options) {
428
433
  return {
429
434
  app,
430
435
  store,
436
+ log_file_path: logFilePath,
431
437
  runtime_endpoint: runtimeEndpoint,
432
438
  trusted_relay_metadata_store: trustedMetadataStore,
433
439
  attachRelayTunnel: (tunnel) => {
@@ -38,6 +38,9 @@ export type MachineProjectBindingResult = {
38
38
  export declare function getProjectLocalStoreRoot(tuttiHome: string, projectId: ProjectId): string;
39
39
  export declare function getMachineProjectBindingPath(tuttiHome: string, projectId: ProjectId): string;
40
40
  export declare function getMachineRuntimeEndpointPath(tuttiHome: string, projectId: ProjectId): string;
41
+ export declare function getHostLogFilePath(tuttiHome: string): string;
42
+ export declare function ensureHostLogFile(logFilePath: string): void;
43
+ export declare function appendHostLogLine(logFilePath: string, line: string): void;
41
44
  export declare function createHostRegistrationSecretRef(projectId: ProjectId): string;
42
45
  export declare function getHostRegistrationSecretPath(tuttiHome: string, projectId: ProjectId): string;
43
46
  export declare function createHostRegistrationSecret(): string;
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
5
5
  import { LaunchError } from "./errors.js";
@@ -36,6 +36,19 @@ export function getMachineProjectBindingPath(tuttiHome, projectId) {
36
36
  export function getMachineRuntimeEndpointPath(tuttiHome, projectId) {
37
37
  return join(getProjectLocalStoreRoot(tuttiHome, projectId), "runtime", "endpoint.json");
38
38
  }
39
+ export function getHostLogFilePath(tuttiHome) {
40
+ return join(tuttiHome, "logs", "host.log");
41
+ }
42
+ export function ensureHostLogFile(logFilePath) {
43
+ ensurePrivateDirectory(dirname(logFilePath));
44
+ const fileDescriptor = openSync(logFilePath, "a", 0o600);
45
+ closeSync(fileDescriptor);
46
+ chmodSync(logFilePath, 0o600);
47
+ }
48
+ export function appendHostLogLine(logFilePath, line) {
49
+ ensureHostLogFile(logFilePath);
50
+ appendFileSync(logFilePath, `${line}\n`, { encoding: "utf8", mode: 0o600 });
51
+ }
39
52
  export function createHostRegistrationSecretRef(projectId) {
40
53
  return `host_registration:${projectId}`;
41
54
  }
@@ -13,7 +13,9 @@ export type HostServerOptions = {
13
13
  store?: () => "not_configured" | "ready";
14
14
  };
15
15
  };
16
- logger?: false;
16
+ logger?: false | {
17
+ file?: string;
18
+ };
17
19
  localControl?: HostLocalControlOptions;
18
20
  now?: () => Date;
19
21
  projectApi?: false | HostProjectApiRouteOptions;
@@ -9,7 +9,7 @@ import { registerApiErrorHandler } from "./validation.js";
9
9
  export function createHostServer(options = {}) {
10
10
  const app = options.logger === false
11
11
  ? fastify({ logger: false })
12
- : fastify({ logger: createRedactedLoggerOptions("tutti-host") });
12
+ : fastify({ logger: createRedactedLoggerOptions("tutti-host", options.logger) });
13
13
  registerApiErrorHandler(app);
14
14
  registerHostHealthRoute(app, {
15
15
  ...(options.health?.readiness === undefined
@@ -3,5 +3,7 @@ import type { FastifyError, FastifyServerOptions } from "fastify";
3
3
  export declare const serializeRequestLog: typeof serializeRuntimeRequestLog;
4
4
  export declare const serializeResponseLog: typeof serializeRuntimeResponseLog;
5
5
  export declare function serializeErrorLog(error: FastifyError): RuntimeSerializedErrorLog;
6
- export declare function createRedactedLoggerOptions(service: string): Exclude<FastifyServerOptions["logger"], undefined>;
6
+ export declare function createRedactedLoggerOptions(service: string, options?: {
7
+ file?: string;
8
+ }): Exclude<FastifyServerOptions["logger"], undefined>;
7
9
  //# sourceMappingURL=logger.d.ts.map
@@ -4,11 +4,12 @@ export const serializeResponseLog = serializeRuntimeResponseLog;
4
4
  export function serializeErrorLog(error) {
5
5
  return serializeRuntimeErrorLog(error);
6
6
  }
7
- export function createRedactedLoggerOptions(service) {
7
+ export function createRedactedLoggerOptions(service, options = {}) {
8
8
  return {
9
9
  level: "info",
10
10
  messageKey: "message",
11
11
  base: { service, component: "http" },
12
+ ...(options.file === undefined ? {} : { file: options.file }),
12
13
  serializers: {
13
14
  req: serializeRequestLog,
14
15
  res: serializeResponseLog,
@@ -3,10 +3,11 @@ export declare const REDACTION_LABELS: {
3
3
  readonly bearerToken: "[REDACTED:bearer_token]";
4
4
  readonly cookie: "[REDACTED:cookie]";
5
5
  readonly joinToken: "[REDACTED:join_token]";
6
+ readonly runtimeToken: "[REDACTED:runtime_token]";
6
7
  readonly hostSecret: "[REDACTED:host_secret]";
7
8
  readonly localPath: "[REDACTED:local_path]";
8
9
  };
9
- export declare const REDACTION_FIXTURES: readonly ["sk-proj-example-secret", "Authorization: Bearer example-token", "Cookie: tutti_session=example-session", "http://127.0.0.1:4370/join/example-join-token", "http://127.0.0.1:4370/api/v1/join/example-join-token/accept", "host_connection_token=example-host-token", "host_registration_secret=example-host-secret", "/Users/example/.tutti/auth.json", "/Users/example/project/.git/config", "/Users/example/.tutti/projects/proj_123/runs/act_123", "OPENAI_API_KEY=example"];
10
+ export declare const REDACTION_FIXTURES: readonly ["sk-proj-example-secret", "Authorization: Bearer example-token", "Cookie: tutti_session=example-session", "http://127.0.0.1:4370/join/example-join-token", "http://127.0.0.1:4370/api/v1/join/example-join-token/accept", "http://127.0.0.1:4371/setup?token=example-runtime-token", "host_connection_token=example-host-token", "host_registration_secret=example-host-secret", "/Users/example/.tutti/auth.json", "/Users/example/project/.git/config", "/Users/example/.tutti/projects/proj_123/runs/act_123", "OPENAI_API_KEY=example"];
10
11
  export declare function redactText(value: string): string;
11
12
  export declare function redactValue(value: unknown): unknown;
12
13
  export type RedactedError = {
@@ -3,6 +3,7 @@ export const REDACTION_LABELS = {
3
3
  bearerToken: "[REDACTED:bearer_token]",
4
4
  cookie: "[REDACTED:cookie]",
5
5
  joinToken: "[REDACTED:join_token]",
6
+ runtimeToken: "[REDACTED:runtime_token]",
6
7
  hostSecret: "[REDACTED:host_secret]",
7
8
  localPath: "[REDACTED:local_path]",
8
9
  };
@@ -12,6 +13,7 @@ export const REDACTION_FIXTURES = [
12
13
  "Cookie: tutti_session=example-session",
13
14
  "http://127.0.0.1:4370/join/example-join-token",
14
15
  "http://127.0.0.1:4370/api/v1/join/example-join-token/accept",
16
+ "http://127.0.0.1:4371/setup?token=example-runtime-token",
15
17
  "host_connection_token=example-host-token",
16
18
  "host_registration_secret=example-host-secret",
17
19
  "/Users/example/.tutti/auth.json",
@@ -56,6 +58,14 @@ const TEXT_REDACTION_RULES = [
56
58
  pattern: /(^|[\s"'`])\/join\/[A-Za-z0-9._~+/-]+/giu,
57
59
  replacement: `$1/join/${REDACTION_LABELS.joinToken}`,
58
60
  },
61
+ {
62
+ pattern: /(https?:\/\/[^\s"'`]+\/setup)\?token=[A-Za-z0-9._~+/-]+/giu,
63
+ replacement: `$1?token=${REDACTION_LABELS.runtimeToken}`,
64
+ },
65
+ {
66
+ pattern: /(^|[\s"'`])\/setup\?token=[A-Za-z0-9._~+/-]+/giu,
67
+ replacement: `$1/setup?token=${REDACTION_LABELS.runtimeToken}`,
68
+ },
59
69
  {
60
70
  pattern: /\bhost_connection_token\s*=\s*[^\s"'`&]+/giu,
61
71
  replacement: `host_connection_token=${REDACTION_LABELS.hostSecret}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",