@playcraft/cli 0.1.9 → 0.1.11

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
@@ -141,6 +141,17 @@ playcraft login --url https://your-playcraft.example.com
141
141
 
142
142
  登录成功后,`tools`、`recommend`、`fix-ids` 等所有需要后端 API 的命令均无需额外配置,直接使用。
143
143
 
144
+ 账号提示待激活时,使用同一目标环境及已保存的个人访问令牌主动激活:
145
+
146
+ ```bash
147
+ playcraft login --activate
148
+ playcraft login --activate --url https://your-playcraft.example.com
149
+ ```
150
+
151
+ 也可通过 `PLAYCRAFT_TOKEN` 提供个人访问令牌。激活只发送一次请求,成功后重新执行原业务命令;该动作不会打开浏览器或续签令牌。目标服务须已接入身份服务的激活入口;封禁、停用账号及沙箱执行令牌不能用于激活。
152
+
153
+ 若本地、全局或环境变量配置冲突,激活会要求明确指定 `--url`、所需的 `PLAYCRAFT_ENV` 和对应的个人访问令牌。
154
+
144
155
  ### 沙箱环境(CI / Agent)
145
156
 
146
157
  在沙箱或 CI 中可通过 `.playcraft.json` 或环境变量配置:
@@ -0,0 +1,157 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { loadGlobalConfig } from '../config.js';
5
+ import { accountActivationMessage } from './messages.js';
6
+ export const ACCOUNT_PENDING_ACTIVATION = 'account_pending_activation';
7
+ export class AccountActivationError extends Error {
8
+ code;
9
+ status;
10
+ retryable = false;
11
+ constructor(code, message, status) {
12
+ super(message);
13
+ this.code = code;
14
+ this.status = status;
15
+ this.name = 'AccountActivationError';
16
+ }
17
+ }
18
+ export function responseErrorCode(payload) {
19
+ if (!payload || typeof payload !== 'object')
20
+ return undefined;
21
+ const { error, code } = payload;
22
+ return typeof error?.code === 'string' ? error.code : typeof code === 'string' ? code : undefined;
23
+ }
24
+ export function pendingAccountActivationError(status, payload) {
25
+ return status === 403 && responseErrorCode(payload) === ACCOUNT_PENDING_ACTIVATION
26
+ ? new AccountActivationError(ACCOUNT_PENDING_ACTIVATION, `${ACCOUNT_PENDING_ACTIVATION}: ${accountActivationMessage('pending')}`, status)
27
+ : undefined;
28
+ }
29
+ export function parseErrorPayload(text) {
30
+ try {
31
+ return JSON.parse(text);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function localActivationConfig() {
38
+ for (const path of [
39
+ join(process.cwd(), '.playcraft.json'),
40
+ '/project/.playcraft.json',
41
+ join(homedir(), '.playcraft.json'),
42
+ ]) {
43
+ if (!existsSync(path))
44
+ continue;
45
+ try {
46
+ const config = JSON.parse(readFileSync(path, 'utf8'));
47
+ if (config && typeof config === 'object' && !Array.isArray(config))
48
+ return config;
49
+ }
50
+ catch {
51
+ // Follow the existing local credential lookup when a file is unreadable.
52
+ }
53
+ }
54
+ return {};
55
+ }
56
+ function stringValue(value) {
57
+ return typeof value === 'string' && value ? value : undefined;
58
+ }
59
+ function activationBaseUrl(raw) {
60
+ try {
61
+ const url = new URL(raw);
62
+ if (!['http:', 'https:'].includes(url.protocol) ||
63
+ url.username ||
64
+ url.password ||
65
+ url.search ||
66
+ url.hash)
67
+ throw new Error();
68
+ url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/api$/, '');
69
+ return url.toString().replace(/\/+$/, '');
70
+ }
71
+ catch {
72
+ throw new AccountActivationError('INVALID_ACTIVATION_TARGET', accountActivationMessage('invalidTarget'));
73
+ }
74
+ }
75
+ function savedTokenForTarget(config, rawUrl, target, environment) {
76
+ if (!rawUrl || (stringValue(config.env)?.trim() || '') !== environment)
77
+ return undefined;
78
+ try {
79
+ return activationBaseUrl(rawUrl) === target ? stringValue(config.token) : undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ function normalizedSavedUrl(raw) {
86
+ try {
87
+ return raw ? activationBaseUrl(raw) : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ export async function activateAccount(options, defaultBaseUrl) {
94
+ const local = localActivationConfig();
95
+ const global = loadGlobalConfig();
96
+ const localUrl = stringValue(local.apiUrl);
97
+ const globalUrl = stringValue(global.backendUrl) || stringValue(global.url);
98
+ const explicitUrl = options.url || process.env.PLAYCRAFT_API_URL || process.env.PLAYCRAFT_URL;
99
+ const target = activationBaseUrl(explicitUrl || localUrl || globalUrl || defaultBaseUrl);
100
+ const explicitToken = options.token || process.env.PLAYCRAFT_TOKEN || process.env.PLAYCRAFT_SANDBOX_TOKEN;
101
+ const savedUrls = [normalizedSavedUrl(localUrl), normalizedSavedUrl(globalUrl)].filter(Boolean);
102
+ const savedTokens = [stringValue(local.token), stringValue(global.token)].filter(Boolean);
103
+ const environmentsConflict = savedUrls.length === 2 &&
104
+ savedUrls[0] === savedUrls[1] &&
105
+ (stringValue(local.env)?.trim() || '') !== (stringValue(global.env)?.trim() || '');
106
+ const environmentTokensConflict = process.env.PLAYCRAFT_TOKEN &&
107
+ process.env.PLAYCRAFT_SANDBOX_TOKEN &&
108
+ process.env.PLAYCRAFT_TOKEN !== process.env.PLAYCRAFT_SANDBOX_TOKEN;
109
+ const environmentUrlsConflict = process.env.PLAYCRAFT_API_URL &&
110
+ process.env.PLAYCRAFT_URL &&
111
+ normalizedSavedUrl(process.env.PLAYCRAFT_API_URL) !==
112
+ normalizedSavedUrl(process.env.PLAYCRAFT_URL);
113
+ if ((!explicitUrl && new Set(savedUrls).size > 1) ||
114
+ (!explicitToken && new Set(savedTokens).size > 1) ||
115
+ (environmentsConflict && !process.env.PLAYCRAFT_ENV) ||
116
+ (environmentTokensConflict && !options.token) ||
117
+ (environmentUrlsConflict && !options.url)) {
118
+ throw new AccountActivationError('AMBIGUOUS_ACTIVATION_TARGET', accountActivationMessage('ambiguous'));
119
+ }
120
+ const environment = (process.env.PLAYCRAFT_ENV ||
121
+ (!explicitUrl ? (localUrl ? stringValue(local.env) : stringValue(global.env)) : '') ||
122
+ '').trim();
123
+ const token = explicitToken ||
124
+ savedTokenForTarget(local, localUrl, target, environment) ||
125
+ savedTokenForTarget(global, globalUrl, target, environment);
126
+ if (!token)
127
+ throw new AccountActivationError('AUTH_REQUIRED', accountActivationMessage('missingToken'));
128
+ if (!/^playcraft_pat_[A-Za-z0-9_-]{32}$/.test(token)) {
129
+ throw new AccountActivationError('INVALID_ACTIVATION_TOKEN', accountActivationMessage('invalidToken'));
130
+ }
131
+ let response;
132
+ try {
133
+ response = await fetch(`${target}/api/settings/personal-access-tokens/activate`, {
134
+ method: 'POST',
135
+ redirect: 'error',
136
+ signal: AbortSignal.timeout(30_000),
137
+ headers: {
138
+ Authorization: `Bearer ${token}`,
139
+ ...(environment ? { 'playcraft-env': environment } : {}),
140
+ },
141
+ });
142
+ }
143
+ catch {
144
+ throw new AccountActivationError('ACTIVATION_UNCONFIRMED', accountActivationMessage('unavailable'));
145
+ }
146
+ if (!response.ok) {
147
+ await response.body?.cancel().catch(() => { });
148
+ throw new AccountActivationError('ACTIVATION_FAILED', accountActivationMessage('failed'), response.status);
149
+ }
150
+ const result = await response.json().catch(() => undefined);
151
+ if (!result ||
152
+ typeof result !== 'object' ||
153
+ result.accountStatus !== 'active') {
154
+ throw new AccountActivationError('ACTIVATION_UNCONFIRMED', accountActivationMessage('unavailable'), response.status);
155
+ }
156
+ console.log(accountActivationMessage('success'));
157
+ }
@@ -0,0 +1,30 @@
1
+ const messages = {
2
+ en: {
3
+ activateOption: 'Explicitly activate the account using the current personal access token',
4
+ tokenOption: 'Personal access token for --activate (or use PLAYCRAFT_TOKEN)',
5
+ pending: 'Account activation is required. Run `playcraft login --activate` with the same token and target environment, then rerun your command.',
6
+ success: 'Account activated. Rerun your original command to continue.',
7
+ missingToken: 'No personal access token matches this target environment. Provide PLAYCRAFT_TOKEN or log in to this environment first.',
8
+ invalidToken: 'Account activation requires a personal access token. A sandbox token cannot activate an account.',
9
+ ambiguous: 'Activation credentials or target environments conflict. Specify --url, PLAYCRAFT_ENV when needed, and an explicit personal access token using --token or PLAYCRAFT_TOKEN.',
10
+ invalidTarget: 'Use a web service base address (http:// or https://) without credentials, a query, or a fragment.',
11
+ failed: 'Account activation failed. Check the target environment and token before trying again.',
12
+ unavailable: 'Account activation could not be confirmed. Check your account status before explicitly trying again.',
13
+ },
14
+ zh: {
15
+ activateOption: '明确使用当前个人访问令牌激活账号',
16
+ tokenOption: '用于 --activate 的个人访问令牌(也可使用 PLAYCRAFT_TOKEN)',
17
+ pending: '账号需要激活。请使用同一令牌和目标环境主动运行 `playcraft login --activate`,然后重新执行原命令。',
18
+ success: '账号已激活,请重新执行原命令继续。',
19
+ missingToken: '没有与目标环境匹配的个人访问令牌。请提供 PLAYCRAFT_TOKEN,或先登录此环境。',
20
+ invalidToken: '激活账号需要个人访问令牌,沙箱令牌不能用于激活账号。',
21
+ ambiguous: '激活凭据或目标环境存在冲突。请明确指定 --url、所需的 PLAYCRAFT_ENV,并通过 --token 或 PLAYCRAFT_TOKEN 提供个人访问令牌。',
22
+ invalidTarget: '激活目标必须是网页服务基址(http:// 或 https://),不能包含凭据、查询参数或片段。',
23
+ failed: '账号激活失败,请检查目标环境和令牌后再尝试。',
24
+ unavailable: '无法确认账号是否已激活。请先检查账号状态,再主动决定是否重试。',
25
+ },
26
+ };
27
+ export function accountActivationMessage(key) {
28
+ const locale = process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en';
29
+ return messages[locale.startsWith('zh') ? 'zh' : 'en'][key];
30
+ }
@@ -2,6 +2,7 @@ import http from 'http';
2
2
  import { exec } from 'child_process';
3
3
  import crypto from 'crypto';
4
4
  import { saveGlobalConfig, loadGlobalConfig } from '../config.js';
5
+ import { activateAccount } from '../auth/account-activation.js';
5
6
  /** 默认云端 Portal(Next.js `/cli-auth` 与 API 同源时的基址;可通过 --url 或 PLAYCRAFT_URL 覆盖) */
6
7
  export const DEFAULT_PLAYCRAFT_CLOUD_BASE = 'https://playcraft.aix.intlgame.com';
7
8
  const CALLBACK_PORT = 3456;
@@ -100,6 +101,10 @@ export async function renewExistingToken(options) {
100
101
  }
101
102
  }
102
103
  export async function loginCommand(options) {
104
+ if (options.activate) {
105
+ await activateAccount(options, DEFAULT_PLAYCRAFT_CLOUD_BASE);
106
+ return;
107
+ }
103
108
  const backendUrl = resolveLoginBaseUrl(options);
104
109
  const callbackPort = options.callbackPort ?? CALLBACK_PORT;
105
110
  const globalConfig = loadGlobalConfig();
@@ -14,7 +14,7 @@ export async function runWorkspaceRuntimeServe(configPath) {
14
14
  return;
15
15
  }
16
16
  try {
17
- const runtime = await startWorkspaceRuntime(config);
17
+ const runtime = await startWorkspaceRuntime(config, { configPath });
18
18
  console.log(JSON.stringify({
19
19
  status: 'listening',
20
20
  url: runtime.url,
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { accountActivationMessage } from './auth/messages.js';
3
4
  import { readFileSync } from 'fs';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { dirname, join } from 'path';
@@ -66,6 +67,8 @@ program
66
67
  .command('login')
67
68
  .description('登录 PlayCraft 账号并获取 CLI token')
68
69
  .option('--url <url>', 'Portal / API 基址(默认 https://playcraft.aix.intlgame.com,可用环境变量 PLAYCRAFT_URL 覆盖)')
70
+ .option('--activate', accountActivationMessage('activateOption'))
71
+ .option('--token <token>', accountActivationMessage('tokenOption'))
69
72
  .action(async (options) => {
70
73
  const { loginCommand } = await import('./commands/login.js');
71
74
  await loginCommand(options);
@@ -5,6 +5,7 @@ import { loadGlobalConfig } from '../config.js';
5
5
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
6
6
  import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
7
7
  import { beginToolOperation } from './tool-operation-journal.js';
8
+ import { AccountActivationError, parseErrorPayload, pendingAccountActivationError, } from '../auth/account-activation.js';
8
9
  /**
9
10
  * 判断 token 是否为用户 PAT(Personal Access Token)。
10
11
  * PAT 以 "playcraft_pat_" 开头;沙箱 JWT 为三段式 base64。
@@ -86,6 +87,14 @@ export class AgentApiClient {
86
87
  signal: AbortSignal.timeout(30_000),
87
88
  });
88
89
  if (!capabilityResponse.ok) {
90
+ if (capabilityResponse.status === 403) {
91
+ const pending = pendingAccountActivationError(403, parseErrorPayload(await capabilityResponse.clone().text().catch(() => '')));
92
+ if (pending) {
93
+ if (operation.createdNew && !operation.settledOnly)
94
+ operation.reject();
95
+ throw pending;
96
+ }
97
+ }
89
98
  await capabilityResponse.body?.cancel();
90
99
  throw new ToolRequestError(`Business tool protocol unavailable (${capabilityResponse.status}); no operation was sent`);
91
100
  }
@@ -108,6 +117,14 @@ export class AgentApiClient {
108
117
  });
109
118
  if (res.headers.get(AGENT_TOOL_OPERATION_HEADERS.protocol) !== AGENT_TOOL_OPERATION_PROTOCOL ||
110
119
  res.headers.get(AGENT_TOOL_OPERATION_HEADERS.requestId) !== operation.requestId) {
120
+ if (res.status === 403) {
121
+ const pending = pendingAccountActivationError(403, parseErrorPayload(await res.clone().text().catch(() => '')));
122
+ if (pending) {
123
+ if (operation.createdNew && !operation.settledOnly)
124
+ operation.reject();
125
+ throw pending;
126
+ }
127
+ }
111
128
  await res.body?.cancel();
112
129
  // Possibly an instance that ignores identities: a rerun may only read the receipt.
113
130
  operation.requireReceipt();
@@ -115,6 +132,9 @@ export class AgentApiClient {
115
132
  }
116
133
  if (!res.ok) {
117
134
  const text = await res.text().catch(() => res.statusText);
135
+ const pending = pendingAccountActivationError(res.status, parseErrorPayload(text));
136
+ if (pending)
137
+ throw pending;
118
138
  const code = errorCode(text);
119
139
  if (res.status === 409 && code === 'business_operation_in_progress')
120
140
  throw new ToolRequestError(`API 409: ${text}\nTool request ${operation.requestId} is still running. Rerun the ` +
@@ -141,7 +161,7 @@ export class AgentApiClient {
141
161
  return result;
142
162
  }
143
163
  catch (error) {
144
- if (error instanceof ToolRequestError)
164
+ if (error instanceof ToolRequestError || error instanceof AccountActivationError)
145
165
  throw error;
146
166
  throw new Error(`${error instanceof Error ? error.message : String(error)}\n${operation.settledOnly
147
167
  ? `Tool request ${operation.requestId}; record: ${operation.file}. Its settled result is not available yet.`
@@ -164,6 +184,9 @@ export class AgentApiClient {
164
184
  });
165
185
  if (!res.ok) {
166
186
  const text = await res.text().catch(() => res.statusText);
187
+ const pending = pendingAccountActivationError(res.status, parseErrorPayload(text));
188
+ if (pending)
189
+ throw pending;
167
190
  throw new Error(`API ${res.status}: ${text}`);
168
191
  }
169
192
  return res.json();
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { loadGlobalConfig } from '../config.js';
3
3
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
4
+ import { pendingAccountActivationError } from '../auth/account-activation.js';
4
5
  export class PlayCraftApiError extends Error {
5
6
  code;
6
7
  status;
@@ -95,19 +96,20 @@ export class PlayCraftHttpClient {
95
96
  }
96
97
  if (!response.ok) {
97
98
  const error = payload?.error;
98
- const code = typeof error?.code === 'string' ? error.code : statusCode(response.status);
99
- const message = typeof error?.message === 'string'
99
+ const pending = pendingAccountActivationError(response.status, payload);
100
+ const code = pending?.code ?? (typeof error?.code === 'string' ? error.code : statusCode(response.status));
101
+ const message = pending?.message ?? (typeof error?.message === 'string'
100
102
  ? error.message
101
103
  : typeof payload?.message === 'string'
102
104
  ? payload.message
103
105
  : typeof payload === 'string'
104
106
  ? payload
105
- : response.statusText || 'PlayCraft request failed';
107
+ : response.statusText || 'PlayCraft request failed');
106
108
  throw new PlayCraftApiError({
107
109
  code,
108
110
  message,
109
111
  status: response.status,
110
- details: error?.details ?? payload,
112
+ details: pending ? { loginCommand: 'playcraft login --activate' } : error?.details ?? payload,
111
113
  retryable: response.status >= 500,
112
114
  });
113
115
  }
@@ -56,8 +56,10 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
56
56
  createdAt: now,
57
57
  updatedAt: now,
58
58
  };
59
+ let createdNew = false;
59
60
  try {
60
61
  writeFileSync(file, JSON.stringify(entry), { flag: 'wx', mode: 0o600 });
62
+ createdNew = true;
61
63
  }
62
64
  catch (error) {
63
65
  if (error.code !== 'EEXIST')
@@ -81,6 +83,8 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
81
83
  update({ holder });
82
84
  return {
83
85
  requestId,
86
+ /** This call created the identity; a refusal cannot settle an older unknown operation. */
87
+ createdNew,
84
88
  /** An explicit recovery, or a server that did not acknowledge the identity, reads only. */
85
89
  settledOnly: recoverId !== undefined || unconfirmed?.receiptOnly === true,
86
90
  file,
@@ -7,6 +7,7 @@ import { writePlaycraftCodexConfig } from './config-toml.js';
7
7
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
8
8
  import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { collectedEvent, NativePreparationError } from './native-adapter.js';
10
+ import { exportTranscript } from './transcripts.js';
10
11
  function asRecord(value) {
11
12
  return value && typeof value === 'object' && !Array.isArray(value)
12
13
  ? value
@@ -68,6 +69,18 @@ export function createCodexAppServerAdapter(options) {
68
69
  for (const listener of collected)
69
70
  listener(event);
70
71
  }
72
+ function transcriptHome() {
73
+ if (!launcherSocket)
74
+ return { homeDir };
75
+ return {
76
+ homeDir,
77
+ launcher: {
78
+ socketPath: launcherSocket,
79
+ runtimeEpoch: executionEpoch,
80
+ cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
81
+ },
82
+ };
83
+ }
71
84
  async function ensureStarted() {
72
85
  if (readyFlag && rpc)
73
86
  return;
@@ -272,6 +285,9 @@ export function createCodexAppServerAdapter(options) {
272
285
  return undefined;
273
286
  }
274
287
  },
288
+ exportThread(input) {
289
+ return exportTranscript(transcriptHome(), input.threadId);
290
+ },
275
291
  async captureNativeCheckpoint(input) {
276
292
  const thread = threads.get(input.threadId);
277
293
  if (!thread || thread.sessionId !== input.sessionId)
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { startLauncherProcess } from './launcher-client.js';
5
+ export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
6
+ const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
7
+ const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
8
+ const COMMAND_TIMEOUT_MS = 60_000;
9
+ /**
10
+ * Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
11
+ * trailing partial line is dropped; what is returned always parses line by line.
12
+ */
13
+ export async function exportTranscript(home, threadId) {
14
+ if (!THREAD_ID.test(threadId))
15
+ return undefined;
16
+ const found = home.launcher
17
+ ? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
18
+ : await exportDirect(home.homeDir, threadId);
19
+ if (!found || !transcriptPathFor(found.path, threadId))
20
+ return undefined;
21
+ const end = found.body.lastIndexOf(0x0a);
22
+ if (end < 0)
23
+ return undefined;
24
+ return { path: found.path, body: found.body.subarray(0, end + 1) };
25
+ }
26
+ function transcriptPathFor(path, threadId) {
27
+ return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
28
+ }
29
+ async function exportDirect(homeDir, threadId) {
30
+ const suffix = `-${threadId}.jsonl`;
31
+ for (const year of await names(join(homeDir, 'sessions'))) {
32
+ for (const month of await names(join(homeDir, 'sessions', year))) {
33
+ for (const day of await names(join(homeDir, 'sessions', year, month))) {
34
+ const directory = join('sessions', year, month, day);
35
+ const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
36
+ if (!match)
37
+ continue;
38
+ const path = `${directory}/${match}`;
39
+ const absolute = join(homeDir, path);
40
+ if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
41
+ return undefined;
42
+ return { path, body: await readFile(absolute) };
43
+ }
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ async function exportAsDeveloper(homeDir, launcher, threadId) {
49
+ const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
50
+ const outcome = await runAsDeveloper(launcher, [
51
+ `home=${quote(homeDir)}`,
52
+ `f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
53
+ '[ -n "$f" ] || exit 3',
54
+ 'printf \'%s\\n\' "${f#"$home"/}"',
55
+ 'base64 -w 0 "$f"',
56
+ ].join('; '));
57
+ if (outcome.code !== 0)
58
+ return undefined;
59
+ const text = outcome.stdout.toString('utf8');
60
+ const newline = text.indexOf('\n');
61
+ if (newline < 0)
62
+ return undefined;
63
+ return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
64
+ }
65
+ async function runAsDeveloper(launcher, command) {
66
+ const child = await startLauncherProcess({
67
+ socketPath: launcher.socketPath,
68
+ processId: `transcript-${randomUUID()}`,
69
+ runtimeEpoch: launcher.runtimeEpoch,
70
+ argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
71
+ cwd: launcher.cwd,
72
+ env: {},
73
+ });
74
+ child.stdin?.end();
75
+ const chunks = [];
76
+ child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
77
+ const code = await new Promise((resolve) => {
78
+ const timer = setTimeout(() => {
79
+ void child.stop().catch(() => undefined);
80
+ resolve(null);
81
+ }, COMMAND_TIMEOUT_MS);
82
+ child.onExit((value) => {
83
+ clearTimeout(timer);
84
+ resolve(value);
85
+ });
86
+ });
87
+ return { code, stdout: Buffer.concat(chunks) };
88
+ }
89
+ async function names(directory) {
90
+ return readdir(directory).then((entries) => entries.sort(), () => []);
91
+ }
92
+ function quote(value) {
93
+ return `'${value.replaceAll("'", "'\\''")}'`;
94
+ }
@@ -33,6 +33,12 @@ export async function previewWorkspaceRoot(state, operation) {
33
33
  if (!operation.workspaceId || !/^[A-Za-z0-9_-]+$/.test(operation.operationId)) {
34
34
  throw new PreviewFailure('invalid_preview_publish');
35
35
  }
36
+ if (operation.sessionId !== undefined) {
37
+ const session = state.journal.getSession(operation.sessionId);
38
+ if (!session || (state.config.workspaceMode === 'multi' && session.workspaceId !== operation.workspaceId)) {
39
+ throw new PreviewFailure('session_workspace_mismatch');
40
+ }
41
+ }
36
42
  return workspaceRoot(state, operation.workspaceId);
37
43
  }
38
44
  /** Tracks a background publish so tests and shutdown can wait for it. */
@@ -55,10 +61,13 @@ export async function recoverInterruptedPreviews(journal) {
55
61
  }
56
62
  export async function runPreviewPublish(state, operation, root) {
57
63
  const workspaceId = operation.workspaceId;
64
+ const scope = operation.sessionId === undefined
65
+ ? {}
66
+ : { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
58
67
  const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
59
68
  const output = { log: '' };
60
69
  try {
61
- await ensureDependencies(state, workspaceId, root);
70
+ await ensureDependencies(state, workspaceId, root, output);
62
71
  await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
63
72
  await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
64
73
  await buildPreview(state, root, outputDir, operation.operationId, output);
@@ -73,6 +82,7 @@ export async function runPreviewPublish(state, operation, root) {
73
82
  const source = await sourceRevision(root);
74
83
  const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
75
84
  workspaceId,
85
+ ...scope,
76
86
  operationId: operation.operationId,
77
87
  contentDigest,
78
88
  contentMd5,
@@ -81,6 +91,8 @@ export async function runPreviewPublish(state, operation, root) {
81
91
  dirty: source.dirty,
82
92
  cliVersion: state.config.cliVersion,
83
93
  }));
94
+ if (reserved.sessionId !== operation.sessionId)
95
+ throw new PreviewFailure('preview_scope_mismatch');
84
96
  if (!reserved.alreadyUploaded) {
85
97
  const uploaded = await fetch(reserved.uploadUrl, {
86
98
  method: 'PUT',
@@ -99,11 +111,16 @@ export async function runPreviewPublish(state, operation, root) {
99
111
  }
100
112
  const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
101
113
  workspaceId,
114
+ ...scope,
102
115
  operationId: operation.operationId,
103
116
  artifactId: reserved.artifactId,
104
117
  expectedPublishRevision: reserved.expectedPublishRevision,
105
118
  logTail: tail(output.log),
106
119
  }));
120
+ if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
121
+ published.artifactId !== reserved.artifactId) {
122
+ throw new PreviewFailure('preview_scope_mismatch');
123
+ }
107
124
  if (published.status === 'conflict')
108
125
  throw new PreviewFailure('publish_conflict');
109
126
  return published;
@@ -113,6 +130,7 @@ export async function runPreviewPublish(state, operation, root) {
113
130
  if (code !== 'publish_conflict') {
114
131
  await postControl(state, '/runtime/internal/preview-failures', {
115
132
  workspaceId,
133
+ ...scope,
116
134
  operationId: operation.operationId,
117
135
  error: code,
118
136
  logTail: tail(output.log || (error instanceof Error ? error.message : '')),
@@ -137,11 +155,13 @@ export function previewFailureCode(error) {
137
155
  * Codex added since the workspace opened is installed first. A running install is joined rather
138
156
  * than started twice; a failed one is retried once here.
139
157
  */
140
- async function ensureDependencies(state, workspaceId, root) {
158
+ async function ensureDependencies(state, workspaceId, root, output) {
141
159
  await scheduleDependencyInstall(state, workspaceId, root);
142
- const status = readDependencies(state, workspaceId).status;
143
- if (status !== 'ready' && status !== 'none')
160
+ const dependencies = readDependencies(state, workspaceId);
161
+ if (dependencies.status !== 'ready' && dependencies.status !== 'none') {
162
+ output.log = [dependencies.error, dependencies.logTail].filter(Boolean).join('\n');
144
163
  throw new PreviewFailure('dependencies_not_ready');
164
+ }
145
165
  }
146
166
  async function buildPreview(state, root, outputDir, operationId, output) {
147
167
  // Tests replace the build; the real one needs a project and the published CLI.
@@ -33,11 +33,15 @@ export async function startWorkspaceRuntime(config, options = {}) {
33
33
  try {
34
34
  journal = new RuntimeJournal(config.stateDir);
35
35
  await journal.load();
36
+ const previousEpoch = journal.storedRuntimeEpoch();
37
+ const previousGroup = journal.executionGroup();
38
+ const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
36
39
  await recoverInterruptedInstalls(journal);
37
40
  await recoverInterruptedPreviews(journal);
38
41
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
39
42
  const state = {
40
43
  config: { ...config, protocolMinor: negotiated.protocolMinor },
44
+ configPath: options.configPath,
41
45
  journal,
42
46
  acceptedLeaseFence: journal.acceptedLeaseFence(),
43
47
  gitAvailable: await probeGitBinary(),
@@ -63,8 +67,28 @@ export async function startWorkspaceRuntime(config, options = {}) {
63
67
  try {
64
68
  await native?.start?.();
65
69
  const group = native?.executionProcess?.();
66
- if (group)
67
- await journal.noteExecutionGroup(group);
70
+ if (group) {
71
+ let previousStopped = false;
72
+ if (previousGroup.pid && previousGroup.pid !== group.pid) {
73
+ try {
74
+ process.kill(previousGroup.pid, 0);
75
+ }
76
+ catch (error) {
77
+ previousStopped = error.code === 'ESRCH';
78
+ }
79
+ }
80
+ // The supervisor advances the trusted config epoch only after stopping the old
81
+ // native group. Reconfirm its recorded PID before publishing that replacement.
82
+ if (previousIdentityMatches && previousEpoch !== undefined && config.runtimeEpoch > previousEpoch && previousStopped) {
83
+ await journal.writeExecutionEpoch({
84
+ runtimeEpoch: state.runtimeEpoch, ...group, previousPid: previousGroup.pid, stopConfirmed: true,
85
+ });
86
+ await journal.replaceSessions(journal.listSessions().map((session) => ({ ...session, recoverability: 'history_only' })));
87
+ }
88
+ else {
89
+ await journal.noteExecutionGroup(group);
90
+ }
91
+ }
68
92
  }
69
93
  catch (error) {
70
94
  const code = error instanceof Error ? error.message : '';