@playcraft/cli 0.1.8 → 0.1.10
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 +11 -0
- package/dist/auth/account-activation.js +157 -0
- package/dist/auth/messages.js +30 -0
- package/dist/commands/login.js +5 -0
- package/dist/commands/workspace-runtime.js +1 -1
- package/dist/index.js +3 -0
- package/dist/utils/agent-api-client.js +24 -1
- package/dist/utils/playcraft-http-client.js +6 -4
- package/dist/utils/tool-operation-journal.js +4 -0
- package/dist/workspace-runtime/codex/launcher-client.js +6 -1
- package/dist/workspace-runtime/codex/model-broker.js +1 -1
- package/dist/workspace-runtime/previews/publish.js +7 -4
- package/dist/workspace-runtime/serve.js +26 -2
- package/dist/workspace-runtime/server/desktop-host.js +6 -0
- package/dist/workspace-runtime/server/dispatch.js +1 -1
- package/dist/workspace-runtime/server/endpoints.js +19 -0
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/server/terminal-host.js +66 -20
- package/dist/workspace-runtime/workspaces/dependencies.js +40 -10
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +6 -2
- package/dist/workspace-runtime/workspaces/prepare.js +31 -22
- package/dist/workspace-runtime/workspaces/registry.js +14 -5
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +25 -25
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
|
+
}
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
|
99
|
-
const
|
|
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,
|
|
@@ -65,7 +65,7 @@ export async function startLauncherProcess(input) {
|
|
|
65
65
|
let started = false;
|
|
66
66
|
socket.on('connect', () => {
|
|
67
67
|
const { socketPath: _socketPath, ...payload } = input;
|
|
68
|
-
socket.write(`${JSON.stringify({ ...payload, action: 'start-stdio' })}\n`);
|
|
68
|
+
socket.write(`${JSON.stringify({ ...payload, action: input.terminal ? 'start-pty' : 'start-stdio' })}\n`);
|
|
69
69
|
});
|
|
70
70
|
socket.on('data', (chunk) => {
|
|
71
71
|
buffer += chunk;
|
|
@@ -135,6 +135,11 @@ export async function startLauncherProcess(input) {
|
|
|
135
135
|
else
|
|
136
136
|
events.once('exit', listener);
|
|
137
137
|
},
|
|
138
|
+
resize(cols, rows) {
|
|
139
|
+
if (!input.terminal || exited)
|
|
140
|
+
return;
|
|
141
|
+
socket.write(`${JSON.stringify({ action: 'resize', cols, rows })}\n`);
|
|
142
|
+
},
|
|
138
143
|
stop() {
|
|
139
144
|
stopPromise ??= (async () => {
|
|
140
145
|
// A new launcher's empty registry cannot confirm an old group's exit.
|
|
@@ -87,7 +87,7 @@ export class WorkspaceModelBroker {
|
|
|
87
87
|
throw new Error('model_callback_insecure');
|
|
88
88
|
const response = await fetch(endpoint, {
|
|
89
89
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
90
|
-
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json' },
|
|
90
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
91
91
|
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
|
|
92
92
|
runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId }),
|
|
93
93
|
});
|
|
@@ -58,7 +58,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
58
58
|
const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
|
|
59
59
|
const output = { log: '' };
|
|
60
60
|
try {
|
|
61
|
-
await ensureDependencies(state, workspaceId, root);
|
|
61
|
+
await ensureDependencies(state, workspaceId, root, output);
|
|
62
62
|
await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
|
|
63
63
|
await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
|
|
64
64
|
await buildPreview(state, root, outputDir, operation.operationId, output);
|
|
@@ -137,11 +137,13 @@ export function previewFailureCode(error) {
|
|
|
137
137
|
* Codex added since the workspace opened is installed first. A running install is joined rather
|
|
138
138
|
* than started twice; a failed one is retried once here.
|
|
139
139
|
*/
|
|
140
|
-
async function ensureDependencies(state, workspaceId, root) {
|
|
140
|
+
async function ensureDependencies(state, workspaceId, root, output) {
|
|
141
141
|
await scheduleDependencyInstall(state, workspaceId, root);
|
|
142
|
-
const
|
|
143
|
-
if (status !== 'ready' && status !== 'none')
|
|
142
|
+
const dependencies = readDependencies(state, workspaceId);
|
|
143
|
+
if (dependencies.status !== 'ready' && dependencies.status !== 'none') {
|
|
144
|
+
output.log = [dependencies.error, dependencies.logTail].filter(Boolean).join('\n');
|
|
144
145
|
throw new PreviewFailure('dependencies_not_ready');
|
|
146
|
+
}
|
|
145
147
|
}
|
|
146
148
|
async function buildPreview(state, root, outputDir, operationId, output) {
|
|
147
149
|
// Tests replace the build; the real one needs a project and the published CLI.
|
|
@@ -212,6 +214,7 @@ async function postControl(state, path, body) {
|
|
|
212
214
|
signal: AbortSignal.timeout(60_000),
|
|
213
215
|
headers: {
|
|
214
216
|
authorization: `Bearer ${state.config.controlToken}`,
|
|
217
|
+
'playcraft-env': state.config.environment,
|
|
215
218
|
'content-type': 'application/json',
|
|
216
219
|
},
|
|
217
220
|
body: JSON.stringify({
|
|
@@ -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
|
-
|
|
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 : '';
|
|
@@ -192,6 +192,12 @@ export class DesktopHost {
|
|
|
192
192
|
const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
|
|
193
193
|
viewer.upstream = upstream;
|
|
194
194
|
viewer.detach = () => upstream.close();
|
|
195
|
+
upstream.on('error', () => {
|
|
196
|
+
if (!this.source && this.generation === generation)
|
|
197
|
+
this.probed = 'display_unavailable';
|
|
198
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
199
|
+
ws.close(1011, 'desktop_unavailable');
|
|
200
|
+
});
|
|
195
201
|
upstream.on('message', (data, isBinary) => {
|
|
196
202
|
if (this.generation !== generation) {
|
|
197
203
|
this.drop(viewer);
|
|
@@ -767,7 +767,7 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
|
|
|
767
767
|
}
|
|
768
768
|
/** The branch a registered workspace is bound to; the single-workspace mode has none. */
|
|
769
769
|
function boundBranch(state, operation) {
|
|
770
|
-
return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName : undefined;
|
|
770
|
+
return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName ?? undefined : undefined;
|
|
771
771
|
}
|
|
772
772
|
function gitErrorCode(error) {
|
|
773
773
|
return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { chmod, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { applySandboxEndpointPatch } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
/** Persist the new addresses, then publish them to the running process. A restart is not required. */
|
|
4
|
+
export async function applyRuntimeEndpointPatch(state, patch) {
|
|
5
|
+
const next = applySandboxEndpointPatch(state.config, patch);
|
|
6
|
+
const changed = next.callbackUrl !== state.config.callbackUrl || next.modelProxyUrl !== state.config.modelProxyUrl;
|
|
7
|
+
const result = { changed, callbackUrl: next.callbackUrl, modelProxyUrl: next.modelProxyUrl };
|
|
8
|
+
if (!changed)
|
|
9
|
+
return result;
|
|
10
|
+
if (!state.configPath) {
|
|
11
|
+
throw new Error('endpoint_config_path_missing');
|
|
12
|
+
}
|
|
13
|
+
const temporary = `${state.configPath}.${process.pid}.tmp`;
|
|
14
|
+
await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
15
|
+
await chmod(temporary, 0o600);
|
|
16
|
+
await rename(temporary, state.configPath);
|
|
17
|
+
state.config = next;
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
@@ -3,6 +3,7 @@ import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
|
+
import { applyRuntimeEndpointPatch } from './endpoints.js';
|
|
6
7
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
7
8
|
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
8
9
|
import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
|
|
@@ -61,6 +62,15 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
61
62
|
writeJson(response, 200, { workspaceId: binding.workspaceId, status: 'registered' });
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
65
|
+
if (request.method === 'POST' && url.pathname === '/runtime/endpoints') {
|
|
66
|
+
try {
|
|
67
|
+
writeJson(response, 200, await applyRuntimeEndpointPatch(state, await readJson(request)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
writeError(response, runtimeError('invalid_config', 'endpoint_patch_rejected'));
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
64
74
|
if (request.method === 'GET' && url.pathname === '/runtime/handshake') {
|
|
65
75
|
writeJson(response, 200, handshake(state));
|
|
66
76
|
return;
|
|
@@ -101,7 +111,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
101
111
|
if (request.method === 'GET' && url.pathname === '/runtime/files') {
|
|
102
112
|
if (writeDestroyed(state, response))
|
|
103
113
|
return;
|
|
104
|
-
|
|
114
|
+
const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
115
|
+
const root = await workspaceRoot(state, workspaceId);
|
|
116
|
+
const listed = await listWorkspaceFiles(root, state.journal.fileRevisions(revisionScope(state, workspaceId)));
|
|
117
|
+
console.log(`[workspace-files] GET /runtime/files workspaceId=${workspaceId ?? '-'} root=${root} files=${listed.files.length}`);
|
|
118
|
+
writeJson(response, 200, listed);
|
|
105
119
|
return;
|
|
106
120
|
}
|
|
107
121
|
if (request.method === 'GET' && url.pathname === '/runtime/file') {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
5
|
import { WebSocketServer } from 'ws';
|
|
5
6
|
import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, runtimeError, terminalClientFrameSchema, terminalOutputSince, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
7
|
import { resolveWorkspaceFile } from '../workspaces/files.js';
|
|
7
8
|
import { workspaceRoot } from '../workspaces/registry.js';
|
|
9
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
8
10
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
9
11
|
const require = createRequire(import.meta.url);
|
|
10
|
-
const pty = require('node-pty');
|
|
11
12
|
const commands = new WeakMap();
|
|
12
13
|
const hosts = new WeakMap();
|
|
13
14
|
export function setTerminalCommand(state, command) {
|
|
@@ -27,7 +28,9 @@ export function disposeTerminalHost(state) {
|
|
|
27
28
|
}
|
|
28
29
|
export class TerminalHost {
|
|
29
30
|
state;
|
|
31
|
+
disposed = false;
|
|
30
32
|
sessions = new Map();
|
|
33
|
+
spawning = new Map();
|
|
31
34
|
sockets = new WebSocketServer({ noServer: true });
|
|
32
35
|
constructor(state) {
|
|
33
36
|
this.state = state;
|
|
@@ -39,11 +42,18 @@ export class TerminalHost {
|
|
|
39
42
|
rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
|
|
40
43
|
return;
|
|
41
44
|
}
|
|
42
|
-
if (this.state.journal.destroyed()) {
|
|
45
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
43
46
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
|
|
44
47
|
return;
|
|
45
48
|
}
|
|
46
|
-
|
|
49
|
+
let terminalId;
|
|
50
|
+
try {
|
|
51
|
+
terminalId = decodeURIComponent(match[1]);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
47
57
|
const terminal = this.state.journal.getTerminal(terminalId);
|
|
48
58
|
if (!terminal) {
|
|
49
59
|
rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
|
|
@@ -56,13 +66,24 @@ export class TerminalHost {
|
|
|
56
66
|
let session = this.sessions.get(terminal.terminalId);
|
|
57
67
|
if (!session) {
|
|
58
68
|
try {
|
|
59
|
-
|
|
69
|
+
let pending = this.spawning.get(terminal.terminalId);
|
|
70
|
+
if (!pending) {
|
|
71
|
+
pending = terminalDirectory(this.state, terminal).then((cwd) => this.spawn(terminal, cwd))
|
|
72
|
+
.finally(() => this.spawning.delete(terminal.terminalId));
|
|
73
|
+
this.spawning.set(terminal.terminalId, pending);
|
|
74
|
+
}
|
|
75
|
+
session = await pending;
|
|
60
76
|
}
|
|
61
77
|
catch {
|
|
62
78
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'pty_unavailable'));
|
|
63
79
|
return;
|
|
64
80
|
}
|
|
65
81
|
}
|
|
82
|
+
if (this.disposed || this.state.journal.destroyed() || this.state.journal.getTerminal(terminalId)?.status === 'destroyed') {
|
|
83
|
+
this.revoke(terminalId, 'terminal_destroyed');
|
|
84
|
+
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
66
87
|
const active = session;
|
|
67
88
|
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
|
|
68
89
|
}
|
|
@@ -83,11 +104,43 @@ export class TerminalHost {
|
|
|
83
104
|
}
|
|
84
105
|
}
|
|
85
106
|
dispose() {
|
|
107
|
+
this.disposed = true;
|
|
86
108
|
for (const terminalId of [...this.sessions.keys()])
|
|
87
109
|
this.revoke(terminalId, 'runtime_destroyed');
|
|
88
110
|
this.sockets.close();
|
|
89
111
|
}
|
|
90
|
-
spawn(terminal, cwd) {
|
|
112
|
+
async spawn(terminal, cwd) {
|
|
113
|
+
if (this.disposed)
|
|
114
|
+
throw new Error('pty_unavailable');
|
|
115
|
+
if (this.state.config.workspaceMode === 'multi' || process.env.PLAYCRAFT_LAUNCHER_SOCKET
|
|
116
|
+
|| process.getuid?.() === 1500) {
|
|
117
|
+
// User shells require the root launcher's fixed development account, even when an
|
|
118
|
+
// alternative command is installed for local tests. Never fall back to the control user.
|
|
119
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
120
|
+
if (!socketPath)
|
|
121
|
+
throw new Error('pty_unavailable');
|
|
122
|
+
const child = await startLauncherProcess({ socketPath, processId: `terminal-${randomUUID()}`,
|
|
123
|
+
runtimeEpoch: this.state.runtimeEpoch, cwd, argv: [], env: {}, terminal: { cols: 80, rows: 24 } });
|
|
124
|
+
if (this.disposed) {
|
|
125
|
+
await child.stop();
|
|
126
|
+
throw new Error('pty_unavailable');
|
|
127
|
+
}
|
|
128
|
+
const session = { streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
|
|
129
|
+
pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
|
|
130
|
+
kill: () => { void child.stop().catch(() => undefined); } } };
|
|
131
|
+
const decoder = new StringDecoder('utf8');
|
|
132
|
+
child.stdout?.on('data', (data) => { const text = decoder.write(data); if (text)
|
|
133
|
+
publish(session, text); });
|
|
134
|
+
child.onExit((code) => {
|
|
135
|
+
const tail = decoder.end();
|
|
136
|
+
if (tail)
|
|
137
|
+
publish(session, tail);
|
|
138
|
+
this.exited(session, code ?? null);
|
|
139
|
+
});
|
|
140
|
+
this.sessions.set(terminal.terminalId, session);
|
|
141
|
+
return session;
|
|
142
|
+
}
|
|
143
|
+
const pty = require('node-pty');
|
|
91
144
|
const command = commands.get(this.state) ?? defaultShell();
|
|
92
145
|
const session = {
|
|
93
146
|
streamId: randomUUID(),
|
|
@@ -101,24 +154,17 @@ export class TerminalHost {
|
|
|
101
154
|
env: shellEnv(command.file, cwd),
|
|
102
155
|
}),
|
|
103
156
|
};
|
|
104
|
-
session.pty
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
code: exitCode,
|
|
108
|
-
...(signal === undefined ? {} : { signal: String(signal) }),
|
|
109
|
-
};
|
|
110
|
-
const frame = {
|
|
111
|
-
type: 'exit',
|
|
112
|
-
streamId: session.streamId,
|
|
113
|
-
code: exitCode,
|
|
114
|
-
...(session.exited.signal ? { signal: session.exited.signal } : {}),
|
|
115
|
-
};
|
|
116
|
-
for (const listener of session.listeners)
|
|
117
|
-
listener(frame);
|
|
118
|
-
});
|
|
157
|
+
const local = session.pty;
|
|
158
|
+
local.onData((data) => publish(session, data));
|
|
159
|
+
local.onExit(({ exitCode, signal }) => this.exited(session, exitCode, signal));
|
|
119
160
|
this.sessions.set(terminal.terminalId, session);
|
|
120
161
|
return session;
|
|
121
162
|
}
|
|
163
|
+
exited(session, code, signal) {
|
|
164
|
+
session.exited = { code, ...(signal === undefined ? {} : { signal: String(signal) }) };
|
|
165
|
+
for (const listener of session.listeners)
|
|
166
|
+
listener(exitFrame(session));
|
|
167
|
+
}
|
|
122
168
|
attach(session, ws) {
|
|
123
169
|
let open = true;
|
|
124
170
|
let resumed = false;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { appendFile, lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { appendFile, lstat, mkdir, open, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
3
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { launcherSocket, runDeveloperCommand } from './developer-command.js';
|
|
@@ -186,15 +187,44 @@ export async function waitUntilShared(root, timeoutMs = SHARE_WAIT_MS, intervalM
|
|
|
186
187
|
/** A local-only ignore, never committed: the change list must not fill up with installed packages. */
|
|
187
188
|
/** Keeps sandbox-managed folders out of the Git panel without touching the project's .gitignore. */
|
|
188
189
|
export async function excludeFromGit(root, entry = '/node_modules/') {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
191
|
+
const handles = [];
|
|
192
|
+
let file;
|
|
193
|
+
try {
|
|
194
|
+
const directory = await open(root, flags);
|
|
195
|
+
handles.push(directory);
|
|
196
|
+
const pinned = (handle, fallback) => process.platform === 'linux' ? `/proc/self/fd/${handle.fd}` : fallback;
|
|
197
|
+
const rootPath = pinned(directory, root);
|
|
198
|
+
const git = await open(join(rootPath, '.git'), flags).catch((error) => {
|
|
199
|
+
if (error.code === 'ENOENT')
|
|
200
|
+
return undefined;
|
|
201
|
+
throw error;
|
|
202
|
+
});
|
|
203
|
+
if (!git)
|
|
204
|
+
return;
|
|
205
|
+
handles.push(git);
|
|
206
|
+
const gitPath = pinned(git, join(root, '.git'));
|
|
207
|
+
await mkdir(join(gitPath, 'info')).catch((error) => {
|
|
208
|
+
if (error.code !== 'EEXIST')
|
|
209
|
+
throw error;
|
|
210
|
+
});
|
|
211
|
+
const info = await open(join(gitPath, 'info'), flags);
|
|
212
|
+
handles.push(info);
|
|
213
|
+
file = await open(join(pinned(info, join(root, '.git/info')), 'exclude'), constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
|
|
214
|
+
const metadata = await file.stat();
|
|
215
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
|
|
216
|
+
throw new Error('invalid_git_exclude');
|
|
217
|
+
}
|
|
218
|
+
const current = await file.readFile('utf8');
|
|
219
|
+
if (!current.split('\n').some((line) => line.trim() === entry)) {
|
|
220
|
+
await file.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await file?.close();
|
|
225
|
+
for (const handle of handles.reverse())
|
|
226
|
+
await handle.close();
|
|
227
|
+
}
|
|
198
228
|
}
|
|
199
229
|
function tail(log) {
|
|
200
230
|
return log.split('\n').slice(-LOG_TAIL_LINES).join('\n').slice(-LOG_TAIL_MAX_CHARS);
|
|
@@ -77,6 +77,8 @@ export class WorkspaceGitCredentialBroker {
|
|
|
77
77
|
}
|
|
78
78
|
async configure(binding, preparation) {
|
|
79
79
|
preparation = workspacePreparationSchema.parse(preparation);
|
|
80
|
+
if (binding.targetKind === 'standalone' || preparation.mode !== 'clone')
|
|
81
|
+
throw new Error('git_access_denied');
|
|
80
82
|
const transport = preparation.transport;
|
|
81
83
|
const isDirect = isLocalWorkspaceGitAuthMode(preparation.authMode);
|
|
82
84
|
if (!isDirect && transport?.kind !== 'git-gateway-v2')
|
|
@@ -118,7 +120,7 @@ export class WorkspaceGitCredentialBroker {
|
|
|
118
120
|
|| typeof input.path !== 'string')
|
|
119
121
|
throw new Error('git_access_denied');
|
|
120
122
|
const binding = this.state.journal.getWorkspace(input.workspaceId);
|
|
121
|
-
if (!binding)
|
|
123
|
+
if (!binding || binding.targetKind === 'standalone')
|
|
122
124
|
throw new Error('git_access_denied');
|
|
123
125
|
const cloneUrl = `${input.protocol}://${input.host}/${input.path}`;
|
|
124
126
|
const direct = await this.direct.get(binding);
|
|
@@ -168,7 +170,7 @@ export class WorkspaceGitCredentialBroker {
|
|
|
168
170
|
const epoch = this.state.runtimeEpoch;
|
|
169
171
|
const response = await fetch(endpoint, {
|
|
170
172
|
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
171
|
-
headers: { authorization: `Bearer ${this.state.config.controlToken}`, 'content-type': 'application/json' },
|
|
173
|
+
headers: { authorization: `Bearer ${this.state.config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': this.state.config.environment },
|
|
172
174
|
body: JSON.stringify({ runtimeId: this.state.config.runtimeId, generation: this.state.config.generation,
|
|
173
175
|
runtimeEpoch: epoch, workspaceId: binding.workspaceId }),
|
|
174
176
|
});
|
|
@@ -177,6 +179,8 @@ export class WorkspaceGitCredentialBroker {
|
|
|
177
179
|
throw new Error('git_broker_unavailable');
|
|
178
180
|
}
|
|
179
181
|
const preparation = workspacePreparationSchema.parse(await response.json());
|
|
182
|
+
if (preparation.mode !== 'clone')
|
|
183
|
+
throw new Error('git_access_denied');
|
|
180
184
|
const matched = preparation.transport && matchWorkspaceGitGatewayCloneUrl(preparation.transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
|
|
181
185
|
if (this.closed || this.state.journal.destroyed() || this.state.runtimeEpoch !== epoch
|
|
182
186
|
|| JSON.stringify(this.state.journal.getWorkspace(binding.workspaceId)) !== JSON.stringify(binding)
|
|
@@ -4,15 +4,19 @@ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { WorkspaceContextError } from './context-error.js';
|
|
7
|
-
/** The caller holds the Workspace lock. Only an absent, never-published
|
|
7
|
+
/** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
|
|
8
8
|
export async function prepareWorkspaceCheckout(state, binding, input, leaseFence) {
|
|
9
9
|
const preparation = workspacePreparationSchema.safeParse(input);
|
|
10
10
|
if (!preparation.success)
|
|
11
11
|
throw new WorkspaceContextError('invalid_workspace_preparation');
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
const standalone = binding.targetKind === 'standalone';
|
|
13
|
+
if (standalone !== (preparation.data.mode === 'empty'))
|
|
14
|
+
throw new WorkspaceContextError('invalid_workspace_preparation');
|
|
15
|
+
const clone = preparation.data.mode === 'clone' ? preparation.data : undefined;
|
|
16
|
+
const source = binding.repositoryUrl ? parseCloneSource(binding.repositoryUrl) : undefined;
|
|
17
|
+
const direct = isLocalWorkspaceGitAuthMode(clone?.authMode);
|
|
18
|
+
if (direct && (!isLocalWorkspaceGitEnvironment(state.config.environment) || !clone?.directAccess
|
|
19
|
+
|| clone.directAccess.repositoryUrl !== binding.repositoryUrl))
|
|
16
20
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
17
21
|
const parent = await realpath(state.config.workspaceRoot);
|
|
18
22
|
const target = join(parent, binding.workspaceId);
|
|
@@ -42,15 +46,15 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
42
46
|
await writeReceipt(receiptPath, receipt);
|
|
43
47
|
await rm(staging, { recursive: true, force: true });
|
|
44
48
|
await mkdir(staging, { mode: 0o700 });
|
|
45
|
-
const access =
|
|
46
|
-
const credential =
|
|
49
|
+
const access = clone?.gitAccess;
|
|
50
|
+
const credential = clone?.directAccess ?? access;
|
|
47
51
|
const now = Date.now();
|
|
48
52
|
if (access &&
|
|
49
53
|
(Date.parse(access.issuedAt) > now || Date.parse(access.expiresAt) <= now + 10_000)) {
|
|
50
54
|
await rm(staging, { recursive: true, force: true });
|
|
51
55
|
throw new WorkspaceContextError('workspace_git_access_expired');
|
|
52
56
|
}
|
|
53
|
-
const transport =
|
|
57
|
+
const transport = clone?.transport;
|
|
54
58
|
if (transport && !matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, state.config.gitGatewayOrigins, binding.repositoryUrl).ok) {
|
|
55
59
|
await rm(staging, { recursive: true, force: true });
|
|
56
60
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
@@ -76,20 +80,25 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
|
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
'--
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
if (standalone) {
|
|
84
|
+
await runGit(['init', '--initial-branch=main', staging], env, deadline);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
await runGit(['check-ref-format', '--branch', binding.branchName], env, deadline);
|
|
88
|
+
await runGit([
|
|
89
|
+
'clone',
|
|
90
|
+
'--no-checkout',
|
|
91
|
+
'--single-branch',
|
|
92
|
+
'--branch',
|
|
93
|
+
binding.branchName,
|
|
94
|
+
'--',
|
|
95
|
+
binding.repositoryUrl,
|
|
96
|
+
staging,
|
|
97
|
+
], env, deadline);
|
|
98
|
+
// Checkout never receives the temporary credential and never invokes repository hooks or global filters.
|
|
99
|
+
clearCloneSecrets(env);
|
|
100
|
+
await runGit(['-C', staging, 'checkout', '--force', binding.branchName], env, deadline);
|
|
101
|
+
}
|
|
93
102
|
assertCurrent();
|
|
94
103
|
receipt.status = 'ready';
|
|
95
104
|
await writeReceipt(receiptPath, receipt);
|
|
@@ -46,7 +46,7 @@ async function checkedDirectory(state, workspaceId) {
|
|
|
46
46
|
throw new WorkspaceContextError('workspace_not_prepared');
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
/** Registers a fixed
|
|
49
|
+
/** Registers a fixed directory; optional first creation is restricted to its trusted binding. */
|
|
50
50
|
export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
51
51
|
if (state.config.workspaceMode !== 'multi')
|
|
52
52
|
throw new WorkspaceContextError('multi_workspace_unavailable');
|
|
@@ -87,9 +87,18 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
87
87
|
},
|
|
88
88
|
})).stdout.trim();
|
|
89
89
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
let checkoutMatches;
|
|
91
|
+
if (binding.targetKind === 'standalone') {
|
|
92
|
+
const gitDirectory = await lstat(join(root, '.git'));
|
|
93
|
+
checkoutMatches = gitDirectory.isDirectory() && !gitDirectory.isSymbolicLink()
|
|
94
|
+
&& (await git('rev-parse', '--absolute-git-dir')) === join(root, '.git')
|
|
95
|
+
&& (await git('remote')) === '';
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
checkoutMatches = (await git('config', '--get', 'remote.origin.url')) === binding.repositoryUrl
|
|
99
|
+
&& (await git('symbolic-ref', '--short', 'HEAD')) === binding.branchName;
|
|
100
|
+
}
|
|
101
|
+
if ((await realpath(await git('rev-parse', '--show-toplevel'))) !== root || !checkoutMatches) {
|
|
93
102
|
throw new WorkspaceContextError('workspace_checkout_mismatch');
|
|
94
103
|
}
|
|
95
104
|
}
|
|
@@ -102,7 +111,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
|
|
|
102
111
|
throw new WorkspaceContextError('runtime_destroyed');
|
|
103
112
|
if (leaseFence < state.acceptedLeaseFence)
|
|
104
113
|
throw new WorkspaceContextError('stale_lease');
|
|
105
|
-
if (preparation?.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation
|
|
114
|
+
if (preparation?.mode === 'clone' && (preparation.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation.authMode))) {
|
|
106
115
|
if (!state.gitCredentials)
|
|
107
116
|
throw new WorkspaceContextError('workspace_git_gateway_unavailable');
|
|
108
117
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.10",
|
|
52
|
+
"@playcraft/common": "^0.1.10",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|
|
@@ -4,147 +4,147 @@
|
|
|
4
4
|
{
|
|
5
5
|
"skillRef": "platform/playcraft-3d-flip-sprite",
|
|
6
6
|
"version": "1.0.0",
|
|
7
|
-
"artifactHash": "
|
|
7
|
+
"artifactHash": "95183007861a4dae5b67440ea9beed1c360eb616d17e4aa04b97383cc2b63516",
|
|
8
8
|
"surface": "portal-agent",
|
|
9
9
|
"mountPath": ".playcraft/skills/platform__playcraft-3d-flip-sprite"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
12
|
"skillRef": "platform/playcraft-asset-management",
|
|
13
13
|
"version": "1.0.0",
|
|
14
|
-
"artifactHash": "
|
|
14
|
+
"artifactHash": "27209dba24f6e1851dc83682fbd24165894c802794c797cac32d8871f740cf14",
|
|
15
15
|
"surface": "portal-agent",
|
|
16
16
|
"mountPath": ".playcraft/skills/platform__playcraft-asset-management"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"skillRef": "platform/playcraft-build",
|
|
20
20
|
"version": "1.0.0",
|
|
21
|
-
"artifactHash": "
|
|
21
|
+
"artifactHash": "2ca96916041bcc04ee48e827599dd819072e67a626bf59a1c20281f337e4063a",
|
|
22
22
|
"surface": "portal-agent",
|
|
23
23
|
"mountPath": ".playcraft/skills/platform__playcraft-build"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"skillRef": "platform/playcraft-code-editor",
|
|
27
27
|
"version": "1.0.0",
|
|
28
|
-
"artifactHash": "
|
|
28
|
+
"artifactHash": "abcc9aafe2b15adb8de62eb89921dbcb35c7dc634055b937fe34bdf83ca6685d",
|
|
29
29
|
"surface": "portal-agent",
|
|
30
30
|
"mountPath": ".playcraft/skills/platform__playcraft-code-editor"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"skillRef": "platform/playcraft-create-remix",
|
|
34
34
|
"version": "1.0.0",
|
|
35
|
-
"artifactHash": "
|
|
35
|
+
"artifactHash": "8d2a4d4d096433ccec090f1003c050471ddfbc50d69c1c0c66f88424b52bacf6",
|
|
36
36
|
"surface": "portal-agent",
|
|
37
37
|
"mountPath": ".playcraft/skills/platform__playcraft-create-remix"
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"skillRef": "platform/playcraft-deploy",
|
|
41
|
-
"version": "1.0.
|
|
42
|
-
"artifactHash": "
|
|
41
|
+
"version": "1.0.1",
|
|
42
|
+
"artifactHash": "31ac7f90d93606eb40c2113d56204e09b97ecd9e49b910b2ce05445b824ccfdc",
|
|
43
43
|
"surface": "portal-agent",
|
|
44
44
|
"mountPath": ".playcraft/skills/platform__playcraft-deploy"
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"skillRef": "platform/playcraft-iteration-methodology",
|
|
48
|
-
"version": "1.
|
|
49
|
-
"artifactHash": "
|
|
48
|
+
"version": "1.1.0",
|
|
49
|
+
"artifactHash": "f67a2f1806e08a48a903408383a06802622da3760d24d72a0dcbbea202349be2",
|
|
50
50
|
"surface": "portal-agent",
|
|
51
51
|
"mountPath": ".playcraft/skills/platform__playcraft-iteration-methodology"
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"skillRef": "platform/playcraft-platform-intro",
|
|
55
55
|
"version": "1.0.0",
|
|
56
|
-
"artifactHash": "
|
|
56
|
+
"artifactHash": "b37f6b0515a79822f5f69519fcddd1182d420b90eb9ba40298bb1ed73eced281",
|
|
57
57
|
"surface": "portal-agent",
|
|
58
58
|
"mountPath": ".playcraft/skills/platform__playcraft-platform-intro"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"skillRef": "platform/playcraft-prefab",
|
|
62
62
|
"version": "1.0.0",
|
|
63
|
-
"artifactHash": "
|
|
63
|
+
"artifactHash": "a4a991302922d6846a2e313dc4cd4514f54820040dc2166ccc864803053c1ea5",
|
|
64
64
|
"surface": "portal-agent",
|
|
65
65
|
"mountPath": ".playcraft/skills/platform__playcraft-prefab"
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"skillRef": "platform/playcraft-project-management",
|
|
69
69
|
"version": "1.0.0",
|
|
70
|
-
"artifactHash": "
|
|
70
|
+
"artifactHash": "e7e4bffe80e0737362a5cac4234056a7d7fb048f0c8b5e41041e61ee914495e3",
|
|
71
71
|
"surface": "portal-agent",
|
|
72
72
|
"mountPath": ".playcraft/skills/platform__playcraft-project-management"
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"skillRef": "platform/playcraft-remix-workflow",
|
|
76
|
-
"version": "1.0.
|
|
77
|
-
"artifactHash": "
|
|
76
|
+
"version": "1.0.1",
|
|
77
|
+
"artifactHash": "23807ff591b4bd08c87daa4e9b2c9900b0505e4f2f601c47de51a96d6ebbed19",
|
|
78
78
|
"surface": "portal-agent",
|
|
79
79
|
"mountPath": ".playcraft/skills/platform__playcraft-remix-workflow"
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"skillRef": "platform/playcraft-save",
|
|
83
|
-
"version": "1.0.
|
|
84
|
-
"artifactHash": "
|
|
83
|
+
"version": "1.0.1",
|
|
84
|
+
"artifactHash": "6d42c681f91f849603bf3c4f235411ea05189a0d5e3ca1d3a8f92b69e536bdd1",
|
|
85
85
|
"surface": "portal-agent",
|
|
86
86
|
"mountPath": ".playcraft/skills/platform__playcraft-save"
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"skillRef": "platform/playcraft-template-upgrade-merge",
|
|
90
90
|
"version": "1.0.0",
|
|
91
|
-
"artifactHash": "
|
|
91
|
+
"artifactHash": "354da60f395f3eb7a267db667783344f54567e12abcfec454b269193a5af1cb6",
|
|
92
92
|
"surface": "portal-agent",
|
|
93
93
|
"mountPath": ".playcraft/skills/platform__playcraft-template-upgrade-merge"
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
"skillRef": "platform/sound-effects",
|
|
97
97
|
"version": "1.0.0",
|
|
98
|
-
"artifactHash": "
|
|
98
|
+
"artifactHash": "a6ba7ea91ddbf4f5a04ae8340a69c3725c5d97b239e8636bf64886928e8dd1da",
|
|
99
99
|
"surface": "portal-agent",
|
|
100
100
|
"mountPath": ".playcraft/skills/platform__sound-effects"
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"skillRef": "remix/playcraft-adsdk",
|
|
104
104
|
"version": "1.0.0",
|
|
105
|
-
"artifactHash": "
|
|
105
|
+
"artifactHash": "0de877b122246ed5c665a893f8dc408282007d0950b0f6c44876f87ceddb519f",
|
|
106
106
|
"surface": "sandbox-agent",
|
|
107
107
|
"mountPath": ".playcraft/skills/remix__playcraft-adsdk"
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
"skillRef": "remix/playcraft-audio-generation",
|
|
111
111
|
"version": "1.0.0",
|
|
112
|
-
"artifactHash": "
|
|
112
|
+
"artifactHash": "8d1b035b289545ca8fd6c247b9417bf34bddf5a26b31e114c35d001aa3a9ebf0",
|
|
113
113
|
"surface": "sandbox-agent",
|
|
114
114
|
"mountPath": ".playcraft/skills/remix__playcraft-audio-generation"
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"skillRef": "remix/playcraft-dist-publish",
|
|
118
118
|
"version": "1.0.0",
|
|
119
|
-
"artifactHash": "
|
|
119
|
+
"artifactHash": "9a00acc9b3b6f140f276032b722bbe7bbf3a9b1fa61fe71cb65913ebcd118772",
|
|
120
120
|
"surface": "sandbox-agent",
|
|
121
121
|
"mountPath": ".playcraft/skills/remix__playcraft-dist-publish"
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
"skillRef": "remix/playcraft-image-generation",
|
|
125
125
|
"version": "1.0.0",
|
|
126
|
-
"artifactHash": "
|
|
126
|
+
"artifactHash": "91bec2422c750d9d47d5be2796ec5aaffb48de561f2c2d8b92112f37d576f489",
|
|
127
127
|
"surface": "sandbox-agent",
|
|
128
128
|
"mountPath": ".playcraft/skills/remix__playcraft-image-generation"
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
"skillRef": "remix/playcraft-image-processing",
|
|
132
132
|
"version": "1.0.0",
|
|
133
|
-
"artifactHash": "
|
|
133
|
+
"artifactHash": "d845c067bed103c3d1d3f145b1cb8246ffbd6a9ae91efcfbce2eae456819212d",
|
|
134
134
|
"surface": "sandbox-agent",
|
|
135
135
|
"mountPath": ".playcraft/skills/remix__playcraft-image-processing"
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
"skillRef": "remix/playcraft-prefab",
|
|
139
139
|
"version": "1.0.0",
|
|
140
|
-
"artifactHash": "
|
|
140
|
+
"artifactHash": "3cce28f5b67398fffaaa6295d27cf514c8e1e1e998d142524facaff7d2c25561",
|
|
141
141
|
"surface": "sandbox-agent",
|
|
142
142
|
"mountPath": ".playcraft/skills/remix__playcraft-prefab"
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"skillRef": "remix/playcraft-remix-workflow",
|
|
146
146
|
"version": "1.0.0",
|
|
147
|
-
"artifactHash": "
|
|
147
|
+
"artifactHash": "e2a59c61ef5b094a821fbbab3b0f65f906bfc0dfda6f3d8c0b33a6f460dd0963",
|
|
148
148
|
"surface": "sandbox-agent",
|
|
149
149
|
"mountPath": ".playcraft/skills/remix__playcraft-remix-workflow"
|
|
150
150
|
}
|