@tangle-network/agent-app 0.49.32 → 0.49.34
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/bin/tangle-agent/home-maintenance.py +280 -0
- package/bin/tangle-agent/home.mjs +166 -0
- package/bin/tangle-agent/provision.mjs +168 -0
- package/bin/tangle-agent/runtime.mjs +151 -0
- package/bin/tangle-agent.mjs +26 -0
- package/dist/VaultPane-2SJSP6TS.js +7 -0
- package/dist/app-auth/index.d.ts +14 -2
- package/dist/app-auth/index.js +3 -3
- package/dist/app-auth/index.js.map +1 -1
- package/dist/assistant/index.js +6 -6
- package/dist/chat-routes/index.js +4 -4
- package/dist/chat-store/index.js +2 -2
- package/dist/{chunk-FCUWRMAF.js → chunk-56SVXAIK.js} +14 -9
- package/dist/chunk-56SVXAIK.js.map +1 -0
- package/dist/chunk-6CV66YOB.js +99 -0
- package/dist/chunk-6CV66YOB.js.map +1 -0
- package/dist/{chunk-V4YGTOCS.js → chunk-MIFVOYZ7.js} +5 -5
- package/dist/{chunk-FXWNNHPC.js → chunk-MVIIH2EQ.js} +165 -72
- package/dist/chunk-MVIIH2EQ.js.map +1 -0
- package/dist/{chunk-4PZE7XAM.js → chunk-S3344RBY.js} +4 -4
- package/dist/{chunk-4S36HWE4.js → chunk-WAMJSZYB.js} +1 -1
- package/dist/{chunk-VIL6MHHQ.js → chunk-XTLYE2VO.js} +6 -6
- package/dist/design-canvas-react/index.js +1 -1
- package/dist/design-canvas-react/lazy.js +1 -1
- package/dist/hosted-agent/general-skill.d.ts +2 -0
- package/dist/hosted-agent/general.d.ts +53 -0
- package/dist/hosted-agent/index.d.ts +9 -1
- package/dist/hosted-agent/index.js +294 -7
- package/dist/hosted-agent/index.js.map +1 -1
- package/dist/hosted-agent/react/LineBilling.d.ts +3 -0
- package/dist/hosted-agent/react/LineMembers.d.ts +3 -0
- package/dist/hosted-agent/react/LineSetup.d.ts +3 -0
- package/dist/hosted-agent/react/contracts.d.ts +136 -0
- package/dist/hosted-agent/react/index.d.ts +5 -0
- package/dist/hosted-agent/react/index.js +573 -0
- package/dist/hosted-agent/react/index.js.map +1 -0
- package/dist/hosted-agent/react/lines.css +110 -0
- package/dist/hosted-agent/react/styles.d.ts +2 -0
- package/dist/hosted-agent/react/styles.js +1 -0
- package/dist/hosted-agent/react/styles.js.map +1 -0
- package/dist/hosted-agent/workflows.d.ts +17 -0
- package/dist/integrations/index.d.ts +2 -9
- package/dist/integrations/index.js +11 -27
- package/dist/integrations/index.js.map +1 -1
- package/dist/platform/index.js +1 -1
- package/dist/platform/sso.d.ts +74 -7
- package/dist/profile/home.d.ts +23 -0
- package/dist/profile/index.d.ts +1 -0
- package/dist/profile/index.js +9 -1
- package/dist/profile/index.js.map +1 -1
- package/dist/public-consultation/index.js +4 -4
- package/dist/stories/hosted-agent/fixtures.d.ts +34 -0
- package/dist/teams/index.js +5 -5
- package/dist/teams/invitations-api.js +4 -4
- package/dist/vault/ConfirmDialog.d.ts +1 -1
- package/dist/vault/VaultPane.d.ts +2 -2
- package/dist/vault/contracts.d.ts +8 -0
- package/dist/vault/index.js +1 -1
- package/dist/vault/lazy.d.ts +3 -3
- package/dist/vault/lazy.js +1 -1
- package/dist/vault/lazy.js.map +1 -1
- package/dist/web-react/index.js +22 -22
- package/package.json +50 -17
- package/dist/VaultPane-32HVWZ6A.js +0 -7
- package/dist/chunk-FCUWRMAF.js.map +0 -1
- package/dist/chunk-FXWNNHPC.js.map +0 -1
- /package/dist/{VaultPane-32HVWZ6A.js.map → VaultPane-2SJSP6TS.js.map} +0 -0
- /package/dist/{chunk-V4YGTOCS.js.map → chunk-MIFVOYZ7.js.map} +0 -0
- /package/dist/{chunk-4PZE7XAM.js.map → chunk-S3344RBY.js.map} +0 -0
- /package/dist/{chunk-4S36HWE4.js.map → chunk-WAMJSZYB.js.map} +0 -0
- /package/dist/{chunk-VIL6MHHQ.js.map → chunk-XTLYE2VO.js.map} +0 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The sole writer of the protected agent home. Installed root-owned, invoked
|
|
3
|
+
through one exact sudo command with Python -I. No caller paths or environment
|
|
4
|
+
choose the home, executable, git configuration, or policy. JSON on stdin/stdout.
|
|
5
|
+
"""
|
|
6
|
+
import datetime
|
|
7
|
+
import fcntl
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
BASE = pathlib.Path('/var/lib/tangle-agent')
|
|
20
|
+
ROOT = BASE / 'home'
|
|
21
|
+
STATE = BASE / 'state'
|
|
22
|
+
CONFIG = pathlib.Path('/etc/tangle-agent/home.json')
|
|
23
|
+
GIT = STATE / 'home.git'
|
|
24
|
+
FIXED = {'AGENTS.md', 'SOUL.md', 'IDENTITY.md', 'USER.md', 'MEMORY.md', 'BOOTSTRAP.md'}
|
|
25
|
+
MAX_FILES = 1024
|
|
26
|
+
MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
|
27
|
+
CAPS = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def protected(path, directory=False):
|
|
31
|
+
info = path.lstat()
|
|
32
|
+
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
|
|
33
|
+
raise ValueError('home ownership or permissions are unsafe')
|
|
34
|
+
if directory:
|
|
35
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
36
|
+
raise ValueError('home directory is not a directory')
|
|
37
|
+
elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
38
|
+
raise ValueError('home entry must be a root-owned regular file with one link')
|
|
39
|
+
return info
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_policy():
|
|
43
|
+
if os.geteuid() != 0 or len(sys.argv) != 1:
|
|
44
|
+
raise ValueError('use the installed privileged home command without arguments')
|
|
45
|
+
for path in [pathlib.Path('/etc'), CONFIG.parent, pathlib.Path('/var'),
|
|
46
|
+
pathlib.Path('/var/lib'), BASE, ROOT, STATE]:
|
|
47
|
+
protected(path, True)
|
|
48
|
+
if ROOT.stat().st_dev != STATE.stat().st_dev:
|
|
49
|
+
raise ValueError('home and journal must share one persistent filesystem')
|
|
50
|
+
protected(CONFIG)
|
|
51
|
+
policy = json.loads(CONFIG.read_text(encoding='utf8'))
|
|
52
|
+
uid = policy.get('runtimeUid')
|
|
53
|
+
if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
|
|
54
|
+
raise ValueError('home runtime identity is invalid')
|
|
55
|
+
# sudo supplies SUDO_UID and the exact-command sudoers grant disallows
|
|
56
|
+
# SETENV. Direct root invocation is reserved for image/operator setup.
|
|
57
|
+
sudo_uid = os.environ.get('SUDO_UID')
|
|
58
|
+
if sudo_uid is not None and sudo_uid != str(uid):
|
|
59
|
+
raise ValueError('this caller does not own this member home')
|
|
60
|
+
caps = policy.get('caps', {})
|
|
61
|
+
if set(caps) != FIXED | {'dailyNote', 'skill'} or any(
|
|
62
|
+
not isinstance(value, int) or isinstance(value, bool) or value <= 0 or value > 32768
|
|
63
|
+
for value in caps.values()
|
|
64
|
+
):
|
|
65
|
+
raise ValueError('home caps are invalid')
|
|
66
|
+
CAPS.update(caps)
|
|
67
|
+
protected(ROOT / 'AGENTS.md')
|
|
68
|
+
if hashlib.sha256((ROOT / 'AGENTS.md').read_bytes()).hexdigest() != policy.get('agentsSha256'):
|
|
69
|
+
raise ValueError('platform AGENTS.md attestation failed')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def checked(name):
|
|
73
|
+
if not isinstance(name, str):
|
|
74
|
+
raise ValueError('home path must be text')
|
|
75
|
+
if name not in FIXED:
|
|
76
|
+
note = re.fullmatch(r'memory/(\d{4}-\d{2}-\d{2})\.md', name)
|
|
77
|
+
skill = re.fullmatch(r'skills/[a-z0-9][a-z0-9-]{0,63}/SKILL\.md', name)
|
|
78
|
+
if note:
|
|
79
|
+
datetime.date.fromisoformat(note.group(1))
|
|
80
|
+
elif not skill:
|
|
81
|
+
raise ValueError('path is outside the home allowlist')
|
|
82
|
+
target = ROOT / name
|
|
83
|
+
current = ROOT
|
|
84
|
+
for component in pathlib.PurePosixPath(name).parts:
|
|
85
|
+
current = current / component
|
|
86
|
+
if current.exists() or current.is_symlink():
|
|
87
|
+
protected(current, current != target)
|
|
88
|
+
return target
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cap(name):
|
|
92
|
+
return CAPS[name] if name in FIXED else CAPS['skill' if name.startswith('skills/') else 'dailyNote']
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bounded(name, content):
|
|
96
|
+
checked(name)
|
|
97
|
+
if not isinstance(content, str) or len(content) > cap(name):
|
|
98
|
+
raise ValueError('home character budget exceeded')
|
|
99
|
+
if len(content.encode('utf8')) > cap(name) * 4:
|
|
100
|
+
raise ValueError('home byte budget exceeded')
|
|
101
|
+
return content
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read(name):
|
|
105
|
+
path = checked(name)
|
|
106
|
+
if not path.exists():
|
|
107
|
+
return ''
|
|
108
|
+
if path.stat().st_size > cap(name) * 4:
|
|
109
|
+
raise ValueError('home byte budget exceeded')
|
|
110
|
+
return bounded(name, path.read_text(encoding='utf8'))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def paths():
|
|
114
|
+
result = []
|
|
115
|
+
for directory, directories, files in os.walk(ROOT, followlinks=False):
|
|
116
|
+
for child in directories:
|
|
117
|
+
protected(pathlib.Path(directory) / child, True)
|
|
118
|
+
for child in files:
|
|
119
|
+
name = str((pathlib.Path(directory) / child).relative_to(ROOT))
|
|
120
|
+
checked(name)
|
|
121
|
+
result.append(name)
|
|
122
|
+
if len(result) > MAX_FILES:
|
|
123
|
+
raise ValueError('home file-count budget exceeded')
|
|
124
|
+
return sorted(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def atomic(path, content, mode=0o444):
|
|
128
|
+
fd, temporary = tempfile.mkstemp(prefix='.home-', dir=STATE)
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(fd, 'w', encoding='utf8') as output:
|
|
131
|
+
output.write(content)
|
|
132
|
+
output.flush()
|
|
133
|
+
os.fchmod(output.fileno(), mode)
|
|
134
|
+
os.fsync(output.fileno())
|
|
135
|
+
os.replace(temporary, path)
|
|
136
|
+
directory = os.open(path.parent, os.O_DIRECTORY)
|
|
137
|
+
try:
|
|
138
|
+
os.fsync(directory)
|
|
139
|
+
finally:
|
|
140
|
+
os.close(directory)
|
|
141
|
+
finally:
|
|
142
|
+
if os.path.exists(temporary):
|
|
143
|
+
os.unlink(temporary)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def git(*args):
|
|
147
|
+
# Neither the caller's PATH/HOME nor git-related environment is inherited.
|
|
148
|
+
env = {'PATH': '/usr/bin:/bin', 'HOME': '/nonexistent', 'LANG': 'C.UTF-8',
|
|
149
|
+
'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_TERMINAL_PROMPT': '0'}
|
|
150
|
+
result = subprocess.run(['/usr/bin/git', '--git-dir=' + str(GIT), '--work-tree=' + str(ROOT),
|
|
151
|
+
'-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.fsync=all',
|
|
152
|
+
'-c', 'user.name=Tangle home', '-c', 'user.email=home@localhost', *args],
|
|
153
|
+
cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
154
|
+
timeout=20, check=False)
|
|
155
|
+
if result.returncode:
|
|
156
|
+
raise RuntimeError('home git operation failed')
|
|
157
|
+
return result.stdout.strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def checkpoint():
|
|
161
|
+
existing = paths()
|
|
162
|
+
if sum(len(read(name).encode('utf8')) for name in existing) > MAX_TOTAL_BYTES:
|
|
163
|
+
raise ValueError('home total-byte budget exceeded')
|
|
164
|
+
tracked = list(filter(None, git('ls-files', '-z').split('\0')))
|
|
165
|
+
selected = sorted(set(existing + tracked))
|
|
166
|
+
for name in selected:
|
|
167
|
+
checked(name)
|
|
168
|
+
if selected:
|
|
169
|
+
git('add', '--all', '--', *selected)
|
|
170
|
+
changed = bool(git('diff', '--cached', '--name-only'))
|
|
171
|
+
if changed:
|
|
172
|
+
git('commit', '-m', 'home: ' + datetime.datetime.now(datetime.timezone.utc).isoformat())
|
|
173
|
+
return {'commit': git('rev-parse', '--verify', 'HEAD'), 'changed': changed}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def apply(updates):
|
|
177
|
+
# Validate the whole change before writing any document. A durable journal
|
|
178
|
+
# finishes the same transaction after a crash instead of mixing two nightly
|
|
179
|
+
# summaries. Neither the journal nor the git metadata is agent-writable.
|
|
180
|
+
current = {name: read(name) for name in paths()}
|
|
181
|
+
for name, value in updates.items():
|
|
182
|
+
checked(name)
|
|
183
|
+
if name == 'AGENTS.md':
|
|
184
|
+
raise ValueError('AGENTS.md is platform-owned')
|
|
185
|
+
if value is None:
|
|
186
|
+
current.pop(name, None)
|
|
187
|
+
else:
|
|
188
|
+
current[name] = bounded(name, value)
|
|
189
|
+
if len(current) > MAX_FILES or sum(len(value.encode('utf8')) for value in current.values()) > MAX_TOTAL_BYTES:
|
|
190
|
+
raise ValueError('home total budget exceeded')
|
|
191
|
+
atomic(STATE / 'pending.json', json.dumps(updates, ensure_ascii=False), 0o600)
|
|
192
|
+
return finish_pending()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def finish_pending():
|
|
196
|
+
pending = STATE / 'pending.json'
|
|
197
|
+
if not pending.exists():
|
|
198
|
+
return None
|
|
199
|
+
protected(pending)
|
|
200
|
+
updates = json.loads(pending.read_text(encoding='utf8'))
|
|
201
|
+
for name, content in updates.items():
|
|
202
|
+
target = checked(name)
|
|
203
|
+
if name == 'AGENTS.md':
|
|
204
|
+
raise ValueError('AGENTS.md is platform-owned')
|
|
205
|
+
if content is None:
|
|
206
|
+
if target.exists():
|
|
207
|
+
target.unlink()
|
|
208
|
+
else:
|
|
209
|
+
bounded(name, content)
|
|
210
|
+
parent = ROOT
|
|
211
|
+
for component in pathlib.PurePosixPath(name).parts[:-1]:
|
|
212
|
+
parent = parent / component
|
|
213
|
+
if not parent.exists():
|
|
214
|
+
parent.mkdir(mode=0o755)
|
|
215
|
+
parent.chmod(0o755)
|
|
216
|
+
protected(parent, True)
|
|
217
|
+
checked(name)
|
|
218
|
+
atomic(target, content)
|
|
219
|
+
result = checkpoint()
|
|
220
|
+
pending.unlink()
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main():
|
|
225
|
+
load_policy()
|
|
226
|
+
os.umask(0o077)
|
|
227
|
+
raw = sys.stdin.buffer.read(262145)
|
|
228
|
+
if len(raw) > 262144:
|
|
229
|
+
raise ValueError('home command input exceeds 262144 bytes')
|
|
230
|
+
request = json.loads(raw)
|
|
231
|
+
fields = {'status': set(), 'commit': set(), 'bootstrap': set(), 'read': {'path'},
|
|
232
|
+
'write': {'path', 'content'}, 'append': {'path', 'content'}, 'delete': {'path'},
|
|
233
|
+
'consolidate': {'user', 'memory', 'expectedHead'}}
|
|
234
|
+
if not isinstance(request, dict) or request.get('action') not in fields:
|
|
235
|
+
raise ValueError('unknown home operation')
|
|
236
|
+
action = request['action']
|
|
237
|
+
if set(request) != {'action'} | fields[action]:
|
|
238
|
+
raise ValueError('invalid home operation fields')
|
|
239
|
+
lock = STATE / 'home.lock'
|
|
240
|
+
with lock.open('a') as handle:
|
|
241
|
+
protected(lock)
|
|
242
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
243
|
+
if not GIT.exists():
|
|
244
|
+
git('init')
|
|
245
|
+
protected(GIT, True)
|
|
246
|
+
finish_pending()
|
|
247
|
+
if action == 'read':
|
|
248
|
+
return {'path': request['path'], 'content': read(request['path']),
|
|
249
|
+
'commit': git('rev-parse', '--verify', 'HEAD')}
|
|
250
|
+
if action in {'write', 'append', 'delete'}:
|
|
251
|
+
name = request['path']
|
|
252
|
+
content = None if action == 'delete' else request['content']
|
|
253
|
+
if action == 'append':
|
|
254
|
+
content = read(name) + content
|
|
255
|
+
result = apply({name: content})
|
|
256
|
+
return {**result, 'path': name, 'ownerNoticeRequired': name == 'SOUL.md'}
|
|
257
|
+
if action == 'bootstrap':
|
|
258
|
+
if not read('IDENTITY.md').strip():
|
|
259
|
+
raise ValueError('fill IDENTITY.md before completing bootstrap')
|
|
260
|
+
return apply({'BOOTSTRAP.md': None})
|
|
261
|
+
if action == 'consolidate':
|
|
262
|
+
if request['expectedHead'] != git('rev-parse', '--verify', 'HEAD'):
|
|
263
|
+
raise ValueError('home changed; reread notes before consolidating')
|
|
264
|
+
return apply({'USER.md': request['user'], 'MEMORY.md': request['memory']})
|
|
265
|
+
result = checkpoint()
|
|
266
|
+
if action == 'status':
|
|
267
|
+
result['files'] = [{'path': name, 'characters': len(read(name)),
|
|
268
|
+
'sha256': hashlib.sha256(read(name).encode('utf8')).hexdigest()} for name in paths()]
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == '__main__':
|
|
273
|
+
signal.alarm(30)
|
|
274
|
+
try:
|
|
275
|
+
print(json.dumps(main(), ensure_ascii=False))
|
|
276
|
+
except Exception as error:
|
|
277
|
+
# Do not reflect file bodies, request data, or subprocess diagnostics.
|
|
278
|
+
print(json.dumps({'error': str(error) if isinstance(error, (ValueError, RuntimeError))
|
|
279
|
+
else type(error).__name__}), file=sys.stderr)
|
|
280
|
+
sys.exit(1)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdir, open, lstat, readFile, rename, unlink } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { execFile, spawn } from 'node:child_process'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
export const PROTECTED_HOME = '/var/lib/tangle-agent/home'
|
|
8
|
+
const BASE = '/var/lib/tangle-agent'
|
|
9
|
+
const STATE = `${BASE}/state`
|
|
10
|
+
const CONFIG = '/etc/tangle-agent/home.json'
|
|
11
|
+
const HELPER = '/usr/local/libexec/tangle-agent-home.py'
|
|
12
|
+
const run = promisify(execFile)
|
|
13
|
+
const cleanEnv = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
|
|
14
|
+
|
|
15
|
+
async function protectedEntry(path, directory = false) {
|
|
16
|
+
const info = await lstat(path)
|
|
17
|
+
if (info.uid !== 0 || (info.mode & 0o022) || info.isSymbolicLink() ||
|
|
18
|
+
(directory ? !info.isDirectory() : !info.isFile() || info.nlink !== 1)) {
|
|
19
|
+
throw new Error(`Unsafe protected home installation: ${path}`)
|
|
20
|
+
}
|
|
21
|
+
return info
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One installed, exact-command sudo capability. No shell and no caller environment. */
|
|
25
|
+
export async function homeOperation(request, signal) {
|
|
26
|
+
signal?.throwIfAborted()
|
|
27
|
+
const input = JSON.stringify(request)
|
|
28
|
+
if (Buffer.byteLength(input) > 262144) throw new Error('Home request is too large')
|
|
29
|
+
await protectedEntry(HELPER)
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn('/usr/bin/sudo', ['-n', '--', '/usr/bin/python3', '-I', HELPER], {
|
|
32
|
+
cwd: '/', env: cleanEnv, stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
})
|
|
34
|
+
let output = '', errorOutput = '', size = 0, inputFailed = false, timedOut = false
|
|
35
|
+
child.stdout.setEncoding('utf8')
|
|
36
|
+
child.stderr.setEncoding('utf8')
|
|
37
|
+
const stop = () => child.kill('SIGTERM')
|
|
38
|
+
const timer = setTimeout(() => { timedOut = true; stop() }, 35_000)
|
|
39
|
+
timer.unref()
|
|
40
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
41
|
+
child.stdout.on('data', bytes => {
|
|
42
|
+
size += Buffer.byteLength(bytes)
|
|
43
|
+
if (size > 1024 * 1024) stop()
|
|
44
|
+
else output += bytes.toString('utf8')
|
|
45
|
+
})
|
|
46
|
+
child.stderr.on('data', bytes => { if (errorOutput.length < 2048) errorOutput += bytes.toString('utf8') })
|
|
47
|
+
child.stdin.on('error', () => { inputFailed = true })
|
|
48
|
+
child.once('error', finishError)
|
|
49
|
+
child.once('close', (code, killed) => {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
signal?.removeEventListener('abort', stop)
|
|
52
|
+
if (signal?.aborted) return reject(signal.reason)
|
|
53
|
+
if (code !== 0 || killed || inputFailed || timedOut || size > 1024 * 1024) {
|
|
54
|
+
let detail = 'Home operation failed or is unconfirmed; inspect home_status before retrying a write'
|
|
55
|
+
try { const value = JSON.parse(errorOutput); if (typeof value.error === 'string') detail = value.error }
|
|
56
|
+
catch { detail = 'Protected home returned no validation receipt; inspect home_status before retrying a write' }
|
|
57
|
+
return reject(new Error(detail))
|
|
58
|
+
}
|
|
59
|
+
try { resolve(JSON.parse(output)) } catch { reject(new Error('Invalid protected home response')) }
|
|
60
|
+
})
|
|
61
|
+
function finishError(error) {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
signal?.removeEventListener('abort', stop)
|
|
64
|
+
reject(new Error('The protected home command could not start', { cause: error }))
|
|
65
|
+
}
|
|
66
|
+
child.stdin.end(input)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The runtime cannot create or repair this boundary as its own UID. */
|
|
71
|
+
export async function initializeHome(directoryName) {
|
|
72
|
+
if (directoryName !== PROTECTED_HOME || typeof process.getuid !== 'function' || process.getuid() === 0) {
|
|
73
|
+
throw new Error('General agents require the installed protected home and a non-root runtime UID')
|
|
74
|
+
}
|
|
75
|
+
for (const directory of ['/var', '/var/lib', BASE, PROTECTED_HOME, '/etc', '/etc/tangle-agent', '/usr/local/libexec']) {
|
|
76
|
+
await protectedEntry(directory, true)
|
|
77
|
+
}
|
|
78
|
+
await protectedEntry(CONFIG)
|
|
79
|
+
const policy = JSON.parse(await readFile(CONFIG, 'utf8'))
|
|
80
|
+
if (policy.runtimeUid !== process.getuid()) throw new Error('Protected home belongs to another runtime UID')
|
|
81
|
+
await protectedEntry(`${PROTECTED_HOME}/AGENTS.md`)
|
|
82
|
+
const digest = createHash('sha256').update(await readFile(`${PROTECTED_HOME}/AGENTS.md`)).digest('hex')
|
|
83
|
+
if (digest !== policy.agentsSha256) throw new Error('Platform AGENTS.md attestation failed')
|
|
84
|
+
await homeOperation({ action: 'status' })
|
|
85
|
+
return PROTECTED_HOME
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function checkpointHome(directoryName, signal) {
|
|
89
|
+
if (directoryName !== PROTECTED_HOME) throw new Error('Invalid protected home')
|
|
90
|
+
return { home: PROTECTED_HOME, ...await homeOperation({ action: 'commit' }, signal) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Image/operator install only. Uses the same canonical profile seed records. */
|
|
94
|
+
export async function installProtectedHome(runtimeUser) {
|
|
95
|
+
if (process.getuid?.() !== 0 || !/^[a-z_][a-z0-9_-]{0,31}$/.test(runtimeUser ?? '')) {
|
|
96
|
+
throw new Error('install-home requires root and an explicit non-root runtime user')
|
|
97
|
+
}
|
|
98
|
+
const uid = Number((await run('/usr/bin/id', ['-u', runtimeUser])).stdout.trim())
|
|
99
|
+
const gid = Number((await run('/usr/bin/id', ['-g', runtimeUser])).stdout.trim())
|
|
100
|
+
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Invalid runtime UID/GID')
|
|
101
|
+
for (const [directory, mode] of [[BASE, 0o755], [PROTECTED_HOME, 0o755], [STATE, 0o700],
|
|
102
|
+
['/etc/tangle-agent', 0o755], ['/usr/local/libexec', 0o755], ['/etc/sudoers.d', 0o750]]) {
|
|
103
|
+
await mkdir(directory, { recursive: true, mode })
|
|
104
|
+
await protectedEntry(directory, true)
|
|
105
|
+
}
|
|
106
|
+
if ((await lstat(STATE)).dev !== (await lstat(PROTECTED_HOME)).dev) throw new Error('Home and journal must share one persistent filesystem')
|
|
107
|
+
async function replace(path, body, mode) {
|
|
108
|
+
const temporary = join(dirname(path), `.tangle-${randomUUID()}`)
|
|
109
|
+
const file = await open(temporary, 'wx', mode)
|
|
110
|
+
try {
|
|
111
|
+
await file.writeFile(body)
|
|
112
|
+
await file.sync()
|
|
113
|
+
await rename(temporary, path)
|
|
114
|
+
} finally {
|
|
115
|
+
await file.close()
|
|
116
|
+
try { await unlink(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const { defaultHomeFiles, DEFAULT_HOME_LIMITS } = await import('../../dist/hosted-agent/index.js')
|
|
120
|
+
const seeds = defaultHomeFiles().filter(mount => mount.path !== '.tangle/home.py')
|
|
121
|
+
const agents = seeds.find(mount => mount.path === 'AGENTS.md')?.resource.content
|
|
122
|
+
if (!agents) throw new Error('The installed package has no canonical AGENTS.md')
|
|
123
|
+
let initialized = false
|
|
124
|
+
try { await protectedEntry(`${STATE}/initialized`); initialized = true }
|
|
125
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
126
|
+
for (const mount of seeds) {
|
|
127
|
+
if (mount.resource.kind !== 'inline' || !Object.hasOwn(DEFAULT_HOME_LIMITS, mount.path)) throw new Error('Unsupported home seed')
|
|
128
|
+
if (initialized && mount.path !== 'AGENTS.md') continue
|
|
129
|
+
const path = `${PROTECTED_HOME}/${mount.path}`
|
|
130
|
+
try {
|
|
131
|
+
await protectedEntry(path)
|
|
132
|
+
if (mount.path !== 'AGENTS.md') continue
|
|
133
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
134
|
+
await replace(path, mount.resource.content, 0o444)
|
|
135
|
+
}
|
|
136
|
+
await replace(CONFIG, JSON.stringify({ runtimeUid: uid, caps: DEFAULT_HOME_LIMITS,
|
|
137
|
+
agentsSha256: createHash('sha256').update(agents).digest('hex') }), 0o444)
|
|
138
|
+
await replace(HELPER, await readFile(new URL('./home-maintenance.py', import.meta.url)), 0o444)
|
|
139
|
+
// Verify inherited sudoers ordering rather than silently leaving a broad grant.
|
|
140
|
+
const policyFile = '/etc/sudoers.d/zz-tangle-agent-home'
|
|
141
|
+
await replace(policyFile, `Defaults:${runtimeUser} env_reset,secure_path="/usr/bin:/bin"\n` +
|
|
142
|
+
`${runtimeUser} ALL=(ALL:ALL) !ALL\n` +
|
|
143
|
+
`${runtimeUser} ALL=(root) NOPASSWD: NOSETENV: /usr/bin/python3 -I ${HELPER}\n`, 0o440)
|
|
144
|
+
await run('/usr/sbin/visudo', ['-cf', policyFile], { env: cleanEnv })
|
|
145
|
+
let rootCommandDenied = false
|
|
146
|
+
try { await run('/usr/bin/sudo', ['-n', '--', '/usr/bin/id', '-u'], { uid, gid, env: cleanEnv }) }
|
|
147
|
+
catch (error) { if (error.code === 1) rootCommandDenied = true; else throw error }
|
|
148
|
+
if (!rootCommandDenied) throw new Error('Runtime still has a broad sudo grant; refuse the image')
|
|
149
|
+
const status = await new Promise((resolve, reject) => {
|
|
150
|
+
const child = spawn('/usr/bin/sudo', ['-n', '--', '/usr/bin/python3', '-I', HELPER],
|
|
151
|
+
{ uid, gid, cwd: '/', env: cleanEnv, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
152
|
+
let output = ''
|
|
153
|
+
child.stdout.setEncoding('utf8')
|
|
154
|
+
child.stdout.on('data', bytes => { output += bytes.toString() })
|
|
155
|
+
child.stderr.resume()
|
|
156
|
+
child.on('error', reject)
|
|
157
|
+
child.on('close', code => {
|
|
158
|
+
try { if (code !== 0) throw new Error('Privileged home command did not pass its real UID probe'); resolve(JSON.parse(output)) }
|
|
159
|
+
catch (error) { reject(error) }
|
|
160
|
+
})
|
|
161
|
+
child.stdin.end(JSON.stringify({ action: 'status' }))
|
|
162
|
+
})
|
|
163
|
+
if (!status.commit) throw new Error('Protected home has no Git checkpoint')
|
|
164
|
+
await replace(`${STATE}/initialized`, 'tangle-agent-home-v2\n', 0o400)
|
|
165
|
+
return { home: PROTECTED_HOME, runtimeUid: uid, commit: status.commit }
|
|
166
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { Sandbox } from '@tangle-network/sandbox/core'
|
|
4
|
+
import { HubClient } from '@tangle-network/hub-sdk'
|
|
5
|
+
import { agentProfileSchema, defineAgentProfilePublicConfig as publicValue,
|
|
6
|
+
defineAgentProfileSecretRef as secretRef, mergeAgentProfiles } from '@tangle-network/agent-interface'
|
|
7
|
+
import { GENERAL_AGENT_MODEL, GENERAL_AGENT_SYSTEM_PROMPT, DEFAULT_AGENT_HOME,
|
|
8
|
+
withDefaultAgentHome, agentHomeWorkflows } from '../../dist/hosted-agent/index.js'
|
|
9
|
+
|
|
10
|
+
const name = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/)
|
|
11
|
+
const role = z.enum(['owner', 'manager', 'staff', 'vendor', 'finance', 'practitioner'])
|
|
12
|
+
const envKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
|
13
|
+
const domain = z.string().regex(/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9-]+$/)
|
|
14
|
+
const protectedProfile = z.record(z.string(), z.unknown()).superRefine((value, context) => {
|
|
15
|
+
if (Object.hasOwn(value, 'interactions')) context.addIssue({ code: 'custom',
|
|
16
|
+
message: 'A general-agent profile cannot override backend permission interactions' })
|
|
17
|
+
}).transform(value => agentProfileSchema.parse(value))
|
|
18
|
+
const backend = z.object({ type: z.literal('opencode').optional(), profile: protectedProfile }).strict()
|
|
19
|
+
.transform(value => ({ ...value, type: 'opencode', interactions: { permission: true } }))
|
|
20
|
+
const configSchema = z.object({
|
|
21
|
+
version: z.literal(1),
|
|
22
|
+
keyPrefix: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,99}$/),
|
|
23
|
+
environment: z.string().min(1),
|
|
24
|
+
model: z.string().min(1).default(GENERAL_AGENT_MODEL),
|
|
25
|
+
imageModel: z.string().min(1), videoModel: z.string().min(1),
|
|
26
|
+
runtimeModule: z.string().regex(/^\//).default('/opt/tangle-agent/node_modules/@tangle-network/agent-app/bin/tangle-agent.mjs'),
|
|
27
|
+
home: z.literal(DEFAULT_AGENT_HOME).default(DEFAULT_AGENT_HOME),
|
|
28
|
+
chromium: z.string().regex(/^\//),
|
|
29
|
+
proxyEnv: envKey.default('HTTPS_PROXY'), routerKeyEnv: envKey.default('OPENCODE_MODEL_API_KEY'),
|
|
30
|
+
allowDomains: z.array(domain).max(95).default([]),
|
|
31
|
+
resources: z.object({ cpuCores: z.number().int().min(2).max(64).default(2),
|
|
32
|
+
memoryMB: z.number().int().min(4096).max(262144).default(4096),
|
|
33
|
+
diskGB: z.number().int().min(10).max(2048).default(20) }).strict().prefault({}),
|
|
34
|
+
baseProfile: protectedProfile.prefault({}),
|
|
35
|
+
scopedServers: z.record(z.string(), z.array(name).min(1).max(16)).default({}),
|
|
36
|
+
members: z.array(z.object({ address: z.string().regex(/^\+[1-9]\d{6,14}$/), role,
|
|
37
|
+
label: z.string().min(1).max(60), backend: backend.optional(),
|
|
38
|
+
secretNames: z.array(z.string().regex(/^TANGLE_AGENT_PRODUCT_[A-Z0-9_]{1,96}$/)).max(16).optional() }).strict()).min(1).max(50),
|
|
39
|
+
lines: z.array(z.object({ id: z.string().regex(/^ln_[A-Za-z0-9_-]+$/),
|
|
40
|
+
transport: z.enum(['imessage', 'whatsapp']),
|
|
41
|
+
voice: z.object({ ph0nyConnectionId: z.string().min(1), ph0nyAgentId: z.string().min(1),
|
|
42
|
+
outboundFrom: z.string().regex(/^\+[1-9]\d{6,14}$/) }).strict().optional(),
|
|
43
|
+
}).strict()).min(1).max(8),
|
|
44
|
+
// No guessed owner timezone. Supplying this installs both disabled jobs;
|
|
45
|
+
// the operator enables them after consent and the deployed boundary proof.
|
|
46
|
+
heartbeat: z.object({ lineId: z.string().min(1), owner: z.string().regex(/^\+[1-9]\d{6,14}$/),
|
|
47
|
+
cron: z.literal('0 8-22 * * *').default('0 8-22 * * *'), timezone: z.string().min(1),
|
|
48
|
+
prompt: z.string().min(1).max(8000).optional(),
|
|
49
|
+
}).strict(),
|
|
50
|
+
}).strict()
|
|
51
|
+
|
|
52
|
+
export function buildGeneralAgent(configInput) {
|
|
53
|
+
const config = configSchema.parse(configInput)
|
|
54
|
+
if (new Set(config.members.map(member => member.address)).size !== config.members.length ||
|
|
55
|
+
new Set(config.lines.map(line => line.id)).size !== config.lines.length) throw new Error('Members and lines must be unique')
|
|
56
|
+
if (config.members.filter(member => member.role === 'owner').length !== 1) throw new Error('Exactly one enrolled owner is required for approvals')
|
|
57
|
+
if (config.baseProfile.prompt?.systemPrompt) throw new Error('Business presets add instructions and skills, not a replacement system prompt')
|
|
58
|
+
for (const line of config.lines) if (line.voice && line.transport !== 'imessage') throw new Error('Hub voice requires the Inkbox iMessage line')
|
|
59
|
+
const roles = {}, grants = {}, members = {}
|
|
60
|
+
for (const member of config.members) {
|
|
61
|
+
const general = ['owner', 'manager'].includes(member.role)
|
|
62
|
+
roles[member.role] = { context: 'own', tools: 'act' }
|
|
63
|
+
if (general) grants[member.role] = { kind: 'general' }
|
|
64
|
+
else {
|
|
65
|
+
const servers = config.scopedServers[member.role]
|
|
66
|
+
if (!servers?.length || !member.backend) throw new Error(`Scoped ${member.role} needs an enrolled product backend and named MCP servers`)
|
|
67
|
+
grants[member.role] = { kind: 'scoped', mcpServers: servers }
|
|
68
|
+
}
|
|
69
|
+
if (member.backend) members[member.address] = { backend: member.backend,
|
|
70
|
+
...(member.secretNames ? { secretNames: member.secretNames } : {}) }
|
|
71
|
+
}
|
|
72
|
+
const substrate = agentProfileSchema.parse({
|
|
73
|
+
name: 'tangle-agent-v1', model: { default: config.model },
|
|
74
|
+
prompt: { systemPrompt: GENERAL_AGENT_SYSTEM_PROMPT, instructions: [
|
|
75
|
+
`Your durable home is ${config.home}. It is preinstalled and root-owned. Read AGENTS.md and SOUL.md there. Use home_read, home_write, home_append, home_status, home_bootstrap and home_consolidate. Ordinary shell and code remain available outside this protected directory. Never create an unprotected replacement home.`,
|
|
76
|
+
'The protected writer enforces Unicode-character, byte and file-count caps before any memory mutation. Every successful mutation returns a Git commit. A SOUL.md change requires an immediate owner notice. Keep credentials and third-party private data out of memory.',
|
|
77
|
+
'Use the published tangle_agent tools for Router search/read, Browser Agent and image/video generation. Use native computer-use tools for the virtual display and explicitly mounted Hub connections for email, calendar and ph0ny. Do not substitute direct provider APIs or disable the allowlist proxy.',
|
|
78
|
+
'The platform scheduler owns reminders and maintenance. The hourly heartbeat and nightly consolidation use the same consenting owner session. A queued workflow is not a completed reminder; report the actual workflow and eventual line receipts.',
|
|
79
|
+
] },
|
|
80
|
+
mcp: { tangle_agent: { transport: 'stdio', command: 'node',
|
|
81
|
+
args: [publicValue(config.runtimeModule), publicValue('serve')],
|
|
82
|
+
env: {
|
|
83
|
+
TANGLE_AGENT_ROUTER_KEY: secretRef(config.routerKeyEnv),
|
|
84
|
+
TANGLE_AGENT_BROWSER_PROXY: secretRef(config.proxyEnv),
|
|
85
|
+
TANGLE_AGENT_HOME: publicValue(config.home), TANGLE_AGENT_CHROMIUM: publicValue(config.chromium),
|
|
86
|
+
TANGLE_AGENT_MODEL: publicValue(config.model), TANGLE_AGENT_IMAGE_MODEL: publicValue(config.imageModel),
|
|
87
|
+
TANGLE_AGENT_VIDEO_MODEL: publicValue(config.videoModel),
|
|
88
|
+
}, enabled: true } },
|
|
89
|
+
resources: { failOnError: true },
|
|
90
|
+
})
|
|
91
|
+
const composed = mergeAgentProfiles(config.baseProfile, substrate)
|
|
92
|
+
if (!composed) throw new Error('General profile composition failed')
|
|
93
|
+
const profile = withDefaultAgentHome(composed)
|
|
94
|
+
for (const line of config.lines) {
|
|
95
|
+
if (!line.voice) continue
|
|
96
|
+
const { ph0nyConnectionId, ph0nyAgentId, outboundFrom } = line.voice
|
|
97
|
+
const capabilities = ['phony.start_outbound_call', 'phony.get_call']
|
|
98
|
+
const existing = profile.connections?.find(value => value.connectionId === ph0nyConnectionId)
|
|
99
|
+
if (existing) {
|
|
100
|
+
if (!existing.capabilities.includes('*')) existing.capabilities = [...new Set([...existing.capabilities, ...capabilities])]
|
|
101
|
+
} else profile.connections = [...(profile.connections ?? []), { connectionId: ph0nyConnectionId, capabilities }]
|
|
102
|
+
profile.prompt.instructions.push(`Voice line ${line.id}: use the connected ph0ny tools with agentId ${ph0nyAgentId} and the operator-attested fromNumber ${outboundFrom}. The enrolled owner destination is ${config.members.find(member => member.role === 'owner').address}. Wait for owner approval and actual consent. The call hook rechecks membership and binds the destination member's role and private workspace. Report the actual call id and connected/completed state, not a submission alone.`)
|
|
103
|
+
}
|
|
104
|
+
agentProfileSchema.parse(profile)
|
|
105
|
+
const respond = { kind: 'agent', backend: {
|
|
106
|
+
type: 'opencode', profile, interactions: { permission: true },
|
|
107
|
+
}, rolePolicy: { version: 1, roles: grants, members } }
|
|
108
|
+
const attachments = config.lines.map(line => ({
|
|
109
|
+
number: line.id, mode: 'shared', unknownSenders: 'reject', roles,
|
|
110
|
+
members: config.members.map(({ address, role, label }) => ({ address, role, label })),
|
|
111
|
+
respond, clientReference: config.keyPrefix,
|
|
112
|
+
instance: { keyPrefix: config.keyPrefix, create: {
|
|
113
|
+
name: 'tangle-agent-v1', environment: config.environment, ownerContext: 'isolated', capabilities: ['computer_use'],
|
|
114
|
+
resources: config.resources,
|
|
115
|
+
egressPolicy: { mode: 'strict', includeImplicitDomains: false,
|
|
116
|
+
allowDomains: [...new Set(['router.tangle.tools', 'id.tangle.tools', ...config.allowDomains])] },
|
|
117
|
+
idleTimeoutSeconds: 600, maxLifetimeSeconds: 90 * 86400, deleteAfterStoppedSeconds: 90 * 86400,
|
|
118
|
+
} },
|
|
119
|
+
}))
|
|
120
|
+
const h = config.heartbeat
|
|
121
|
+
if (!config.lines.some(line => line.id === h.lineId) ||
|
|
122
|
+
!config.members.some(member => member.address === h.owner && member.role === 'owner')) {
|
|
123
|
+
throw new Error('Heartbeat must target a configured line and its enrolled owner')
|
|
124
|
+
}
|
|
125
|
+
const workflows = agentHomeWorkflows({ instanceKey: config.keyPrefix, lineId: h.lineId,
|
|
126
|
+
ownerAddress: h.owner, timeZone: h.timezone, heartbeatInstructions: h.prompt })
|
|
127
|
+
return { config, attachments, workflows, heartbeat: workflows.find(job => job.purpose === 'heartbeat').yaml }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function provision(path, apply = false) {
|
|
131
|
+
const plan = buildGeneralAgent(JSON.parse(await readFile(path, 'utf8')))
|
|
132
|
+
if (!apply) {
|
|
133
|
+
console.log(JSON.stringify({ attachments: plan.attachments, workflows: plan.workflows }, null, 2))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const key = process.env.TANGLE_API_KEY
|
|
137
|
+
if (!key) throw new Error('TANGLE_API_KEY must be supplied to the trusted operator, never the agent image')
|
|
138
|
+
const sandbox = new Sandbox({ apiKey: key, baseUrl: process.env.SANDBOX_BASE_URL ?? 'https://sandbox.tangle.tools' })
|
|
139
|
+
const hub = new HubClient({ apiKey: key, baseUrl: 'https://id.tangle.tools' })
|
|
140
|
+
// Validate all owned transports first. SDK attach is idempotent for the
|
|
141
|
+
// exact config and refuses a changed active attachment. Never auto-detach.
|
|
142
|
+
for (const line of plan.config.lines) {
|
|
143
|
+
const current = await sandbox.lines.get(line.id)
|
|
144
|
+
if (current.transport !== line.transport || current.status !== 'active') throw new Error('Line is not an active owned line on the declared transport')
|
|
145
|
+
}
|
|
146
|
+
const receipts = []
|
|
147
|
+
for (let i = 0; i < plan.attachments.length; i++) {
|
|
148
|
+
const attachment = await sandbox.lines.attach(plan.attachments[i])
|
|
149
|
+
const line = plan.config.lines[i]
|
|
150
|
+
if (line.voice) {
|
|
151
|
+
const { ph0nyConnectionId, ph0nyAgentId } = line.voice
|
|
152
|
+
await sandbox.lines.enableVoice(line.id, { ph0nyConnectionId, ph0nyAgentId })
|
|
153
|
+
}
|
|
154
|
+
receipts.push({ line: await sandbox.lines.get(line.id), attachment })
|
|
155
|
+
console.log(JSON.stringify({ state: 'attached', lineId: line.id, attachmentId: attachment.id }))
|
|
156
|
+
}
|
|
157
|
+
const existing = await hub.workflows.list()
|
|
158
|
+
const workflows = []
|
|
159
|
+
for (const job of plan.workflows) {
|
|
160
|
+
const matches = existing.filter(value => value.name === job.name)
|
|
161
|
+
if (matches.length > 1) throw new Error('Duplicate home workflows exist; refuse to create another')
|
|
162
|
+
const workflow = matches.length ? await hub.workflows.update(matches[0].id, job.yaml)
|
|
163
|
+
: await hub.workflows.create(job.yaml)
|
|
164
|
+
workflows.push({ purpose: job.purpose, workflow })
|
|
165
|
+
}
|
|
166
|
+
console.log(JSON.stringify({ receipts, workflows,
|
|
167
|
+
next: 'Do not use this attachment until ADC checks exact native approval on each member instance and the local allow/deny effect-sink proof passes. Then the owner must text first. Verify the actual runtime UID, protected home, scoped vendor denial and installed tools before enabling the two disabled Hub workflows.' }, null, 2))
|
|
168
|
+
}
|